diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs
index 43d13058e3..ead8c806a6 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs
@@ -333,6 +333,40 @@ public class ConditionalImportTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
";
private static ViewDefinitionModel Import(string className, string refName)
@@ -342,6 +376,200 @@ private static ViewDefinitionModel Import(string className, string refName)
$""), parts);
}
+ private static ViewDefinitionModel ImportLayout(string className, string layoutContent)
+ {
+ var parts = new DictionaryPartResolver(XElement.Parse(PartsXml));
+ return new XmlLayoutImporter().Import(XElement.Parse(
+ $"{layoutContent}"), parts);
+ }
+
+ [Test]
+ public void PartRefOverrides_ReachASliceInsideEachIf()
+ {
+ var model = ImportLayout("MoInflAffixTemplate",
+ "");
+
+ Assert.That(model.Roots.Count, Is.EqualTo(2), "one conditional per ");
+ foreach (var conditional in model.Roots)
+ {
+ var slice = conditional.Children.Single();
+ Assert.That(slice.Label, Is.EqualTo("Template Name"));
+ Assert.That(slice.Abbreviation, Is.EqualTo("TN"));
+ Assert.That(slice.MenuId, Is.EqualTo("mnuDataTree-POS-AffixTemplate"));
+ Assert.That(slice.HotlinksId, Is.EqualTo("mnuTemplate-Hotlinks"));
+ Assert.That(slice.Visibility, Is.EqualTo(ViewVisibility.IfData));
+ }
+ }
+
+ [Test]
+ public void PartRefOverrides_ReachSlicesInsideWhereAndOtherwise()
+ {
+ var model = ImportLayout("MoAffixAllomorph",
+ "");
+
+ var choice = model.Roots.Single();
+ foreach (var branch in choice.Children)
+ {
+ var slice = branch.Children.Single();
+ Assert.That(slice.Label, Is.EqualTo("Position"));
+ Assert.That(slice.MenuId, Is.EqualTo("mnuPosition"));
+ }
+ }
+
+ [Test]
+ public void PartRefOverrides_ReachASliceInsideANestedConditional()
+ {
+ var model = ImportLayout("MoInflAffixTemplate",
+ "");
+
+ var slice = model.Roots.Single().Children.Single().Children.Single();
+ Assert.That(slice.Kind, Is.EqualTo(ViewNodeKind.Field));
+ Assert.That(slice.Label, Is.EqualTo("Template Name"));
+ }
+
+ // A sequence item's heading takes its menu from the item layout's root nodes.
+ [Test]
+ public void PartRefLabelAndMenu_StayOnTheConditionalNode()
+ {
+ var model = ImportLayout("MoInflAffixTemplate",
+ "");
+
+ Assert.That(model.Roots.Select(r => r.Label), Is.All.EqualTo("Template Name"));
+ Assert.That(model.Roots.Select(r => r.MenuId),
+ Is.All.EqualTo("mnuDataTree-POS-AffixTemplate"));
+ }
+
+ // Field Visibility reads and sets the row's own node, so that node holds the value.
+ [Test]
+ public void PartRefVisibility_IsHeldByTheContent_NotTheConditional()
+ {
+ var template = ImportLayout("MoInflAffixTemplate",
+ "");
+ foreach (var conditional in template.Roots)
+ {
+ Assert.That(conditional.Visibility, Is.EqualTo(ViewVisibility.Always));
+ Assert.That(conditional.Children.Single().Visibility,
+ Is.EqualTo(ViewVisibility.Never));
+ }
+
+ var choice = ImportLayout("PartOfSpeech",
+ "").Roots.Single();
+ Assert.That(choice.Visibility, Is.EqualTo(ViewVisibility.Always));
+ Assert.That(choice.Children[1].Children.Single().Visibility,
+ Is.EqualTo(ViewVisibility.Never));
+ }
+
+ // Field Visibility on one branch's row reaches every branch of the same part ref.
+ [Test]
+ public void PartRefBranches_ShareOneVisibilityTarget()
+ {
+ var model = ImportLayout("MoInflAffixTemplate",
+ ""
+ + "");
+ var first = model.Roots.Take(2).Select(c => c.Children.Single()).ToList();
+ var second = model.Roots.Skip(2).Select(c => c.Children.Single()).ToList();
+ var active = first[0].Children.Single();
+ Assert.That(model.Roots.Select(r => r.PartRefId), Is.All.Null);
+
+ // The applier and the canonical JSON both keep the part ref.
+ var hideOne = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility,
+ first[1].StableId, visibility: ViewVisibility.Never);
+ var patched = ViewDefinitionOverrideApplier.Apply(
+ ViewDefinitionJsonSerializer.Deserialize(ViewDefinitionJsonSerializer.Serialize(model)),
+ new ViewDefinitionOverride("MoInflAffixTemplate", "T", "detail", new[] { hideOne }, null));
+
+ Assert.That(ViewDefinitionOverrideEditor.LocateTarget(patched, first[1].StableId)
+ .VisibilityTargets, Is.EqualTo(first.Select(s => s.StableId)));
+ Assert.That(ViewDefinitionOverrideEditor.LocateTarget(patched, second[0].StableId)
+ .VisibilityTargets, Is.EqualTo(second.Select(s => s.StableId)));
+ Assert.That(ViewDefinitionOverrideEditor.LocateTarget(patched, active.StableId)
+ .VisibilityTargets, Is.EqualTo(new[] { active.StableId }),
+ "the part ref's own child rows are rows of their own");
+ }
+
+ [Test]
+ public void PartRefChildren_LandUnderTheSliceInsideTheConditional()
+ {
+ var model = ImportLayout("MoInflAffixTemplate",
+ "");
+
+ foreach (var conditional in model.Roots)
+ {
+ var child = conditional.Children.Single().Children.Single();
+ Assert.That(child.Field, Is.EqualTo("Disabled"));
+ Assert.That(child.Indented, Is.True);
+ }
+ }
+
+ [Test]
+ public void SequenceInsideAChoice_TakesTheTargetLayout_ButKeepsItsOwnLabel()
+ {
+ var model = ImportLayout("PartOfSpeech",
+ "");
+
+ var sequence = model.Roots.Single().Children[1].Children.Single();
+ Assert.That(sequence.Kind, Is.EqualTo(ViewNodeKind.Sequence));
+ Assert.That(sequence.TargetLayout, Is.EqualTo("nested"));
+ Assert.That(sequence.MenuId, Is.EqualTo("mnuSub"));
+ Assert.That(sequence.Label, Is.EqualTo("Own Label"));
+ Assert.That(sequence.Abbreviation, Is.Null);
+ }
+
+ [Test]
+ public void ObjectInsideAnIf_TakesTheRefsLayoutAndChildren_ButNotItsLabel()
+ {
+ var model = ImportLayout("PartOfSpeech",
+ ""
+ + "");
+
+ var atom = model.Roots.Single().Children.Single();
+ Assert.That(atom.Kind, Is.EqualTo(ViewNodeKind.ObjectAtom));
+ Assert.That(atom.TargetLayout, Is.EqualTo("Brief"));
+ Assert.That(atom.Visibility, Is.EqualTo(ViewVisibility.IfData));
+ Assert.That(atom.Label, Is.Null);
+ Assert.That(atom.Children.Single().Field, Is.EqualTo("Name"));
+ }
+
+ [Test]
+ public void ConditionalWrittenInALayout_LeavesItsPartRefsAlone()
+ {
+ var model = ImportLayout("MoInflAffixTemplate",
+ "");
+
+ var conditional = model.Roots.Single();
+ Assert.That(conditional.Label, Is.Null);
+ Assert.That(conditional.Children.Single().Label, Is.EqualTo("Active"));
+ Assert.That(model.Diagnostics, Is.Empty);
+ }
+
+ [Test]
+ public void ChoiceWrittenInALayout_KeepsItsOwnVisibility()
+ {
+ var choice = ImportLayout("MoInflAffixTemplate",
+ ""
+ + "").Roots.Single();
+ Assert.That(choice.Kind, Is.EqualTo(ViewNodeKind.ChoiceGroup));
+ Assert.That(choice.Visibility, Is.EqualTo(ViewVisibility.Never));
+
+ // An has no visibility of its own: the attribute is not a condition attribute.
+ var ifModel = ImportLayout("MoInflAffixTemplate",
+ "");
+ Assert.That(ifModel.Roots, Is.Empty);
+ Assert.That(ifModel.Diagnostics.Single().Code, Is.EqualTo("conditional-dropped"));
+ }
+
+ [Test]
+ public void PartRefDiagnostics_AreReportedOncePerConditional_NotAgainPerSlice()
+ {
+ var model = ImportLayout("MoInflAffixTemplate",
+ "");
+
+ Assert.That(model.Diagnostics.Count(d => d.Code == "unhandled-attribute"),
+ Is.EqualTo(model.Roots.Count));
+ }
+
[Test]
public void If_ImportsConditionalNode_WithStructuredIntEqualsCondition()
{
@@ -787,6 +1015,28 @@ public void ShippedLexEntryRefNormal_ImportsTheRefTypeConditionals()
"every condition form LexEntryRef/Normal uses is in the supported set");
}
+ ///
+ /// The affix template's name part wraps its slice in a Disabled test, and its layout
+ /// renames the row and binds the template menu.
+ ///
+ [Test]
+ public void ShippedMoInflAffixTemplateDefault_NameRow_TakesTheLayoutLabelAndMenu()
+ {
+ var repoRoot = FindRepoRoot();
+ var (layouts, parts) = LoadShippedFiles(repoRoot);
+ var model = ImportShippedLayout(repoRoot, layouts, parts, "MoInflAffixTemplate", "default");
+
+ var nameRows = model.Roots
+ .Where(r => r.Kind == ViewNodeKind.Conditional)
+ .SelectMany(r => r.Children)
+ .Where(n => n.Field == "Name")
+ .ToList();
+ Assert.That(nameRows.Count, Is.EqualTo(2), "one per Disabled state");
+ Assert.That(nameRows.Select(n => n.Label), Is.All.EqualTo("Template Name"));
+ Assert.That(nameRows.Select(n => n.MenuId),
+ Is.All.EqualTo("mnuDataTree-POS-AffixTemplate"));
+ }
+
///
/// The shipped lexeme-form ghost configuration must arrive complete on the typed
/// node -- ghost/ghostWs/ghostLabel, the explicit ghostClass (MoStemAllomorph, differing
diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs
index c59990e1d4..4bb5bc14fe 100644
--- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs
+++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs
@@ -95,6 +95,7 @@ private static JObject WriteNode(ViewNode node)
if (node.ForceIncludeEnglish)
o["forceIncludeEnglish"] = true;
AddIfPresent(o, "visField", node.VisibilityField);
+ AddIfPresent(o, "partRef", node.PartRefId);
// The stored selection travels with the two facts above: options without the
// checked set would round-trip a field into the wrong checkmarks.
if (node.VisibleWritingSystems != null && node.VisibleWritingSystems.Count > 0)
@@ -228,6 +229,7 @@ private static ViewNode ReadNode(JToken token)
optionalWritingSystem: (string)o["optionalWs"],
forceIncludeEnglish: (bool?)o["forceIncludeEnglish"] ?? false,
visibilityField: (string)o["visField"],
+ partRefId: (string)o["partRef"],
visibleWritingSystems: ((JArray)o["visibleWritingSystems"])?.Select(t => (string)t).ToList());
}
diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs
index 9dd2076aac..191efe6ede 100644
--- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs
+++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs
@@ -398,8 +398,10 @@ public ViewNode(
string sliceId = null,
string optionalWritingSystem = null,
bool forceIncludeEnglish = false,
- string visibilityField = null)
+ string visibilityField = null,
+ string partRefId = null)
{
+ PartRefId = partRefId;
VisibilityField = visibilityField;
OptionalWritingSystem = optionalWritingSystem;
ForceIncludeEnglish = forceIncludeEnglish;
@@ -508,6 +510,13 @@ public ViewNode(
///
public string VisibilityField { get; }
+ ///
+ /// The layout part ref this node came from, when that part's content sits inside a
+ /// conditional: every branch's node then holds the part ref's own values, visibility
+ /// included. Null on every other node.
+ ///
+ public string PartRefId { get; }
+
public IReadOnlyList Children { get; }
///
diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs
index 4eb761c6fa..403e8b6de0 100644
--- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs
+++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs
@@ -314,10 +314,11 @@ private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string
helpTopicId: n.HelpTopicId, sliceId: n.SliceId,
optionalWritingSystem: n.OptionalWritingSystem,
forceIncludeEnglish: n.ForceIncludeEnglish,
- visibilityField: n.VisibilityField);
+ visibilityField: n.VisibilityField,
+ partRefId: n.PartRefId);
- // Copy a (leaf) node under a new StableId; AutomationId is dropped so the duplicate gets a fresh,
- // non-colliding identity (the renderer derives one from the new StableId by convention).
+ // Copy a (leaf) node under a new StableId, as a row of its own: no AutomationId
+ // (the renderer derives one from the new id) and no PartRefId.
private static ViewNode CloneWithId(ViewNode n, string newId)
=> new ViewNode(
newId, n.Kind, n.Label, n.Abbreviation, n.Field, n.RawEditor, n.EditorClassification,
diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs
index 96e8ddefdc..35767db4ea 100644
--- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs
+++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs
@@ -68,14 +68,11 @@ public static ViewNodeLocation LocateTarget(ViewDefinitionModel model, string te
// Root-level scan first (parent is null).
var rootIndex = IndexOf(model.Roots, templateStableId);
if (rootIndex >= 0)
- {
- return new ViewNodeLocation(model.Roots[rootIndex].Visibility, null,
- model.Roots.Select(n => n.StableId).ToList(), rootIndex);
- }
+ return At(model.Roots, null, model.Roots, rootIndex);
foreach (var root in model.Roots)
{
- var found = LocateUnder(root, templateStableId);
+ var found = LocateUnder(model.Roots, root, templateStableId);
if (found != null)
return found;
}
@@ -83,18 +80,16 @@ public static ViewNodeLocation LocateTarget(ViewDefinitionModel model, string te
return null;
}
- private static ViewNodeLocation LocateUnder(ViewNode parent, string templateStableId)
+ private static ViewNodeLocation LocateUnder(IReadOnlyList roots, ViewNode parent,
+ string templateStableId)
{
var index = IndexOf(parent.Children, templateStableId);
if (index >= 0)
- {
- return new ViewNodeLocation(parent.Children[index].Visibility, parent.StableId,
- parent.Children.Select(n => n.StableId).ToList(), index);
- }
+ return At(roots, parent.StableId, parent.Children, index);
foreach (var child in parent.Children)
{
- var found = LocateUnder(child, templateStableId);
+ var found = LocateUnder(roots, child, templateStableId);
if (found != null)
return found;
}
@@ -102,6 +97,36 @@ private static ViewNodeLocation LocateUnder(ViewNode parent, string templateStab
return null;
}
+ private static ViewNodeLocation At(IReadOnlyList roots, string parentStableId,
+ IReadOnlyList siblings, int index)
+ {
+ var node = siblings[index];
+ return new ViewNodeLocation(node.Visibility, parentStableId,
+ siblings.Select(n => n.StableId).ToList(), index, VisibilityTargetsOf(roots, node));
+ }
+
+ // The node alone, or every node that holds the same part ref's visibility.
+ private static IReadOnlyList VisibilityTargetsOf(IReadOnlyList roots,
+ ViewNode node)
+ {
+ if (string.IsNullOrEmpty(node.PartRefId))
+ return new[] { node.StableId };
+ var ids = new List();
+ CollectPartRefNodes(roots, node.PartRefId, ids);
+ return ids;
+ }
+
+ private static void CollectPartRefNodes(IReadOnlyList nodes, string partRefId,
+ List ids)
+ {
+ foreach (var node in nodes)
+ {
+ if (string.Equals(node.PartRefId, partRefId, StringComparison.Ordinal))
+ ids.Add(node.StableId);
+ CollectPartRefNodes(node.Children, partRefId, ids);
+ }
+ }
+
private static int IndexOf(IReadOnlyList nodes, string id)
{
for (var i = 0; i < nodes.Count; i++)
@@ -178,15 +203,29 @@ public static ViewDefinitionOverride MergeOperation(ViewDefinitionOverride patch
///
public sealed class ViewNodeLocation
{
+ /// The template StableIds a visibility change writes;
+ /// null means the node alone.
public ViewNodeLocation(ViewVisibility visibility, string parentStableId,
- IReadOnlyList siblingOrder, int index)
+ IReadOnlyList siblingOrder, int index,
+ IReadOnlyList visibilityTargets = null)
{
Visibility = visibility;
ParentStableId = parentStableId;
SiblingOrder = siblingOrder ?? Array.Empty();
Index = index;
+ VisibilityTargets = visibilityTargets
+ ?? (index >= 0 && index < SiblingOrder.Count
+ ? new[] { SiblingOrder[index] }
+ : Array.Empty());
}
+ ///
+ /// The template StableIds a Field Visibility change writes, this node's among them.
+ /// A part ref whose content sits in a conditional has one node per branch; all of
+ /// them hold its visibility, so the change reaches every branch.
+ ///
+ public IReadOnlyList VisibilityTargets { get; }
+
/// The node's current visibility (after any override already applied to the model).
public ViewVisibility Visibility { get; }
diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs
index cca2809a77..ee550ccf1a 100644
--- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs
+++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs
@@ -242,7 +242,7 @@ private void ProcessPart(
// Each sibling after the first needs its own stable id so they don't collide.
var childStableId = i == 0 ? stableId : $"{parentPath}/#{output.Count}";
var node = CreateNode(contents[i], callerEl, parts, className, layoutType, childStableId,
- indented, diagnostics);
+ indented, diagnostics, partRefId: stableId);
if (node != null)
{
output.Add(node);
@@ -258,8 +258,16 @@ private ViewNode CreateNode(
string layoutType,
string stableId,
bool indented,
- List diagnostics)
+ List diagnostics,
+ bool insideConditional = false,
+ string partRefId = null)
{
+ // Content inside a part's conditional takes the part ref as its caller; the
+ // conditional's own node has already reported that part ref.
+ var hasPartRef = !ReferenceEquals(callerEl, contentEl);
+ var reportPartRef = hasPartRef && !insideConditional;
+ // Each branch's node holds the part ref's values, so each records which part ref.
+ var heldPartRefId = insideConditional ? partRefId : null;
var label = Attr(callerEl, "label") ?? Attr(contentEl, "label");
var abbreviation = Attr(callerEl, "abbr") ?? Attr(contentEl, "abbr");
var visibility = ParseVisibility(Attr(callerEl, "visibility") ?? Attr(contentEl, "visibility"));
@@ -270,7 +278,7 @@ private ViewNode CreateNode(
// Report attributes the importer drops instead of dropping them silently. The caller
// element is reported only when distinct from the content element (inline children pass the
// same element for both).
- if (!ReferenceEquals(callerEl, contentEl))
+ if (reportPartRef)
{
ReportUnhandledAttributes(callerEl, HandledCallerPartAttributes, "part ref", stableId, diagnostics);
ReportSubstitutionValues(callerEl, HandledCallerPartAttributes, stableId, diagnostics);
@@ -380,7 +388,7 @@ private ViewNode CreateNode(
// part, e.g. AsLexemeForm's MorphTypeBasic) become child nodes, mirroring how
// DataTree.ProcessPartRefNode realizes them as indented child slices. Other caller
// child kinds are reported, not silently dropped.
- if (!ReferenceEquals(callerEl, contentEl))
+ if (hasPartRef)
{
var structuralCallerChildren = new List();
foreach (var callerChild in callerEl.Elements())
@@ -412,7 +420,8 @@ private ViewNode CreateNode(
sliceId: Attr(contentEl, "id"),
helpTopicId: Attr(contentEl, "helpTopicID"),
optionalWritingSystem: optionalWs,
- forceIncludeEnglish: forceEnglish);
+ forceIncludeEnglish: forceEnglish,
+ partRefId: heldPartRefId);
}
// Dynamic custom slices keep their legacy class/assembly identity so the host can
@@ -440,12 +449,22 @@ private ViewNode CreateNode(
helpTopicId: Attr(contentEl, "helpTopicID"),
optionalWritingSystem: optionalWs,
forceIncludeEnglish: forceEnglish,
- visibilityField: Attr(contentEl, "visField"));
+ visibilityField: Attr(contentEl, "visField"),
+ partRefId: heldPartRefId);
}
case "obj":
case "seq":
{
var kind = contentEl.Name.LocalName == "obj" ? ViewNodeKind.ObjectAtom : ViewNodeKind.Sequence;
+ // An object or sequence inside a conditional keeps its own label: the part
+ // ref's label names the section above it and would repeat as a second
+ // heading.
+ if (insideConditional)
+ {
+ label = Attr(contentEl, "label");
+ abbreviation = Attr(contentEl, "abbr");
+ localizationKey = Attr(contentEl, "localizationKey") ?? Attr(contentEl, "labelId");
+ }
var targetLayout = Attr(callerEl, "param") ?? Attr(contentEl, "layout");
ReportUnhandledAttributes(contentEl, HandledObjSeqAttributes, contentEl.Name.LocalName, stableId, diagnostics);
ReportSubstitutionValues(contentEl, HandledObjSeqAttributes, stableId, diagnostics);
@@ -463,7 +482,8 @@ private ViewNode CreateNode(
ghostLabel: Attr(contentEl, "ghostLabel") ?? Attr(callerEl, "ghostLabel"),
// The layout's post-create hook rides the node so the composer's ghost
// setter can invoke it the way GhostStringSliceView.MakeRealObject does.
- ghostInitMethod: Attr(contentEl, "ghostInitMethod") ?? Attr(callerEl, "ghostInitMethod"));
+ ghostInitMethod: Attr(contentEl, "ghostInitMethod") ?? Attr(callerEl, "ghostInitMethod"),
+ partRefId: heldPartRefId);
}
// Conditional display: / shows content only when the condition
// passes (fails, for ifnot), evaluated via XmlVc.ConditionPasses. Preserved
@@ -477,13 +497,14 @@ private ViewNode CreateNode(
return null; // unsupported condition form; diagnostic already raised
var children = new List();
- AddConditionalChildren(contentEl, parts, className, layoutType, stableId, indented,
- children, diagnostics);
+ AddConditionalChildren(contentEl, hasPartRef ? callerEl : null, partRefId, parts,
+ className, layoutType, stableId, indented, children, diagnostics);
+ // A part ref's visibility is held by the content nodes, one per row.
return new ViewNode(stableId, ViewNodeKind.Conditional, label, abbreviation,
Attr(contentEl, "field"), null, EditorClassification.GroupingNone, null,
- visibility, expansion, indented, null, children, localizationKey, automationId,
- routing, menuId: menuId, contextMenuId: contextMenuId, hotlinksId: hotlinksId,
- condition: condition);
+ hasPartRef ? ViewVisibility.Always : visibility, expansion, indented, null,
+ children, localizationKey, automationId, routing, menuId: menuId,
+ contextMenuId: contextMenuId, hotlinksId: hotlinksId, condition: condition);
}
// holds branches (first passing one renders) and an optional
@@ -514,8 +535,8 @@ private ViewNode CreateNode(
}
var branchChildren = new List();
- AddConditionalChildren(clause, parts, className, layoutType, branchId, indented,
- branchChildren, diagnostics);
+ AddConditionalChildren(clause, hasPartRef ? callerEl : null, partRefId, parts,
+ className, layoutType, branchId, indented, branchChildren, diagnostics);
branches.Add(new ViewNode(branchId, ViewNodeKind.Conditional, null, null,
Attr(clause, "field"), null, EditorClassification.GroupingNone, null,
ViewVisibility.Always, ViewExpansion.NotApplicable, indented, null,
@@ -523,8 +544,9 @@ private ViewNode CreateNode(
}
return new ViewNode(stableId, ViewNodeKind.ChoiceGroup, label, abbreviation, null,
- null, EditorClassification.GroupingNone, null, visibility, expansion, indented,
- null, branches, localizationKey, automationId, routing, menuId: menuId,
+ null, EditorClassification.GroupingNone, null,
+ hasPartRef ? ViewVisibility.Always : visibility, expansion, indented, null,
+ branches, localizationKey, automationId, routing, menuId: menuId,
contextMenuId: contextMenuId, hotlinksId: hotlinksId);
}
@@ -606,11 +628,16 @@ private static ViewStringList ImportStringList(
return new ViewStringList(idList, Attr(stringListEl, "group"));
}
- // A conditional wrapper's children are part content (//)
- // inside part definitions, or / refs at layout level,
- // matching DataTree.ProcessPartChildren's dispatch.
+ ///
+ /// Adds a conditional's children: part content (slice, seq, obj) when the conditional
+ /// sits in a part, or part and indent refs when it sits in a layout. Part content takes
+ /// the label, menu and other overrides of , the part ref
+ /// whose part holds the conditional; null for a conditional written in a layout.
+ ///
private void AddConditionalChildren(
XElement container,
+ XElement partRef,
+ string partRefId,
IPartResolver parts,
string className,
string layoutType,
@@ -630,8 +657,9 @@ private void AddConditionalChildren(
break;
default:
{
- var node = CreateNode(child, child, parts, className, layoutType,
- $"{parentPath}/#{output.Count}", indented, diagnostics);
+ var node = CreateNode(child, partRef ?? child, parts, className, layoutType,
+ $"{parentPath}/#{output.Count}", indented, diagnostics,
+ insideConditional: partRef != null, partRefId: partRefId);
if (node != null)
output.Add(node);
break;
diff --git a/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs
index 5e444e124c..58c156342d 100644
--- a/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs
+++ b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs
@@ -121,15 +121,11 @@ private DetailMenuItem HelpItem(string label)
///
internal sealed class OverrideTarget
{
- public OverrideTarget(string templateId, ViewNodeLocation location)
+ public OverrideTarget(ViewNodeLocation location)
{
- TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId));
Location = location ?? throw new ArgumentNullException(nameof(location));
}
- /// The row's template id, without its runtime suffix.
- public string TemplateId { get; }
-
/// The node's position and visibility among its siblings.
public ViewNodeLocation Location { get; }
}
diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
index 59158cd365..7ba614c352 100644
--- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
+++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
@@ -765,14 +765,14 @@ internal void AddOverrideCommands(OverrideCommandRegistry registry, DetailField
// Unknown/stale target: leave the field commands on mediator dispatch rather than
// guess.
- if (!TryLocateOverrideTarget(field, out var templateId, out var location))
+ if (!TryLocateOverrideTarget(field, out _, out var location))
return;
registry.Add("CmdAlwaysVisible",
- (c, d) => VisibilityItem(LabelOf(d), field, templateId, location, ViewVisibility.Always));
+ (c, d) => VisibilityItem(LabelOf(d), field, location, ViewVisibility.Always));
registry.Add("CmdIfData",
- (c, d) => VisibilityItem(LabelOf(d), field, templateId, location, ViewVisibility.IfData));
+ (c, d) => VisibilityItem(LabelOf(d), field, location, ViewVisibility.IfData));
registry.Add("CmdNormallyHidden",
- (c, d) => VisibilityItem(LabelOf(d), field, templateId, location, ViewVisibility.Never));
+ (c, d) => VisibilityItem(LabelOf(d), field, location, ViewVisibility.Never));
registry.Add("CmdDataTree-MoveFieldUp",
(c, d) => MoveItem(LabelOf(d), field, location, up: true));
registry.Add("CmdDataTree-MoveFieldDown",
@@ -817,7 +817,7 @@ private IDetailMenuAuthority CreateMultiStringMenuAuthority(DetailField field,
internal IDetailMenuAuthority CreateObjectMenuAuthority(DetailField field)
=> new ObjectMenuAuthority(field, LocateOverrideTarget,
fieldVisibility: (label, target, visibility) =>
- VisibilityItem(label, field, target.TemplateId, target.Location, visibility),
+ VisibilityItem(label, field, target.Location, visibility),
moveField: (label, target, up) => MoveItem(label, field, target.Location, up),
helpTopic: KnownHelpTopic,
showHelp: ShowDetailHelp);
@@ -828,8 +828,8 @@ private OverrideTarget LocateOverrideTarget(DetailField field)
{
try
{
- return TryLocateOverrideTarget(field, out var templateId, out var location)
- ? new OverrideTarget(templateId, location)
+ return TryLocateOverrideTarget(field, out _, out var location)
+ ? new OverrideTarget(location)
: null;
}
catch (Exception e)
@@ -929,11 +929,11 @@ private static bool TryTemplateIdOf(string stableId, out string templateId)
// SetVisibility override mutation (idempotent -- re-choosing the current value is a
// harmless write).
private DetailMenuItem VisibilityItem(string label, DetailField field,
- string templateId, ViewNodeLocation location, ViewVisibility target)
+ ViewNodeLocation location, ViewVisibility target)
{
var isChecked = location.Visibility == target;
return new DetailMenuItem(label, isEnabled: true, isChecked: isChecked, children: null,
- execute: () => ApplyFieldVisibility(field, templateId, target));
+ execute: () => ApplyFieldVisibility(field, location, target));
}
// A Move Field item: disabled at the first sibling (up) / last sibling (down) / when alone.
@@ -1034,12 +1034,14 @@ private DetailMenuItem ConfigureWritingSystemsItem(string label, DetailField fie
}
});
- // Writes a SetVisibility op for the field's template id into the project override and recomposes.
- private void ApplyFieldVisibility(DetailField field, string templateId, ViewVisibility target)
+ // Writes a SetVisibility op for each of the location's visibility targets into the
+ // project override and recomposes.
+ private void ApplyFieldVisibility(DetailField field, ViewNodeLocation location,
+ ViewVisibility target)
{
- var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, templateId,
- visibility: target);
- MutateOverrideAndRefresh(field, op);
+ var ops = location.VisibilityTargets.Select(id => new ViewOverrideOperation(
+ ViewOverrideOperationKind.SetVisibility, id, visibility: target)).ToArray();
+ MutateOverrideAndRefresh(field, ops);
}
// Writes a ReorderChildren op on the field's PARENT into the project override, then
@@ -1058,15 +1060,15 @@ private void ApplyMoveField(DetailField field, ViewNodeLocation location, bool u
// Loads-or-creates the (class, layout) override, folds the op in, saves it, and
// recomposes the Avalonia detail view. Only the override file is written -- not the
// shipped XML or the part-ref inventory.
- private void MutateOverrideAndRefresh(DetailField field, ViewOverrideOperation op)
+ private void MutateOverrideAndRefresh(DetailField field, params ViewOverrideOperation[] ops)
{
- if (TryMutateOverride(field, op))
+ if (TryMutateOverride(field, ops))
RefreshAvaloniaDetail();
}
- // Folds the op into the (class, layout) override and saves it, WITHOUT recomposing.
+ // Folds the ops into the (class, layout) override and saves it once, WITHOUT recomposing.
// Returns whether the save succeeded, so a failed save can leave view state untouched.
- private bool TryMutateOverride(DetailField field, ViewOverrideOperation op)
+ private bool TryMutateOverride(DetailField field, params ViewOverrideOperation[] ops)
{
try
{
@@ -1074,9 +1076,10 @@ private bool TryMutateOverride(DetailField field, ViewOverrideOperation op)
if (store == null)
return false;
- var existing = store.TryGet(field.ClassName, field.LayoutName)
+ var merged = store.TryGet(field.ClassName, field.LayoutName)
?? new ViewDefinitionOverride(field.ClassName, field.LayoutName, "detail", null, null);
- var merged = ViewDefinitionOverrideEditor.MergeOperation(existing, op);
+ foreach (var op in ops)
+ merged = ViewDefinitionOverrideEditor.MergeOperation(merged, op);
store.Save(merged);
return true;
}
diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/AdhocCoProhibComposeTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/AdhocCoProhibComposeTests.cs
index 1427b7f513..54d1298fa4 100644
--- a/Src/xWorks/xWorksTests/Avalonia/Composer/AdhocCoProhibComposeTests.cs
+++ b/Src/xWorks/xWorksTests/Avalonia/Composer/AdhocCoProhibComposeTests.cs
@@ -175,5 +175,50 @@ public void Compose_MoAdhocProhibGr_ComposesMemberRuleRows()
Assert.That(memberRows.Min(f => f.Indent), Is.GreaterThan(groupIndent),
"the member's rows nest below the group's own rows");
}
+
+ // A nested group's other rows are the child rows of its name row's part ref, and the
+ // name part holds one slice per state of the group's Active box.
+ [TestCase(false)]
+ [TestCase(true)]
+ public void Compose_ANestedGroup_ShowsItsNameDescriptionActiveAndMembers(bool disabled)
+ {
+ IMoAdhocProhibGr group = null;
+ IMoAdhocProhibGr nested = null;
+ IMoMorphAdhocProhib rule = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ var entry = Cache.ServiceLocator.GetInstance().Create();
+ var msa = Cache.ServiceLocator.GetInstance().Create();
+ entry.MorphoSyntaxAnalysesOC.Add(msa);
+
+ group = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.MorphologicalDataOA.AdhocCoProhibitionsOC.Add(group);
+ nested = Cache.ServiceLocator.GetInstance().Create();
+ group.MembersOC.Add(nested);
+ nested.Name.SetAnalysisDefaultWritingSystem("Inner group");
+ nested.Disabled = disabled;
+ rule = Cache.ServiceLocator.GetInstance().Create();
+ nested.MembersOC.Add(rule);
+ rule.FirstMorphemeRA = msa;
+ rule.RestOfMorphsRS.Add(msa);
+ });
+
+ var fields = DetailComposer.Compose(group, Cache, layoutName: "Edit",
+ plugins: SlicePluginRegistry.Default).Model.Fields;
+ TestContext.WriteLine(string.Join(", ",
+ fields.Select(f => $"{f.Field}/{f.Label}:{f.Kind}@{f.Indent}#{f.ObjectHvo}")));
+
+ var nestedRows = fields.Where(f => f.ObjectHvo == nested.Hvo && f.Kind != DetailFieldKind.Header).ToList();
+ var name = nestedRows.Single(f => f.Field == "Name");
+ Assert.That(name.Label, Is.EqualTo("Nested Group Name"));
+ Assert.That(name.MenuId, Is.EqualTo("mnuDataTree-Delete-Adhoc-Group"));
+ var description = nestedRows.Single(f => f.Field == "Description");
+ Assert.That(description.Label, Is.EqualTo("Nested Group Description"));
+ Assert.That(description.Indent, Is.GreaterThan(name.Indent), "the other rows nest below the name row");
+ Assert.That(nestedRows.Count(f => f.Field == "Disabled" && f.Kind == DetailFieldKind.Boolean), Is.EqualTo(1));
+ var members = fields.Single(f => f.ObjectHvo == nested.Hvo && f.Label == "Nested Group Members");
+ Assert.That(members.MenuId, Is.EqualTo("mnuDataTree-Adhoc-Group-Members"));
+ Assert.That(fields.Any(f => f.ObjectHvo == rule.Hvo), Is.True, "the nested group's own member composes");
+ }
}
}
diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailConditionalPartRefTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailConditionalPartRefTests.cs
new file mode 100644
index 0000000000..333585f224
--- /dev/null
+++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailConditionalPartRefTests.cs
@@ -0,0 +1,85 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Linq;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Text;
+using SIL.LCModel.Infrastructure;
+
+namespace SIL.FieldWorks.XWorks
+{
+ ///
+ /// Rows of a grammatical category whose parts wrap their content in a conditional, composed
+ /// from the shipped layouts: the row shows what the layout's part ref asks for.
+ ///
+ [TestFixture]
+ public class DetailConditionalPartRefTests : MemoryOnlyBackendProviderTestBase
+ {
+ private IPartOfSpeech m_noun;
+ private IPartOfSpeech m_subcategory;
+ private IMoInflAffixTemplate m_template;
+
+ public override void TestSetup()
+ {
+ base.TestSetup();
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ var services = Cache.ServiceLocator;
+ if (Cache.LangProject.PartsOfSpeechOA == null)
+ {
+ Cache.LangProject.PartsOfSpeechOA =
+ services.GetInstance().Create();
+ }
+ // The Subcategories part shows nothing in a list too shallow to nest.
+ Cache.LangProject.PartsOfSpeechOA.Depth = 127;
+
+ m_noun = services.GetInstance().Create();
+ Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(m_noun);
+ m_noun.Name.set_String(Cache.DefaultAnalWs, Analysis("Noun"));
+
+ m_subcategory = services.GetInstance().Create();
+ m_noun.SubPossibilitiesOS.Add(m_subcategory);
+ m_subcategory.Name.set_String(Cache.DefaultAnalWs, Analysis("Proper noun"));
+
+ m_template = services.GetInstance().Create();
+ m_noun.AffixTemplatesOS.Add(m_template);
+ m_template.Name.set_String(Cache.DefaultAnalWs, Analysis("Plural"));
+ });
+ }
+
+ private ITsString Analysis(string text) => TsStringUtils.MakeString(text, Cache.DefaultAnalWs);
+
+ // The name part holds one slice per state of the template's Active box.
+ [TestCase(false)]
+ [TestCase(true)]
+ public void Compose_AnAffixTemplatesNameRow_TakesTheLayoutsLabelAndMenu(bool disabled)
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor,
+ () => m_template.Disabled = disabled);
+
+ var fields = DetailComposer.Compose(m_noun, Cache, "default").Model.Fields;
+
+ var name = fields.Single(f => f.ObjectHvo == m_template.Hvo && f.Field == "Name"
+ && f.Kind != DetailFieldKind.Header);
+ Assert.That(name.Label, Is.EqualTo("Template Name"));
+ Assert.That(name.MenuId, Is.EqualTo("mnuDataTree-POS-AffixTemplate"));
+ }
+
+ [Test]
+ public void Compose_Subcategories_KeepOneHeading_AndOneNameRowPerSubcategory()
+ {
+ var fields = DetailComposer.Compose(m_noun, Cache, "default").Model.Fields;
+
+ var headings = fields.Where(f => f.Kind == DetailFieldKind.Header).ToList();
+ Assert.That(headings.Count(h => h.Label == "Subcategories"), Is.EqualTo(1));
+ Assert.That(headings.Any(h => h.ObjectHvo == m_subcategory.Hvo), Is.False,
+ "a subcategory has no numbered heading of its own");
+ var name = fields.Single(f => f.ObjectHvo == m_subcategory.Hvo && f.Field == "Name");
+ Assert.That(name.Label, Is.EqualTo("Subcategory"));
+ }
+ }
+}
diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/LexiconFirstSliceEditContextEdgeCaseTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/LexiconFirstSliceEditContextEdgeCaseTests.cs
index 7465f6eb95..b37f73abb9 100644
--- a/Src/xWorks/xWorksTests/Avalonia/Composer/LexiconFirstSliceEditContextEdgeCaseTests.cs
+++ b/Src/xWorks/xWorksTests/Avalonia/Composer/LexiconFirstSliceEditContextEdgeCaseTests.cs
@@ -111,6 +111,8 @@ public void Validate_WhitespaceOnlyLexeme_IsAnError()
var context = new LexiconFirstSliceEditContext(entry, Cache);
Assert.That(context.TrySetText(Field("Form"), "vern", " "), Is.True, "whitespace stages");
Assert.That(context.Validate(), Is.Not.Empty, "a whitespace-only lexeme form fails validation");
+ // The staged write holds an undo task open; the fixture cannot be torn down with it.
+ context.Cancel();
}
[Test]
diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs
index 571fd13dc5..12a421e15c 100644
--- a/Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs
+++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs
@@ -5,9 +5,11 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Reflection;
using NUnit.Framework;
using SIL.FieldWorks.Common.Controls;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
using SIL.FieldWorks.Common.FwUtils;
using SIL.LCModel;
using SIL.LCModel.Infrastructure;
@@ -286,6 +288,42 @@ public void ExceptionFeatures_AnswersEveryRowMenuNatively_AndInsertsASubitemInAH
Assert.That(m_restriction.SubPossibilitiesOS, Has.Count.EqualTo(1));
}
+ // The template's Description part holds one row per state of Active; hiding the row
+ // holds in both.
+ [Test]
+ public void FieldVisibility_OnATemplatesDescription_HoldsWhenActiveIsUnticked()
+ {
+ ShowCategory(m_verb);
+ var row = DescriptionRow(m_finite);
+ Assert.That(row, Is.Not.Null, "precondition: an active template shows its Description");
+ var store = OverrideStore();
+ store.Save(new ViewDefinitionOverride(row.ClassName, row.LayoutName, "detail", null, null));
+ try
+ {
+ var window = m_propertyTable.GetValue("window");
+ var items = XCoreMenuBridge.CreateMenuItems(window, new[] { ObjectMenuAuthority.MenuId },
+ null, null, m_view.CreateObjectMenuAuthority(row));
+ Run(Item(items, "Field Visibility").Children, "Normally hidden");
+ DrainMediatorAndIdleQueues();
+
+ var ops = store.TryGet(row.ClassName, row.LayoutName).Operations;
+ Assert.That(ops.Select(o => o.Kind), Is.All.EqualTo(ViewOverrideOperationKind.SetVisibility));
+ Assert.That(ops, Has.Count.EqualTo(2), "one write per state of Active");
+ Assert.That(ops.Select(o => o.Visibility), Is.All.EqualTo(ViewVisibility.Never));
+ Assert.That(DescriptionRow(m_finite), Is.Null, "hidden while active");
+
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => m_finite.Disabled = true);
+ m_view.RefreshDisplay();
+ DrainMediatorAndIdleQueues();
+
+ Assert.That(DescriptionRow(m_finite), Is.Null, "still hidden once Active is unticked");
+ }
+ finally
+ {
+ store.Save(new ViewDefinitionOverride(row.ClassName, row.LayoutName, "detail", null, null));
+ }
+ }
+
// Verb (two slots, two templates, the subcategory Irregular) and Noun in a
// hierarchical category list; one exception feature in its flat list.
private void CreateData()
@@ -341,6 +379,14 @@ private DetailField TemplateRow(IMoInflAffixTemplate template)
private DetailField SubcategoriesRow() => ShownRows().First(f => f.MenuId == SubcategoriesMenuId);
+ private DetailField DescriptionRow(IMoInflAffixTemplate template)
+ => ShownRows().FirstOrDefault(f => f.ObjectHvo == template.Hvo && f.Field == "Description");
+
+ private ViewDefinitionOverrideStore OverrideStore()
+ => (ViewDefinitionOverrideStore)typeof(RecordEditView)
+ .GetProperty("ViewOverrideStore", BindingFlags.Instance | BindingFlags.NonPublic)
+ .GetValue(m_view);
+
// The menus of the rows that some id leaves to the hidden tree.
private List UnansweredMenus(IEnumerable rows)
{