Skip to content

Commit 21f1f82

Browse files
Fix LT-22316 regression: persist hidden column labels (#790)
The LT-22265 fix auto-added common columns missing from saved settings, but couldn't distinguish "new column" from "deliberately removed column" since only active columns were persisted. This caused Lexeme Form and other common columns to reappear after navigation. - Bump kBrowseViewVersion to 20 - Save hidden column labels as <hidden> elements sorted for S/R - On load, skip auto-adding columns in the hidden list - Bootstrap: when no hidden tracking exists (pre-upgrade), skip all auto-adds to prevent removed columns from reappearing - Reorder the checks so PossibleColumnSpecs label match takes priority, with GetPartFromParentNode as a fallback for generated columns. - Write sentinel <hidden/> when no columns are hidden (LT-22447)
1 parent 349ace3 commit 21f1f82

3 files changed

Lines changed: 153 additions & 11 deletions

File tree

Src/Common/Controls/XMLViews/BrowseViewer.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
using System.ComponentModel;
2424
using System.Diagnostics;
2525
using System.Drawing;
26+
using System.Security;
2627
using System.Text;
2728
using System.Windows.Forms;
2829
using System.Xml;
@@ -2968,7 +2969,7 @@ protected void InsertColumn(XmlNode colSpec, int position)
29682969

29692970
// Note: often we also want to update LayoutCache.LayoutVersionNumber.
29702971
// (last updated by Ariel Rorabaugh, Oct 28, 2025, for adding PictureLicense column to Browse view)
2971-
internal const int kBrowseViewVersion = 19;
2972+
internal const int kBrowseViewVersion = 20;
29722973

29732974
/// <summary>
29742975
/// Column has been added or removed, update all child windows.
@@ -3042,6 +3043,33 @@ protected void UpdateColumnList()
30423043
colList.Append(node.OuterXml);
30433044
}
30443045
}
3046+
// Save labels of hidden columns so we can distinguish "removed by user" from "new column" on load.
3047+
// Use SortedSet with Ordinal comparison for deterministic output (stable for Send/Receive).
3048+
var activeLabels = new HashSet<string>();
3049+
foreach (XmlNode node in ColumnSpecs)
3050+
{
3051+
var label = XmlUtils.GetOptionalAttributeValue(node, "label", "");
3052+
if (!string.IsNullOrEmpty(label))
3053+
activeLabels.Add(label);
3054+
}
3055+
var hiddenLabels = new SortedSet<string>(StringComparer.Ordinal);
3056+
foreach (XmlNode node in m_xbv.Vc.PossibleColumnSpecs)
3057+
{
3058+
var label = XmlUtils.GetOptionalAttributeValue(node, "label", "");
3059+
if (!string.IsNullOrEmpty(label) && !activeLabels.Contains(label))
3060+
hiddenLabels.Add(label);
3061+
}
3062+
if (hiddenLabels.Count == 0)
3063+
{
3064+
// Write a sentinel so the load side knows hidden tracking is active
3065+
// (distinguishes "all columns visible" from "pre-upgrade save with no tracking").
3066+
colList.Append("<hidden/>");
3067+
}
3068+
else
3069+
{
3070+
foreach (var label in hiddenLabels)
3071+
colList.Append("<hidden label=\"" + SecurityElement.Escape(label) + "\"/>");
3072+
}
30453073
colList.Append("</root>");
30463074
m_propertyTable.SetProperty(m_xbv.Vc.ColListId, colList.ToString(), PropertyTable.SettingsGroup.LocalSettings, true);
30473075
}

Src/Common/Controls/XMLViews/XMLViewsTests/TestXmlBrowseView.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// (http://www.gnu.org/licenses/lgpl-2.1.html)
44

55
using System;
6+
using System.Collections.Generic;
67
using System.Xml;
78
using NUnit.Framework;
89
using SIL.FieldWorks.Common.Controls;
@@ -133,6 +134,84 @@ public void MigrateBrowseColumns()
133134
}
134135
}
135136

137+
[Test]
138+
public void MigrateBrowseColumns_Version19ToVersion20()
139+
{
140+
var input =
141+
"<root version=\"19\">" +
142+
"<column layout=\"EntryHeadwordForFindEntry\" label=\"Headword\" sortmethod=\"FullSortKey\" ws=\"$ws=vernacular\" editable=\"false\" width=\"96000\"/>" +
143+
"<column layout=\"Unknown Test\"/>" +
144+
"</root>";
145+
using (var mediator = new Mediator())
146+
using (var propertyTable = new PropertyTable(mediator))
147+
{
148+
var output = XmlBrowseViewBaseVc.GetSavedColumns(input, mediator, propertyTable, "myKey");
149+
Assert.That(XmlUtils.GetOptionalAttributeValue(output.DocumentElement, "version"), Is.EqualTo(BrowseViewer.kBrowseViewVersion.ToString()));
150+
Assert.That(XmlUtils.GetOptionalAttributeValue(output.DocumentElement, "version"), Is.EqualTo("20"));
151+
// Columns should be preserved as-is
152+
var headwordNode = output.SelectSingleNode("//column[@label='Headword']");
153+
Assert.That(headwordNode, Is.Not.Null);
154+
Assert.That(XmlUtils.GetOptionalAttributeValue(headwordNode, "layout"), Is.EqualTo("EntryHeadwordForFindEntry"));
155+
}
156+
}
157+
158+
[Test]
159+
public void MigrateBrowseColumns_HiddenElementsPreservedThroughMigration()
160+
{
161+
// Version 19 with hidden elements (simulating a future scenario where hidden elements exist)
162+
var input =
163+
"<root version=\"19\">" +
164+
"<column layout=\"EntryHeadwordForFindEntry\" label=\"Headword\"/>" +
165+
"<hidden label=\"Lexeme Form\"/>" +
166+
"</root>";
167+
using (var mediator = new Mediator())
168+
using (var propertyTable = new PropertyTable(mediator))
169+
{
170+
var output = XmlBrowseViewBaseVc.GetSavedColumns(input, mediator, propertyTable, "myKey");
171+
Assert.That(XmlUtils.GetOptionalAttributeValue(output.DocumentElement, "version"), Is.EqualTo("20"));
172+
// Hidden elements should be preserved
173+
var hiddenNode = output.SelectSingleNode("//hidden[@label='Lexeme Form']");
174+
Assert.That(hiddenNode, Is.Not.Null, "Hidden column labels should be preserved through migration");
175+
}
176+
}
177+
178+
[Test]
179+
public void HiddenSentinel_AllColumnsVisible_AllowsAutoAddOfNewCommonColumns()
180+
{
181+
// Simulate a version-20 save where all columns are visible: the sentinel <hidden/>
182+
// element (no label) signals that hidden tracking is active, so new common columns
183+
// should be auto-added rather than skipped by the bootstrap path.
184+
var savedXml =
185+
"<root version=\"20\">" +
186+
"<column layout=\"Name\" label=\"Name\" ws=\"$ws=best analysis\" field=\"Name\"/>" +
187+
"<column layout=\"Abbreviation\" label=\"Abbreviation\" ws=\"$ws=best analysis\" field=\"Abbreviation\"/>" +
188+
"<hidden/>" + // sentinel: hidden tracking active, but nothing hidden
189+
"</root>";
190+
var doc = new XmlDocument();
191+
doc.LoadXml(savedXml);
192+
193+
var hiddenNodes = doc.DocumentElement.SelectNodes("//hidden");
194+
bool hasHiddenTracking = hiddenNodes.Count > 0;
195+
Assert.That(hasHiddenTracking, Is.True, "Sentinel <hidden/> should enable hidden tracking");
196+
197+
// Collect hidden labels — sentinel has no label, so hiddenLabels should be empty
198+
var hiddenLabels = new HashSet<string>();
199+
foreach (XmlNode hidden in hiddenNodes)
200+
{
201+
var label = XmlUtils.GetOptionalAttributeValue(hidden, "label", "");
202+
if (!string.IsNullOrEmpty(label))
203+
hiddenLabels.Add(label);
204+
}
205+
Assert.That(hiddenLabels, Is.Empty, "Sentinel <hidden/> should not contribute a label");
206+
207+
// A new common column that is not in the saved list and not hidden should be auto-added
208+
// (i.e. hasHiddenTracking is true AND label is not in hiddenLabels)
209+
var newColumnLabel = "NewCommonColumn";
210+
Assert.That(hasHiddenTracking, Is.True);
211+
Assert.That(hiddenLabels.Contains(newColumnLabel), Is.False,
212+
"New column should not be blocked when hidden tracking is active with no hidden labels");
213+
}
214+
136215
void VerifyColumn(XmlNode output, string layoutName, string attrName, string attrVal)
137216
{
138217
var node = output.SelectSingleNode("//column[@layout='" + layoutName + "']");

Src/Common/Controls/XMLViews/XmlBrowseViewBaseVc.cs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,34 @@ public XmlBrowseViewBaseVc(XmlNode xnSpec, int fakeFlid, XmlBrowseViewBase xbv)
229229
ColumnSpecs.Add(node);
230230
}
231231

232+
// Collect labels of columns the user deliberately removed (hidden).
233+
var hiddenNodes = doc.DocumentElement.SelectNodes("//hidden");
234+
var hiddenLabels = new HashSet<string>();
235+
bool hasHiddenTracking = hiddenNodes.Count > 0;
236+
if (hasHiddenTracking)
237+
{
238+
foreach (XmlNode hidden in hiddenNodes)
239+
{
240+
var label = XmlUtils.GetOptionalAttributeValue(hidden, "label", "");
241+
if (!string.IsNullOrEmpty(label))
242+
hiddenLabels.Add(label);
243+
}
244+
}
245+
232246
foreach (var node in newPossibleColumns)
233247
{
234-
// add any possible columns that were not in the saved list and are common (and not custom)
235-
if (!IsCustomField(node, out _) && XmlUtils.GetOptionalAttributeValue(node, "common", "false") == "true")
248+
if (!hasHiddenTracking)
249+
{
250+
// Bootstrap: no hidden tracking yet (pre-upgrade save).
251+
// Don't auto-add anything — all missing columns are presumed
252+
// deliberately removed. They'll be properly tracked on next save.
253+
continue;
254+
}
255+
// Add common non-custom columns that were not in the saved list and not deliberately hidden.
256+
var label = XmlUtils.GetOptionalAttributeValue(node, "label", "");
257+
if (!hiddenLabels.Contains(label)
258+
&& !IsCustomField(node, out _)
259+
&& XmlUtils.GetOptionalAttributeValue(node, "common", "false") == "true")
236260
{
237261
ColumnSpecs.Add(node);
238262
}
@@ -299,6 +323,9 @@ internal static XmlDocument GetSavedColumns(string savedCols, Mediator mediator,
299323
case 18:
300324
savedCols = FixVersion19Columns(savedCols);
301325
savedCols = savedCols.Replace("root version=\"18\"", "root version=\"19\"");
326+
goto case 19;
327+
case 19:
328+
savedCols = savedCols.Replace("root version=\"19\"", "root version=\"20\"");
302329
propertyTable.SetProperty(colListId, savedCols, true);
303330
doc.LoadXml(savedCols);
304331
break;
@@ -613,23 +640,31 @@ internal int ListItemsClass
613640
/// </summary>
614641
internal bool IsValidColumnSpec(XmlNode colSpec)
615642
{
616-
if (GetPartFromParentNode(colSpec, ListItemsClass) == null)
617-
{
618-
return false;
619-
}
620643
// If it is a Custom Field, check that it is valid and has the correct label.
621644
if (IsCustomField(colSpec, out var isValid))
622645
{
623646
return isValid;
624647
}
625-
// In the simple case, `node`s label should match a label in PossibleColumnSpecs. There may be more complicated cases.
626-
// ENHANCE (Hasso) 2025.11: 'layout' (mandatory?) and 'field' (optional) would be better attributes to match, but that would require more test setup.
648+
// If the column matches a known PossibleColumnSpec by label, it is valid.
649+
// Check this before GetPartFromParentNode because some views (e.g. Phonological Features
650+
// with FsFeatDefn) have valid columns whose class hierarchy has no matching parts in
651+
// the part inventory; GetPartFromParentNode would incorrectly reject those columns.
627652
var label = XmlUtils.GetLocalizedAttributeValue(colSpec, "label", null) ??
628653
XmlUtils.GetMandatoryAttributeValue(colSpec, "label");
629654
var originalLabel = XmlUtils.GetLocalizedAttributeValue(colSpec, "originalLabel", null) ??
630655
XmlUtils.GetAttributeValue(colSpec, "originalLabel");
631-
return XmlViewsUtils.FindNodeWithAttrVal(PossibleColumnSpecs, "label", label) != null ||
632-
XmlViewsUtils.FindNodeWithAttrVal(PossibleColumnSpecs, "label", originalLabel) != null;
656+
if (XmlViewsUtils.FindNodeWithAttrVal(PossibleColumnSpecs, "label", label) != null ||
657+
XmlViewsUtils.FindNodeWithAttrVal(PossibleColumnSpecs, "label", originalLabel) != null)
658+
{
659+
return true;
660+
}
661+
// For columns not in PossibleColumnSpecs (e.g. generated columns with a layout
662+
// attribute), fall back to checking whether the part/layout inventory can resolve
663+
// the column. Columns without a layout attribute that aren't in PossibleColumnSpecs
664+
// are invalid (GetPartFromParentNode would trivially return the node itself).
665+
if (XmlUtils.GetOptionalAttributeValue(colSpec, "layout") == null)
666+
return false;
667+
return GetPartFromParentNode(colSpec, ListItemsClass) != null;
633668
}
634669

635670
/// <summary>

0 commit comments

Comments
 (0)