Skip to content

Commit 43837fe

Browse files
thejambiZachary Burnhamclaude
authored
LT-22802: Apply a tool's slice filter list in the Avalonia detail view (#1146)
* test: pin the relevance rule with the most rows riding on it Raised by Jason on #1135, after the merge. MoStemMsa.IsFieldRelevant withholds FromPartsOfSpeech ("Attaches to Categories") unless the owning entry has a proclitic or enclitic, and Morphology.fwlayout:39 declares that part visibility="always" -- so before the relevance gate the row composed on every stem MSA, and now it disappears for every entry without a clitic. That is the gate's most visible consequence and nothing pinned it. The test is Jason's, run and confirmed to bite: suppressing the gate fails its negative half with "Expected: False, But was: True", while the positive control -- add a proclitic allomorph, change nothing else -- keeps passing. Also corrects two comments that claimed more than the code does. The composer implements the SECOND of SliceFilter.IncludeSlice's two gates; the first looks the slice id up in the tool's filter list, and the id never reaches the composer, so that half is LT-22802. The fixture summary said it covered the remainder of the overrides, which was untrue while this test was missing, and still excludes the InflectionClass limb that withholds the row from a compound rule's left/right MSA. xWorksTests filter Avalonia 1642 passed, 0 failed. * LT-22802: Apply a tool's slice filter list in the Avalonia detail view Legacy SliceFilter.IncludeSlice has two gates: look the slice's authored id up in the filter list the tool's filterPath names and withhold the row when it is listed, then ask IsFieldRelevant. LT-22672 added the second. This adds the first. The id was discarded at import, so three pieces: - XmlLayoutImporter carries the slice's id onto ViewNode.SliceId, and id leaves the unhandled-attribute report. - DetailComposer.Walk withholds a node whose SliceId the tool lists, checked before the node kind is dispatched so a withheld node takes its subtree with it -- where legacy checks it, at the top of ProcessSubpartNode. - RecordEditView reads filterPath from its own configuration and parses the same file into the id set, memoized per view. A tool with no filterPath, or a file that cannot be read, filters nothing: an extra row beats a view that will not open. NOTHING VISIBLE CHANGES TODAY, and the ticket's symptom section overstates it. The only shipped filter entries that still resolve to a real slice are the five CmPossibility ids in basicPlusFilter.xml, and the only tool they reach is Exception "Features" (ProdRestrictEdit) -- which is not in the Avalonia tool catalog, so it renders legacy even under UIMode=New. The other 27 ids across the six filter files name nodes that no longer exist. Category Edit is in the catalog and carries the same filterPath, but its layout reaches none of those ids. So this is parity landed before it is needed: the day ProdRestrictEdit joins LexiconFeatureCatalog, the view already withholds what legacy withholds instead of growing five rows nobody looks for. Tested at the mechanism, not the wiring. Suppressing the composer gate fails the filter tests with the full row list -- Name, Abbreviation, Description, Status, Discussion, Confidence, Researchers, Restrictions -- and the importer tests pin the id. Reading filterPath in RecordEditView has NO test and cannot be checked in the app while the tool renders legacy; it becomes verifiable, by Jason's acceptance steps, when that tool is activated. FwAvaloniaTests 751 passed, xWorksTests filter Avalonia 1645 passed, 0 failed. * LT-22802: Cover the step that reads a tool's filter list The filter-list read was the one part with no test, and the part that could quietly make the whole feature a no-op: every other test in this area supplies the id set by hand, so a broken read leaves them all green. Demonstrated rather than assumed. Pointing the XPath at the wrong element name fails only the new test; the three composer tests still pass. The read moves to an internal static taking the tool's configuration node, so it is reachable without a mediator or property table -- the same way the reference-vector menu entry points are tested. The property keeps only its memoization. One test drives the whole chain against a shipped filter file: the attribute name, the path resolution, the XPath and the id attribute. It asserts the ids are PRESENT rather than exhaustive, so editing that file leaves it alone while breaking any link in the chain does not. xWorksTests filter Avalonia 1650 passed, 0 failed. * Merge remote-tracking branch 'origin/main' into LT-22802-slice-filter-lists ViewNode gained an optional parameter on each side -- sliceId here, and helpTopicId on main -- with the same conflict at its one call site in the importer. Both are kept; they are independent. * Merge remote-tracking branch 'origin/main' into LT-22802-slice-filter-lists Main now has #1145, which this branch also carried as its first commit. Squash-merging gave it a different identity, so both files it touched conflicted with that commit's own later edits there. Resolved toward main, whose comments had been corrected, with one change: the relevance doc named LT-22802 as covering the other gate. This branch is that gate, so the doc now points at IsFilteredOutByTool, in the same class. The fixture summary keeps its pointer to DetailSliceFilterTests, where that gate is tested. * LT-22802: Rejoin a doc line the comment wrapper split The comment-hygiene wrapper broke the new pointer to IsFilteredOutByTool so that one word sat alone on the last line. Reflowed. Comments only. * Merge remote-tracking branch 'origin/main' into LT-22802-slice-filter-lists * LT-22802: Stop an override from shifting a node's help topic and id sliceId was inserted ahead of helpTopicId in ViewNode's constructor, when resolving a merge conflict between the two. Both override clone helpers pass that tail positionally, and both parameters are strings, so n.HelpTopicId bound to sliceId and helpTopicId fell back to null -- with no warning. Any layout with a Field Visibility or Move Field override lost every authored help topic, and each of its rows took the help id as its slice id. The helpers also never carried SliceId at all. Moving the parameter back alone would have restored help topics and left SliceId null after every override, so a tool's filter list would stop withholding rows in exactly the layouts users have customised. sliceId goes last again, and both helpers name the tail and pass both fields, so a parameter added ahead of them cannot shift either one. The existing test for fields an override must preserve now covers both, and a duplicated node is checked for them too. Rebuilding the merged code fails both tests exactly as reported: help topic null, slice id the help id. Dropping only the SliceId carry fails them on SliceId. Reported in review. * LT-22802: Carry a node's slice id through the canonical JSON The serializer wrote and read helpTopicID but had no slice id, so a layout loaded from canonical JSON would lose the id a tool's filter list names. No shipped path loads JSON today; this keeps that path from springing the same trap if it is enabled. Written as "sliceId", not "id": "id" is already the stable id's key, and sharing it would overwrite every node's stable id on the way out. The round-trip test that sets every property now sets helpTopicId and sliceId, which it had not been covering, and checks the two ids stay apart. Dropping the write fails it on SliceId. * LT-22802: Test that a tool's filter list reaches the composer The file reader and the composer's gate were each tested, but not the join between them: deleting the view's hiddenSliceIds argument left the whole suite green. No shipped tool reaches a live filter id today, so nothing else would have noticed. The view's composition moves out of ShowAvaloniaEntry into ComposeDetail, unchanged, so a test can reach it without the settle, adapter and message work around it. The test installs the shipped ProdRestrictEdit configuration, whose filter list is basicPlusFilter.xml, and composes a production restriction through the view. The same view with the filterPath removed is the control, and shows the Status row. Removing the argument fails the test, with Status among the composed rows. Reported in review. * LT-22802: Put each summary back on the member it describes Extracting ComposeDetail left it under ShowAvaloniaEntry's summary as well as its own, and ShowAvaloniaEntry with none. That summary is back on its method. The memoized filter list caches a failed read too, which reads like an oversight; the comment now says it is deliberate, since a missing filter file is an install fault and re-reading it would log the same failure for every record shown. The reading tests' summary argued for their own existence, and its claim that every other test supplies the id set by hand stopped being true with the wiring test. It now says what the fixture reads. Reported in review. Comments only. Co-Authored-By: Zachary Burnham <zachary_burnham@sil.org> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b18c601 commit 43837fe

13 files changed

Lines changed: 506 additions & 34 deletions

‎Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ public void EveryViewNodeProperty_SurvivesRoundTrip()
111111
{
112112
new ViewChooserLink("goto", "Edit the Publications list", "publicationsEdit"),
113113
new ViewChooserLink("simple", "Add a slot", "MakeInflAffixSlotChooserCommand", "TopPOS")
114-
});
114+
},
115+
helpTopicId: "khtpField-LexEntry-Senses",
116+
sliceId: "CmPossibilityStatus");
115117
var model = new ViewDefinitionModel("LexEntry", "Normal", "detail",
116118
new List<ViewNode> { node }, new List<ViewDiagnostic>());
117119

@@ -172,6 +174,10 @@ public void EveryViewNodeProperty_SurvivesRoundTrip()
172174
Assert.That(r.ChooserLinks[0].Target, Is.Null, "ChooserLinks[0].Target");
173175
Assert.That(r.ChooserLinks[1].Type, Is.EqualTo("simple"), "ChooserLinks[1].Type");
174176
Assert.That(r.ChooserLinks[1].Target, Is.EqualTo("TopPOS"), "ChooserLinks[1].Target");
177+
Assert.That(r.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Senses"), nameof(r.HelpTopicId));
178+
Assert.That(r.SliceId, Is.EqualTo("CmPossibilityStatus"), nameof(r.SliceId));
179+
Assert.That(r.StableId, Is.Not.EqualTo(r.SliceId),
180+
"both are written as ids, and one must not overwrite the other");
175181
});
176182
}
177183
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Xml.Linq;
8+
using NUnit.Framework;
9+
using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
10+
11+
namespace FwAvaloniaTests
12+
{
13+
/// <summary>
14+
/// A slice's authored <c>id=</c> is the name a tool's filter list uses to withhold the row.
15+
/// It has to survive import, or the composer has nothing to match a filter against
16+
/// (LT-22802).
17+
/// </summary>
18+
[TestFixture]
19+
public class SliceIdImportTests
20+
{
21+
private const string PartsXml = @"
22+
<PartInventory><bin>
23+
<part id='CmPossibility-Detail-Status'>
24+
<slice id='CmPossibilityStatus' label='Status' editor='string' field='Status'/>
25+
</part>
26+
<part id='CmPossibility-Detail-Name'>
27+
<slice label='Name' editor='string' field='Name'/>
28+
</part>
29+
</bin></PartInventory>";
30+
31+
private static ViewDefinitionModel Import(string layoutXml)
32+
{
33+
var parts = new DictionaryPartResolver(XElement.Parse(PartsXml));
34+
return new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts);
35+
}
36+
37+
private static IEnumerable<ViewNode> Flatten(ViewNode n)
38+
{
39+
yield return n;
40+
foreach (var c in n.Children)
41+
foreach (var d in Flatten(c))
42+
yield return d;
43+
}
44+
45+
private static ViewDefinitionModel BothRows() => Import(@"
46+
<layout class='CmPossibility' type='detail' name='default'>
47+
<part ref='Status'/>
48+
<part ref='Name'/>
49+
</layout>");
50+
51+
[Test]
52+
public void Slice_WithAnId_CarriesItOntoTheNode()
53+
{
54+
var nodes = BothRows().Roots.SelectMany(Flatten).ToList();
55+
56+
Assert.That(nodes.Select(n => n.SliceId), Does.Contain("CmPossibilityStatus"),
57+
"the filter list names rows by this id; dropping it at import leaves the "
58+
+ "composer unable to apply the tool's filter at all");
59+
}
60+
61+
[Test]
62+
public void Slice_WithoutAnId_LeavesItNull()
63+
{
64+
var name = BothRows().Roots.SelectMany(Flatten)
65+
.Single(n => n.Field == "Name");
66+
67+
Assert.That(name.SliceId, Is.Null,
68+
"most slices author no id, and a synthesized one could collide with a real "
69+
+ "entry in some tool's filter list");
70+
}
71+
72+
[Test]
73+
public void SliceId_IsNotReportedAsAnUnhandledAttribute()
74+
{
75+
var unhandled = BothRows().Diagnostics
76+
.Where(d => d.Code == "unhandled-attribute" && d.Message.Contains("id"))
77+
.ToList();
78+
79+
Assert.That(unhandled, Is.Empty,
80+
"id is consumed now, so it must leave the unhandled-attribute report -- that "
81+
+ "report is the list of things the Avalonia view still ignores");
82+
}
83+
}
84+
}

‎Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ public void Apply_EmptyPatch_ReproducesBaseExactly()
4848
}
4949

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

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

164+
// A duplicate is the same authored part, so a filter list that withholds the source
165+
// withholds the copy, and its help is the source's.
166+
[Test]
167+
public void Apply_DuplicateNode_KeepsTheSourcesSliceIdAndHelpTopic()
168+
{
169+
var shipped = Model(GroupNode("g", "Group",
170+
new ViewNode("g/a", ViewNodeKind.Field, "A", null, "F", "string",
171+
EditorClassification.Known, "vern", ViewVisibility.Always,
172+
ViewExpansion.NotApplicable, false, null, null,
173+
helpTopicId: "khtpField-LexEntry-Form", sliceId: "CmPossibilityStatus")));
174+
var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview",
175+
new[]
176+
{
177+
new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a-copy",
178+
parentStableId: "g", index: 1, sourceStableId: "g/a")
179+
}, null);
180+
181+
var copy = ViewDefinitionOverrideApplier.Apply(shipped, patch).Roots[0].Children[1];
182+
183+
Assert.That(copy.StableId, Is.EqualTo("g/a-copy"), "precondition: this is the copy");
184+
Assert.That(copy.SliceId, Is.EqualTo("CmPossibilityStatus"));
185+
Assert.That(copy.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Form"));
186+
}
187+
158188
[Test]
159189
public void Apply_DuplicateNode_SourceWithChildren_ReportsDiagnostic_AndDoesNotInsert()
160190
{

‎Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ private static JObject WriteNode(ViewNode node)
9090
AddIfPresent(o, "contextMenu", node.ContextMenuId);
9191
AddIfPresent(o, "hotlinks", node.HotlinksId);
9292
AddIfPresent(o, "helpTopicID", node.HelpTopicId);
93+
AddIfPresent(o, "sliceId", node.SliceId);
9394
AddIfPresent(o, "ghost", node.GhostField);
9495
AddIfPresent(o, "ghostWs", node.GhostWs);
9596
AddIfPresent(o, "ghostClass", node.GhostClass);
@@ -214,7 +215,8 @@ private static ViewNode ReadNode(JToken token)
214215
ghostInitMethod: (string)o["ghostInitMethod"],
215216
condition: ReadCondition((JObject)o["condition"]),
216217
chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList(),
217-
helpTopicId: (string)o["helpTopicID"]);
218+
helpTopicId: (string)o["helpTopicID"],
219+
sliceId: (string)o["sliceId"]);
218220
}
219221

220222
private static T ParseEnum<T>(JObject o, string name, T fallback) where T : struct

‎Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,8 +387,10 @@ public ViewNode(
387387
IReadOnlyList<string> visibleWritingSystems = null,
388388
bool toggleValue = false,
389389
bool reorder = false,
390-
string helpTopicId = null)
390+
string helpTopicId = null,
391+
string sliceId = null)
391392
{
393+
SliceId = sliceId;
392394
HelpTopicId = helpTopicId;
393395
Reorder = reorder;
394396
ToggleValue = toggleValue;
@@ -432,6 +434,13 @@ public ViewNode(
432434

433435
public ViewNodeKind Kind { get; }
434436

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

437446
public string Abbreviation { get; }

‎Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,9 @@ void Visit(ViewNode node)
299299
return map;
300300
}
301301

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

315316
// Copy a (leaf) node under a new StableId; AutomationId is dropped so the duplicate gets a fresh,
316317
// non-colliding identity (the renderer derives one from the new StableId by convention).
@@ -322,6 +323,6 @@ private static ViewNode CloneWithId(ViewNode n, string newId)
322323
n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel,
323324
n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition,
324325
n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, n.Reorder,
325-
n.HelpTopicId);
326+
helpTopicId: n.HelpTopicId, sliceId: n.SliceId);
326327
}
327328
}

‎Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter
3434
public static readonly HashSet<string> HandledSliceAttributes =
3535
new HashSet<string>(System.StringComparer.Ordinal)
3636
{
37-
"label", "abbr", "field", "ws", "editor", "visibility", "expansion",
37+
"id", "label", "abbr", "field", "ws", "editor", "visibility", "expansion",
3838
"localizationKey", "labelId", "automationId", "routing", "menu", "contextMenu", "hotlinks",
3939
"forVariant", "visibleWritingSystems", "reorder", "helpTopicID"
4040
};
@@ -395,6 +395,7 @@ private ViewNode CreateNode(
395395
menuId, contextMenuId, hotlinksId,
396396
chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null,
397397
visibleWritingSystems: visibleWss,
398+
sliceId: Attr(contentEl, "id"),
398399
helpTopicId: Attr(contentEl, "helpTopicID"));
399400
}
400401

@@ -412,6 +413,8 @@ private ViewNode CreateNode(
412413
chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null,
413414
enumStringList: enumStringList,
414415
visibleWritingSystems: visibleWss,
416+
// A tool's filter list withholds rows by this authored id.
417+
sliceId: Attr(contentEl, "id"),
415418
// Legacy toggleValue= on a boolean slice (the displayed checkbox is the
416419
// logical inverse of the stored property); carried so the composer inverts read+write.
417420
toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false,

‎Src/xWorks/Avalonia/Composer/DetailComposer.cs‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,12 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH
117117
SlicePluginRegistry plugins = null,
118118
ViewDefinitionOverrideResolver overrides = null,
119119
ISet<string> showAllWritingSystemsFields = null,
120-
Action<string> writingSystemFocused = null)
120+
Action<string> writingSystemFocused = null,
121+
ISet<string> hiddenSliceIds = null)
121122
=> Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides,
122123
showAllWritingSystemsFields: showAllWritingSystemsFields,
123-
writingSystemFocused: writingSystemFocused);
124+
writingSystemFocused: writingSystemFocused,
125+
hiddenSliceIds: hiddenSliceIds);
124126

125127
/// <summary>
126128
/// Compose the structured detail view for ANY record root + starting layout -- the
@@ -141,7 +143,8 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou
141143
ViewDefinitionOverrideResolver overrides = null,
142144
string layoutChoiceField = null,
143145
ISet<string> showAllWritingSystemsFields = null,
144-
Action<string> writingSystemFocused = null)
146+
Action<string> writingSystemFocused = null,
147+
ISet<string> hiddenSliceIds = null)
145148
{
146149
if (obj == null) throw new ArgumentNullException(nameof(obj));
147150
if (cache == null) throw new ArgumentNullException(nameof(cache));
@@ -162,7 +165,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou
162165
IDetailEditContext composedContext = null;
163166
var state = new ComposeState(cache, showHiddenFields,
164167
plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides,
165-
showAllWritingSystemsFields, writingSystemFocused);
168+
showAllWritingSystemsFields, writingSystemFocused, hiddenSliceIds);
166169
state.EnterModel(root);
167170
foreach (var node in root.Roots)
168171
state.Walk(node, obj, 0);
@@ -352,8 +355,10 @@ public ComposeState(LcmCache cache, bool showHiddenFields,
352355
SlicePluginRegistry plugins, Func<IDetailEditContext> editContextAccessor,
353356
ViewDefinitionOverrideResolver overrides = null,
354357
ISet<string> showAllWritingSystemsFields = null,
355-
Action<string> writingSystemFocused = null)
358+
Action<string> writingSystemFocused = null,
359+
ISet<string> hiddenSliceIds = null)
356360
{
361+
_hiddenSliceIds = hiddenSliceIds;
357362
_cache = cache;
358363
_showHidden = showHiddenFields;
359364
_plugins = plugins;
@@ -572,8 +577,9 @@ private readonly HashSet<Tuple<int, int>> _propsToMonitor
572577
/// irrelevant on a clitic or particle, Position on a non-infix, InflectionClasses on
573578
/// some affix forms, FromPartsOfSpeech on an entry with no clitic.
574579
///
575-
/// The relevance gate of the two <c>SliceFilter.IncludeSlice</c> applies. The other
576-
/// looks the slice's id up in the tool's filter list, and LT-22802 covers it.
580+
/// The relevance gate of the two <c>SliceFilter.IncludeSlice</c> applies. The other,
581+
/// <see cref="IsFilteredOutByTool"/>, looks the slice's id up in the tool's
582+
/// filter list.
577583
///
578584
/// Not the same as hidden: show-hidden-fields leaves an irrelevant field withheld,
579585
/// so this is asked whatever <c>_showHidden</c> says.
@@ -601,10 +607,26 @@ private bool IsIrrelevantForObject(ViewNode node, ICmObject obj)
601607
return !obj.IsFieldRelevant(flid, _propsToMonitor);
602608
}
603609

610+
// The tool's filter list, by authored slice id; null when the tool configures none.
611+
private readonly ISet<string> _hiddenSliceIds;
612+
613+
/// <summary>
614+
/// Whether the TOOL withholds this row: a tool's configuration can name slice ids
615+
/// to leave out, and a node carrying one of them is dropped. Checked before the
616+
/// node kind is dispatched, so a withheld node takes its subtree with it.
617+
/// </summary>
618+
private bool IsFilteredOutByTool(ViewNode node)
619+
=> _hiddenSliceIds != null
620+
&& !string.IsNullOrEmpty(node?.SliceId)
621+
&& _hiddenSliceIds.Contains(node.SliceId);
622+
604623
public void Walk(ViewNode node, ICmObject obj, int depth)
605624
{
606-
if (IsHidden(node) || depth > MaxDepth || IsIrrelevantForObject(node, obj))
625+
if (IsHidden(node) || depth > MaxDepth || IsFilteredOutByTool(node)
626+
|| IsIrrelevantForObject(node, obj))
627+
{
607628
return;
629+
}
608630

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

0 commit comments

Comments
 (0)