Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ public void EveryViewNodeProperty_SurvivesRoundTrip()
{
new ViewChooserLink("goto", "Edit the Publications list", "publicationsEdit"),
new ViewChooserLink("simple", "Add a slot", "MakeInflAffixSlotChooserCommand", "TopPOS")
});
},
helpTopicId: "khtpField-LexEntry-Senses",
sliceId: "CmPossibilityStatus");
var model = new ViewDefinitionModel("LexEntry", "Normal", "detail",
new List<ViewNode> { node }, new List<ViewDiagnostic>());

Expand Down Expand Up @@ -172,6 +174,10 @@ public void EveryViewNodeProperty_SurvivesRoundTrip()
Assert.That(r.ChooserLinks[0].Target, Is.Null, "ChooserLinks[0].Target");
Assert.That(r.ChooserLinks[1].Type, Is.EqualTo("simple"), "ChooserLinks[1].Type");
Assert.That(r.ChooserLinks[1].Target, Is.EqualTo("TopPOS"), "ChooserLinks[1].Target");
Assert.That(r.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Senses"), nameof(r.HelpTopicId));
Assert.That(r.SliceId, Is.EqualTo("CmPossibilityStatus"), nameof(r.SliceId));
Assert.That(r.StableId, Is.Not.EqualTo(r.SliceId),
"both are written as ids, and one must not overwrite the other");
});
}
}
Expand Down
84 changes: 84 additions & 0 deletions Src/Common/FwAvalonia/FwAvaloniaTests/SliceIdImportTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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.Collections.Generic;
using System.Linq;
using System.Xml.Linq;
using NUnit.Framework;
using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;

namespace FwAvaloniaTests
{
/// <summary>
/// A slice's authored <c>id=</c> is the name a tool's filter list uses to withhold the row.
/// It has to survive import, or the composer has nothing to match a filter against
/// (LT-22802).
/// </summary>
[TestFixture]
public class SliceIdImportTests
{
private const string PartsXml = @"
<PartInventory><bin>
<part id='CmPossibility-Detail-Status'>
<slice id='CmPossibilityStatus' label='Status' editor='string' field='Status'/>
</part>
<part id='CmPossibility-Detail-Name'>
<slice label='Name' editor='string' field='Name'/>
</part>
</bin></PartInventory>";

private static ViewDefinitionModel Import(string layoutXml)
{
var parts = new DictionaryPartResolver(XElement.Parse(PartsXml));
return new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts);
}

private static IEnumerable<ViewNode> Flatten(ViewNode n)
{
yield return n;
foreach (var c in n.Children)
foreach (var d in Flatten(c))
yield return d;
}

private static ViewDefinitionModel BothRows() => Import(@"
<layout class='CmPossibility' type='detail' name='default'>
<part ref='Status'/>
<part ref='Name'/>
</layout>");

[Test]
public void Slice_WithAnId_CarriesItOntoTheNode()
{
var nodes = BothRows().Roots.SelectMany(Flatten).ToList();

Assert.That(nodes.Select(n => n.SliceId), Does.Contain("CmPossibilityStatus"),
"the filter list names rows by this id; dropping it at import leaves the "
+ "composer unable to apply the tool's filter at all");
}

[Test]
public void Slice_WithoutAnId_LeavesItNull()
{
var name = BothRows().Roots.SelectMany(Flatten)
.Single(n => n.Field == "Name");

Assert.That(name.SliceId, Is.Null,
"most slices author no id, and a synthesized one could collide with a real "
+ "entry in some tool's filter list");
}

[Test]
public void SliceId_IsNotReportedAsAnUnhandledAttribute()
{
var unhandled = BothRows().Diagnostics
.Where(d => d.Code == "unhandled-attribute" && d.Message.Contains("id"))
.ToList();

Assert.That(unhandled, Is.Empty,
"id is consumed now, so it must leave the unhandled-attribute report -- that "
+ "report is the list of things the Avalonia view still ignores");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ public void Apply_EmptyPatch_ReproducesBaseExactly()
}

// Every node is rebuilt on apply, so a clone that omits a field strips it tree-wide once
// any override exists. These three fields are outside ToSnapshot() and aren't covered by
// the EmptyPatch test.
// any override exists. These fields are outside ToSnapshot() and aren't covered by the
// EmptyPatch test.
[Test]
public void Apply_PreservesNodeFieldsNoOperationTouches()
{
Expand All @@ -59,7 +59,8 @@ public void Apply_PreservesNodeFieldsNoOperationTouches()
new ViewNode("g/a", ViewNodeKind.Field, "A", null, "F", "multistring",
EditorClassification.Known, "vern", ViewVisibility.Always,
ViewExpansion.NotApplicable, false, null, null,
enumStringList: options, visibleWritingSystems: writingSystems, toggleValue: true)));
enumStringList: options, visibleWritingSystems: writingSystems, toggleValue: true,
helpTopicId: "khtpField-LexEntry-Form", sliceId: "CmPossibilityStatus")));
var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview",
new[]
{
Expand All @@ -76,6 +77,11 @@ public void Apply_PreservesNodeFieldsNoOperationTouches()
Assert.That(rebuilt.ToggleValue, Is.True, "a toggle value survives the rebuild");
Assert.That(rebuilt.EnumStringList?.Ids, Is.EqualTo(options.Ids),
"an enum option list survives the rebuild");
Assert.That(rebuilt.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Form"),
"an authored help topic survives the rebuild");
Assert.That(rebuilt.SliceId, Is.EqualTo("CmPossibilityStatus"),
"and so does the slice id a tool's filter list names; losing it would show a "
+ "withheld row in every layout carrying an override");
}

// The writing-system subset a user picks for one field, recorded against its stable id.
Expand Down Expand Up @@ -155,6 +161,30 @@ public void Apply_DuplicateNode_CopiesLeafUnderNewId()
Assert.That(children[1].Field, Is.EqualTo("F"));
}

// A duplicate is the same authored part, so a filter list that withholds the source
// withholds the copy, and its help is the source's.
[Test]
public void Apply_DuplicateNode_KeepsTheSourcesSliceIdAndHelpTopic()
{
var shipped = Model(GroupNode("g", "Group",
new ViewNode("g/a", ViewNodeKind.Field, "A", null, "F", "string",
EditorClassification.Known, "vern", ViewVisibility.Always,
ViewExpansion.NotApplicable, false, null, null,
helpTopicId: "khtpField-LexEntry-Form", sliceId: "CmPossibilityStatus")));
var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview",
new[]
{
new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a-copy",
parentStableId: "g", index: 1, sourceStableId: "g/a")
}, null);

var copy = ViewDefinitionOverrideApplier.Apply(shipped, patch).Roots[0].Children[1];

Assert.That(copy.StableId, Is.EqualTo("g/a-copy"), "precondition: this is the copy");
Assert.That(copy.SliceId, Is.EqualTo("CmPossibilityStatus"));
Assert.That(copy.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Form"));
}

[Test]
public void Apply_DuplicateNode_SourceWithChildren_ReportsDiagnostic_AndDoesNotInsert()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ private static JObject WriteNode(ViewNode node)
AddIfPresent(o, "contextMenu", node.ContextMenuId);
AddIfPresent(o, "hotlinks", node.HotlinksId);
AddIfPresent(o, "helpTopicID", node.HelpTopicId);
AddIfPresent(o, "sliceId", node.SliceId);
AddIfPresent(o, "ghost", node.GhostField);
AddIfPresent(o, "ghostWs", node.GhostWs);
AddIfPresent(o, "ghostClass", node.GhostClass);
Expand Down Expand Up @@ -214,7 +215,8 @@ private static ViewNode ReadNode(JToken token)
ghostInitMethod: (string)o["ghostInitMethod"],
condition: ReadCondition((JObject)o["condition"]),
chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList(),
helpTopicId: (string)o["helpTopicID"]);
helpTopicId: (string)o["helpTopicID"],
sliceId: (string)o["sliceId"]);
}

private static T ParseEnum<T>(JObject o, string name, T fallback) where T : struct
Expand Down
11 changes: 10 additions & 1 deletion Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,10 @@ public ViewNode(
IReadOnlyList<string> visibleWritingSystems = null,
bool toggleValue = false,
bool reorder = false,
string helpTopicId = null)
string helpTopicId = null,
string sliceId = null)
{
SliceId = sliceId;
HelpTopicId = helpTopicId;
Reorder = reorder;
ToggleValue = toggleValue;
Expand Down Expand Up @@ -432,6 +434,13 @@ public ViewNode(

public ViewNodeKind Kind { get; }

/// <summary>
/// The slice's authored <c>id=</c>, the name a tool's filter list uses to withhold the
/// row. Null on the nodes that author none, which is most of them. NOT
/// <see cref="StableId"/>, which is synthesized and always present.
/// </summary>
public string SliceId { get; }

public string Label { get; }

public string Abbreviation { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,9 @@ void Visit(ViewNode node)
return map;
}

// Reconstruct an immutable node with the overridden fields, copying every other one.
// Every trailing optional constructor argument must be passed, or that field is stripped.
// Rebuild an immutable node with the overridden fields, copying every other one. Pass
// every trailing optional argument, by name, or that field is stripped or shifted into
// its neighbour.
private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string label,
IReadOnlyList<ViewNode> children, IReadOnlyList<string> visibleWritingSystems)
=> new ViewNode(
Expand All @@ -310,7 +311,7 @@ private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string
n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel,
n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition,
n.ChooserLinks, n.EnumStringList, visibleWritingSystems, n.ToggleValue, n.Reorder,
n.HelpTopicId);
helpTopicId: n.HelpTopicId, sliceId: n.SliceId);

// 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).
Expand All @@ -322,6 +323,6 @@ private static ViewNode CloneWithId(ViewNode n, string newId)
n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel,
n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition,
n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, n.Reorder,
n.HelpTopicId);
helpTopicId: n.HelpTopicId, sliceId: n.SliceId);
}
}
5 changes: 4 additions & 1 deletion Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter
public static readonly HashSet<string> HandledSliceAttributes =
new HashSet<string>(System.StringComparer.Ordinal)
{
"label", "abbr", "field", "ws", "editor", "visibility", "expansion",
"id", "label", "abbr", "field", "ws", "editor", "visibility", "expansion",
"localizationKey", "labelId", "automationId", "routing", "menu", "contextMenu", "hotlinks",
"forVariant", "visibleWritingSystems", "reorder", "helpTopicID"
};
Expand Down Expand Up @@ -395,6 +395,7 @@ private ViewNode CreateNode(
menuId, contextMenuId, hotlinksId,
chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null,
visibleWritingSystems: visibleWss,
sliceId: Attr(contentEl, "id"),
helpTopicId: Attr(contentEl, "helpTopicID"));
}

Expand All @@ -412,6 +413,8 @@ private ViewNode CreateNode(
chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null,
enumStringList: enumStringList,
visibleWritingSystems: visibleWss,
// A tool's filter list withholds rows by this authored id.
sliceId: Attr(contentEl, "id"),
// Legacy toggleValue= on a boolean slice (the displayed checkbox is the
// logical inverse of the stored property); carried so the composer inverts read+write.
toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false,
Expand Down
38 changes: 30 additions & 8 deletions Src/xWorks/Avalonia/Composer/DetailComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH
SlicePluginRegistry plugins = null,
ViewDefinitionOverrideResolver overrides = null,
ISet<string> showAllWritingSystemsFields = null,
Action<string> writingSystemFocused = null)
Action<string> writingSystemFocused = null,
ISet<string> hiddenSliceIds = null)
=> Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides,
showAllWritingSystemsFields: showAllWritingSystemsFields,
writingSystemFocused: writingSystemFocused);
writingSystemFocused: writingSystemFocused,
hiddenSliceIds: hiddenSliceIds);

/// <summary>
/// Compose the structured detail view for ANY record root + starting layout -- the
Expand All @@ -141,7 +143,8 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou
ViewDefinitionOverrideResolver overrides = null,
string layoutChoiceField = null,
ISet<string> showAllWritingSystemsFields = null,
Action<string> writingSystemFocused = null)
Action<string> writingSystemFocused = null,
ISet<string> hiddenSliceIds = null)
{
if (obj == null) throw new ArgumentNullException(nameof(obj));
if (cache == null) throw new ArgumentNullException(nameof(cache));
Expand All @@ -162,7 +165,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou
IDetailEditContext composedContext = null;
var state = new ComposeState(cache, showHiddenFields,
plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides,
showAllWritingSystemsFields, writingSystemFocused);
showAllWritingSystemsFields, writingSystemFocused, hiddenSliceIds);
state.EnterModel(root);
foreach (var node in root.Roots)
state.Walk(node, obj, 0);
Expand Down Expand Up @@ -352,8 +355,10 @@ public ComposeState(LcmCache cache, bool showHiddenFields,
SlicePluginRegistry plugins, Func<IDetailEditContext> editContextAccessor,
ViewDefinitionOverrideResolver overrides = null,
ISet<string> showAllWritingSystemsFields = null,
Action<string> writingSystemFocused = null)
Action<string> writingSystemFocused = null,
ISet<string> hiddenSliceIds = null)
{
_hiddenSliceIds = hiddenSliceIds;
_cache = cache;
_showHidden = showHiddenFields;
_plugins = plugins;
Expand Down Expand Up @@ -572,8 +577,9 @@ private readonly HashSet<Tuple<int, int>> _propsToMonitor
/// irrelevant on a clitic or particle, Position on a non-infix, InflectionClasses on
/// some affix forms, FromPartsOfSpeech on an entry with no clitic.
///
/// The relevance gate of the two <c>SliceFilter.IncludeSlice</c> applies. The other
/// looks the slice's id up in the tool's filter list, and LT-22802 covers it.
/// The relevance gate of the two <c>SliceFilter.IncludeSlice</c> applies. The other,
/// <see cref="IsFilteredOutByTool"/>, looks the slice's id up in the tool's
/// filter list.
///
/// Not the same as hidden: show-hidden-fields leaves an irrelevant field withheld,
/// so this is asked whatever <c>_showHidden</c> says.
Expand Down Expand Up @@ -601,10 +607,26 @@ private bool IsIrrelevantForObject(ViewNode node, ICmObject obj)
return !obj.IsFieldRelevant(flid, _propsToMonitor);
}

// The tool's filter list, by authored slice id; null when the tool configures none.
private readonly ISet<string> _hiddenSliceIds;

/// <summary>
/// Whether the TOOL withholds this row: a tool's configuration can name slice ids
/// to leave out, and a node carrying one of them is dropped. Checked before the
/// node kind is dispatched, so a withheld node takes its subtree with it.
/// </summary>
private bool IsFilteredOutByTool(ViewNode node)
=> _hiddenSliceIds != null
&& !string.IsNullOrEmpty(node?.SliceId)
&& _hiddenSliceIds.Contains(node.SliceId);

public void Walk(ViewNode node, ICmObject obj, int depth)
{
if (IsHidden(node) || depth > MaxDepth || IsIrrelevantForObject(node, obj))
if (IsHidden(node) || depth > MaxDepth || IsFilteredOutByTool(node)
|| IsIrrelevantForObject(node, obj))
{
return;
}

// Rows added while this node walks are stamped with its help-topic inputs.
_walkNodes.Push(node);
Expand Down
Loading
Loading