Skip to content

Commit 0a6b618

Browse files
authored
[OGUI-689] Tree refresh should keep state of opened objects (#3235)
* The tree on the object tree page now persists its expanded node state in local storage. Note: * The estimated storage size (in bytes) is approximately: `A * (N + 7) * 2` * `A` is the number of expanded (opened) nodes * `N` is the average character length of expanded (opened) node names * The storing of tree node states will break if two identical names are stored directly under the same parent. This limitation already existed in the previous implementation and is unchanged by the current persistence mechanism. * A temporary workaround was added to handle local storage resets in tests, since some tests are not fully isolated and depend on shared state. Ideally, all tests should be isolated, with local storage and page state fully reset between each test.
1 parent 3238e33 commit 0a6b618

File tree

5 files changed

+180
-22
lines changed

5 files changed

+180
-22
lines changed

QualityControl/public/common/enums/storageKeys.enum.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@
2020
export const StorageKeysEnum = Object.freeze({
2121
OBJECT_VIEW_LEFT_PANEL_WIDTH: 'object-view-left-panel-width',
2222
OBJECT_VIEW_INFO_VISIBILITY_SETTING: 'object-view-info-visibility-setting',
23+
OBJECT_TREE_OPEN_NODES: 'object-tree-open-nodes',
2324
});

QualityControl/public/object/ObjectTree.class.js

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
* or submit itself to any jurisdiction.
1313
*/
1414

15-
import { Observable } from '/js/src/index.js';
15+
import { BrowserStorage, Observable, sessionService } from '/js/src/index.js';
16+
import { StorageKeysEnum } from '../common/enums/storageKeys.enum.js';
1617

1718
/**
1819
* This class allows to transforms objects names (A/B/C) into a tree that can have
@@ -27,6 +28,7 @@ export default class ObjectTree extends Observable {
2728
*/
2829
constructor(name, parent) {
2930
super();
31+
this.storage = new BrowserStorage(StorageKeysEnum.OBJECT_TREE_OPEN_NODES);
3032
this.initTree(name, parent);
3133
}
3234

@@ -46,12 +48,116 @@ export default class ObjectTree extends Observable {
4648
this.pathString = ''; // 'A/B'
4749
}
4850

51+
/**
52+
* Load the expanded/collapsed state for this node and its children from localStorage.
53+
* Updates the `open` property for the current node and recursively for all children.
54+
*/
55+
loadExpandedNodes() {
56+
if (!this.parent) {
57+
// The main node may not be collapsable or expandable.
58+
// Because of this we also have to load the expanded state of their direct children.
59+
this.children.forEach((child) => child.loadExpandedNodes());
60+
}
61+
62+
const session = sessionService.get();
63+
const key = session.personid.toString();
64+
65+
// We traverse the path to reach the parent object of this node
66+
let parentNode = this.storage.getLocalItem(key) ?? {};
67+
for (let i = 0; i < this.path.length - 1; i++) {
68+
parentNode = parentNode[this.path[i]];
69+
if (!parentNode) {
70+
// Cannot expand marked node because parent path does not exist
71+
return;
72+
}
73+
}
74+
75+
this._applyExpandedNodesRecursive(parentNode, this);
76+
}
77+
78+
/**
79+
* Recursively traverse the stored data and update the tree nodes
80+
* @param {object} data - The current level of the hierarchical expanded nodes object
81+
* @param {ObjectTree} treeNode - The tree node to update
82+
*/
83+
_applyExpandedNodesRecursive(data, treeNode) {
84+
if (data[treeNode.name]) {
85+
treeNode.open = true;
86+
Object.keys(data[treeNode.name]).forEach((childName) => {
87+
const child = treeNode.children.find((child) => child.name === childName);
88+
if (child) {
89+
this._applyExpandedNodesRecursive(data[treeNode.name], child);
90+
}
91+
});
92+
}
93+
};
94+
95+
/**
96+
* Persist the current node's expanded/collapsed state in localStorage.
97+
*/
98+
storeExpandedNodes() {
99+
if (!this.parent) {
100+
// The main node may not be collapsable or expandable.
101+
// Because of this we have to store the expanded state of their direct children.
102+
this.children.forEach((child) => child.storeExpandedNodes());
103+
}
104+
105+
const session = sessionService.get();
106+
const key = session.personid.toString();
107+
const data = this.storage.getLocalItem(key) ?? {};
108+
109+
// We traverse the path to reach the parent object of this node
110+
let parentNode = data;
111+
for (let i = 0; i < this.path.length - 1; i++) {
112+
const pathKey = this.path[i];
113+
if (!parentNode[pathKey]) {
114+
if (!this.open) {
115+
// Cannot remove marked node because parent path does not exist
116+
// Due to this the marked node also does not exist (so there is nothing to remove)
117+
return;
118+
}
119+
120+
// Parent path does not exist, we create it here so we can mark a deeper node
121+
parentNode[pathKey] = {};
122+
}
123+
124+
parentNode = parentNode[pathKey];
125+
}
126+
127+
if (this.open) {
128+
this._markExpandedNodesRecursive(parentNode, this);
129+
this.storage.setLocalItem(key, data);
130+
} else if (parentNode[this.name]) {
131+
// Deleting from `parentNode` directly updates the `data` object
132+
delete parentNode[this.name];
133+
this.storage.setLocalItem(key, data);
134+
}
135+
}
136+
137+
/**
138+
* Recursively mark a node and all open children in the hierarchical "expanded nodes" object.
139+
* This method updates `data` to reflect the current node's expanded state:
140+
* - If the node has any open children, it creates an object branch and recursively marks those children.
141+
* - If the node has no open children (or is a leaf), it stores a marker value `{}`.
142+
* @param {object} data - The current level in the hierarchical data object where nodes are stored.
143+
* @param {ObjectTree} treeNode - The tree node whose expanded state should be stored.
144+
*/
145+
_markExpandedNodesRecursive(data, treeNode) {
146+
if (!data[treeNode.name]) {
147+
data[treeNode.name] = {};
148+
}
149+
treeNode.children
150+
.filter((child) => child.open)
151+
.forEach((child) => this._markExpandedNodesRecursive(data[treeNode.name], child));
152+
};
153+
49154
/**
50155
* Toggle this node (open/close)
51156
* @returns {undefined}
52157
*/
53158
toggle() {
54159
this.open = !this.open;
160+
this.storeExpandedNodes();
55161
this.notify();
56162
}
57163

@@ -60,6 +166,7 @@ export default class ObjectTree extends Observable {
60166
*/
61167
closeAll() {
62168
this._closeAllRecursive();
169+
this.storeExpandedNodes();
63170
this.notify();
64171
}
65172

@@ -85,15 +192,14 @@ export default class ObjectTree extends Observable {
85192
* addChild(o, [], ['A', 'B']) // end inserting, affecting B
86193
* @returns {undefined}
87194
*/
88-
addChild(object, path, pathParent) {
195+
_addChild(object, path = undefined, pathParent = []) {
89196
// Fill the path argument through recursive call
90197
if (!path) {
91198
if (!object.name) {
92199
throw new Error('Object name must exist');
93200
}
94201
path = object.name.split('/');
95-
this.addChild(object, path, []);
96-
this.notify();
202+
this._addChild(object, path);
97203
return;
98204
}
99205

@@ -122,15 +228,26 @@ export default class ObjectTree extends Observable {
122228
}
123229

124230
// Pass to child
125-
subtree.addChild(object, path, fullPath);
231+
subtree._addChild(object, path, fullPath);
126232
}
127233

128234
/**
129-
* Add a list of objects by calling `addChild`
235+
* Add a single object as a child node
236+
* @param {object} object - child to be added
237+
*/
238+
addOneChild(object) {
239+
this._addChild(object);
240+
this.loadExpandedNodes();
241+
this.notify();
242+
}
243+
244+
/**
245+
* Add a list of objects as child nodes
130246
* @param {Array<object>} objects - children to be added
131-
* @returns {undefined}
132247
*/
133248
addChildren(objects) {
134-
objects.forEach((object) => this.addChild(object));
249+
objects.forEach((object) => this._addChild(object));
250+
this.loadExpandedNodes();
251+
this.notify();
135252
}
136253
}

QualityControl/public/object/objectTreeHeader.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export default function objectTreeHeader(qcObject, filterModel) {
5858
' ',
5959
h('button.btn', {
6060
title: 'Close whole tree',
61+
id: 'collapse-tree-button',
6162
onclick: () => qcObject.tree.closeAll(),
6263
disabled: Boolean(qcObject.searchInput),
6364
}, iconCollapseUp()),

QualityControl/test/public/features/filterTest.test.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => {
3232
strictEqual(location.href.includes('RunNumber=0'), true, 'URL should contain RunNumber=0');
3333

3434
//Naviagte to object view
35-
await extendTree(3, 5);
36-
await page.locator('tr:last-of-type td').click();
35+
await extendTree(3, 4);
36+
await page.locator('tbody tr:nth-child(4) td').click();
3737
await page.waitForSelector('#fullscreen-button');
3838
await page.locator('#fullscreen-button').click();
3939
await page.waitForSelector('#runNumberFilter', { visible: true });
@@ -240,7 +240,6 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => {
240240
{ waitUntil: 'networkidle0' },
241241
);
242242

243-
await extendTree(3, 5);
244243
let rowCount = await page.evaluate(() => document.querySelectorAll('tr').length);
245244
strictEqual(rowCount, 7);
246245

QualityControl/test/public/pages/object-tree.test.js

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
* or submit itself to any jurisdiction.
1212
*/
1313

14-
import { strictEqual, ok, deepStrictEqual } from 'node:assert';
14+
import { strictEqual, ok, deepStrictEqual, notDeepStrictEqual } from 'node:assert';
1515
import { delay } from '../../testUtils/delay.js';
16-
import { getLocalStorage } from '../../testUtils/localStorage.js';
16+
import { getLocalStorage, getLocalStorageAsJson } from '../../testUtils/localStorage.js';
1717
import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js';
1818

1919
const OBJECT_TREE_PAGE_PARAM = '?page=objectTree';
@@ -43,15 +43,22 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent)
4343
ok(rowsCount > 1); // more than 1 object in the tree
4444
});
4545

46-
await testParent.test('should not preserve state if refreshed not in run mode', { timeout }, async () => {
47-
const tbodyPath = 'section > div > div > div > table > tbody';
48-
await page.locator(`${tbodyPath} > tr:nth-child(2)`).click();
46+
await testParent.test('should preserve state if refreshed', { timeout }, async () => {
47+
const selector = 'section > div > div > div > table > tbody > tr:nth-child(2)';
48+
await page.locator(selector).click();
4949
await page.reload({ waitUntil: 'networkidle0' });
5050

51-
const rowCount = await page.evaluate(() =>
51+
const rowCountExpanded = await page.evaluate(() =>
5252
document.querySelectorAll('section > div > div > div > table > tbody > tr').length);
5353

54-
strictEqual(rowCount, 2);
54+
await page.locator(selector).click();
55+
await page.reload({ waitUntil: 'networkidle0' });
56+
57+
const rowCountCollapsed = await page.evaluate(() =>
58+
document.querySelectorAll('section > div > div > div > table > tbody > tr').length);
59+
60+
strictEqual(rowCountExpanded, 3);
61+
strictEqual(rowCountCollapsed, 2);
5562
});
5663

5764
await testParent.test('should have a button to sort by (default "Name" ASC)', async () => {
@@ -136,10 +143,6 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent)
136143
async () => {
137144
const dragAmount = 35;
138145
await page.reload({ waitUntil: 'networkidle0' });
139-
await page.evaluate(() => document.querySelector('tr.object-selectable:nth-child(2)').click());
140-
await delay(500);
141-
await page.evaluate(() => document.querySelector('tr.object-selectable:nth-child(3)').click());
142-
await delay(500);
143146
await page.evaluate(() => document.querySelector('tr.object-selectable:nth-child(4)').click());
144147
await delay(1000);
145148
const panelWidth = await page.evaluate(() =>
@@ -228,6 +231,24 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent)
228231
}
229232
);
230233

234+
await testParent.test('should update local storage when tree node is clicked', { timeout }, async () => {
235+
const selector = 'section > div > div > div > table > tbody > tr:nth-child(2)';
236+
const personid = await page.evaluate(() => window.model.session.personid);
237+
const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`;
238+
239+
await page.locator(selector).click();
240+
const localStorageBefore = await getLocalStorageAsJson(page, storageKey);
241+
242+
await page.locator(selector).click();
243+
const localStorageAfter = await getLocalStorageAsJson(page, storageKey);
244+
245+
notDeepStrictEqual(
246+
localStorageBefore,
247+
localStorageAfter,
248+
'local storage should have changed after clicking a tree node',
249+
);
250+
});
251+
231252
await testParent.test('should sort list of histograms by name in descending order', async () => {
232253
await page.locator('#sortTreeButton').click();
233254
const sortingByNameOptionPath = '#sortTreeButton > div > a:nth-child(2)';
@@ -257,6 +278,25 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent)
257278
strictEqual(sorted.list[0].name, 'qc/test/object/1');
258279
});
259280

281+
await testParent.test(
282+
'should close all branches when clicking the collapse all button',
283+
{ timeout },
284+
async () => {
285+
const selector = '#collapse-tree-button';
286+
const personid = await page.evaluate(() => window.model.session.personid);
287+
const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`;
288+
289+
await page.locator(selector).click();
290+
await delay(100);
291+
const storedNodes = await getLocalStorageAsJson(page, storageKey);
292+
const tableRowCount = await page.evaluate(() =>
293+
document.querySelectorAll('section > div > div > div > table > tbody > tr').length);
294+
295+
deepStrictEqual(storedNodes, {}, 'Stores nodes should be empty');
296+
strictEqual(tableRowCount, 1, 'Tree should be fully collapsed');
297+
},
298+
);
299+
260300
await testParent.test('should have filtered results on input search', async () => {
261301
await page.type('#searchObjectTree', 'qc/test/object/1');
262302
const rowsDisplayed = await page.evaluate(() => {

0 commit comments

Comments
 (0)