From 2e8b20ccac59ac0b0e1a7580d6a295e9c42c7677 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Sat, 13 Dec 2025 16:40:58 +0100 Subject: [PATCH 1/5] Tree stores expanded state of nodes in local storage --- .../public/common/enums/storageKeys.enum.js | 1 + .../public/object/ObjectTree.class.js | 128 ++++++++++++++++-- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/QualityControl/public/common/enums/storageKeys.enum.js b/QualityControl/public/common/enums/storageKeys.enum.js index 54953dd0f..2c6f0c10f 100644 --- a/QualityControl/public/common/enums/storageKeys.enum.js +++ b/QualityControl/public/common/enums/storageKeys.enum.js @@ -20,4 +20,5 @@ export const StorageKeysEnum = Object.freeze({ OBJECT_VIEW_LEFT_PANEL_WIDTH: 'object-view-left-panel-width', OBJECT_VIEW_INFO_VISIBILITY_SETTING: 'object-view-info-visibility-setting', + OBJECT_TREE_OPEN_NODES: 'object-tree-open-nodes', }); diff --git a/QualityControl/public/object/ObjectTree.class.js b/QualityControl/public/object/ObjectTree.class.js index 4135b5e9b..0cbee3998 100644 --- a/QualityControl/public/object/ObjectTree.class.js +++ b/QualityControl/public/object/ObjectTree.class.js @@ -12,7 +12,8 @@ * or submit itself to any jurisdiction. */ -import { Observable } from '/js/src/index.js'; +import { BrowserStorage, Observable, sessionService } from '/js/src/index.js'; +import { StorageKeysEnum } from '../common/enums/storageKeys.enum.js'; /** * 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 { */ constructor(name, parent) { super(); + this.storage = new BrowserStorage(StorageKeysEnum.OBJECT_TREE_OPEN_NODES); this.initTree(name, parent); } @@ -46,12 +48,110 @@ export default class ObjectTree extends Observable { this.pathString = ''; // 'A/B' } + /** + * Load the expanded/collapsed state for this node and its children from localStorage. + * Updates the `open` property for the current node and recursively for all children. + */ + loadExpandedNodes() { + if (!this.parent) { + // The main node may not be collapsable or expandable. + // Because of this we also have to load the expanded state of their direct children. + this.children.forEach((child) => child.loadExpandedNodes()); + } + + const session = sessionService.get(); + const key = session.personid.toString(); + + // We traverse the path to reach the parent object of this node + let parentNode = this.storage.getLocalItem(key) ?? {}; + for (let i = 0; i < this.path.length - 1; i++) { + parentNode = parentNode[this.path[i]]; + if (!parentNode) { + // Cannot expand marked node because parent path does not exist + return; + } + } + + this._applyExpandedNodesRecursive(parentNode, this); + } + + /** + * Recursively traverse the stored data and update the tree nodes + * @param {object} data - The current level of the hierarchical expanded nodes object + * @param {ObjectTree} treeNode - The tree node to update + */ + _applyExpandedNodesRecursive(data, treeNode) { + if (data[treeNode.name]) { + treeNode.open = true; + Object.keys(data[treeNode.name]).forEach((childName) => { + const child = treeNode.children.find((child) => child.name === childName); + if (child) { + this._applyExpandedNodesRecursive(data[treeNode.name], child); + } + }); + } + }; + + /** + * Persist the current node's expanded/collapsed state in localStorage. + */ + storeExpandedNodes() { + const session = sessionService.get(); + const key = session.personid.toString(); + const data = this.storage.getLocalItem(key) ?? {}; + + // We traverse the path to reach the parent object of this node + let parentNode = data; + for (let i = 0; i < this.path.length - 1; i++) { + const pathKey = this.path[i]; + if (!parentNode[pathKey]) { + if (!this.open) { + // Cannot remove marked node because parent path does not exist + // Due to this the marked node also does not exist (so there is nothing to remove) + return; + } + + // Parent path does not exist, we create it here so we can mark a deeper node + parentNode[pathKey] = {}; + } + + parentNode = parentNode[pathKey]; + } + + if (this.open) { + this._markExpandedNodesRecursive(parentNode, this); + this.storage.setLocalItem(key, data); + } else if (parentNode[this.name]) { + // Deleting from `parentNode` directly updates the `data` object + delete parentNode[this.name]; + this.storage.setLocalItem(key, data); + } + } + + /** + * Recursively mark a node and all open children in the hierarchical "expanded nodes" object. + * This method updates `data` to reflect the current node's expanded state: + * - If the node has any open children, it creates an object branch and recursively marks those children. + * - If the node has no open children (or is a leaf), it stores a marker value `{}`. + * @param {object} data - The current level in the hierarchical data object where nodes are stored. + * @param {ObjectTree} treeNode - The tree node whose expanded state should be stored. + */ + _markExpandedNodesRecursive(data, treeNode) { + if (!data[treeNode.name]) { + data[treeNode.name] = {}; + } + treeNode.children + .filter((child) => child.open) + .forEach((child) => this._markExpandedNodesRecursive(data[treeNode.name], child)); + }; + /** * Toggle this node (open/close) * @returns {undefined} */ toggle() { this.open = !this.open; + this.storeExpandedNodes(); this.notify(); } @@ -70,6 +170,7 @@ export default class ObjectTree extends Observable { openAll() { this.open = true; this.children.forEach((child) => child.openAll()); + this.storeExpandedNodes(); this.notify(); } @@ -80,6 +181,7 @@ export default class ObjectTree extends Observable { closeAll() { this.open = false; this.children.forEach((child) => child.closeAll()); + this.storeExpandedNodes(); this.notify(); } @@ -97,15 +199,14 @@ export default class ObjectTree extends Observable { * addChild(o, [], ['A', 'B']) // end inserting, affecting B * @returns {undefined} */ - addChild(object, path, pathParent) { + _addChild(object, path = undefined, pathParent = []) { // Fill the path argument through recursive call if (!path) { if (!object.name) { throw new Error('Object name must exist'); } path = object.name.split('/'); - this.addChild(object, path, []); - this.notify(); + this._addChild(object, path); return; } @@ -134,15 +235,26 @@ export default class ObjectTree extends Observable { } // Pass to child - subtree.addChild(object, path, fullPath); + subtree._addChild(object, path, fullPath); } /** - * Add a list of objects by calling `addChild` + * Add a single object as a child node + * @param {object} object - child to be added + */ + addOneChild(object) { + this._addChild(object); + this.loadExpandedNodes(); + this.notify(); + } + + /** + * Add a list of objects as child nodes * @param {Array} objects - children to be added - * @returns {undefined} */ addChildren(objects) { - objects.forEach((object) => this.addChild(object)); + objects.forEach((object) => this._addChild(object)); + this.loadExpandedNodes(); + this.notify(); } } From f19578d0d3763d1bcc00f22c559b0dfbc510221f Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:22:10 +0100 Subject: [PATCH 2/5] Add a quick (and hacky) workaround for local storage reset due to non-isolated tests. --- .../test/public/features/filterTest.test.js | 8 +++++++ .../test/public/pages/object-tree.test.js | 21 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index bb4130d09..382641cb7 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -13,6 +13,8 @@ import { strictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; +import { removeLocalStorage } from '../../testUtils/localStorage.js'; +import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; export const filterTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('filter should persist between pages', { timeout }, async () => { @@ -84,6 +86,12 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { }); await testParent.test('ObjectTreePage should apply filters for the objects', { timeout }, async () => { + // Ideally, tests should be isolated and not depend on each other. + // Currently, some tests rely on shared localStorage or page state changes from previous tests. + // As a workaround, we do targeted cleanup here to prevent issues in later tests. + const personid = await page.evaluate(() => window.model.session.personid); + await removeLocalStorage(page, `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`); + await page.goto( `${url}?page=objectTree`, { waitUntil: 'networkidle0' }, diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 45788051d..573af3500 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -11,9 +11,9 @@ * or submit itself to any jurisdiction. */ -import { strictEqual, ok, deepStrictEqual } from 'node:assert'; +import { strictEqual, ok, deepStrictEqual, notDeepStrictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; -import { getLocalStorage } from '../../testUtils/localStorage.js'; +import { getLocalStorage, getLocalStorageAsJson, removeLocalStorage } from '../../testUtils/localStorage.js'; import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; const OBJECT_TREE_PAGE_PARAM = '?page=objectTree'; @@ -43,7 +43,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) ok(rowsCount > 1); // more than 1 object in the tree }); - await testParent.test('should not preserve state if refreshed not in run mode', { timeout }, async () => { + await testParent.test('should preserve state if refreshed', { timeout }, async () => { const tbodyPath = 'section > div > div > div > table > tbody'; await page.locator(`${tbodyPath} > tr:nth-child(2)`).click(); await page.reload({ waitUntil: 'networkidle0' }); @@ -51,7 +51,14 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) const rowCount = await page.evaluate(() => document.querySelectorAll('section > div > div > div > table > tbody > tr').length); - strictEqual(rowCount, 2); + // Ideally, tests should be isolated and not depend on each other. + // Currently, some tests rely on shared localStorage or page state changes from previous tests. + // As a workaround, we do targeted cleanup here to prevent issues in later tests. + const personid = await page.evaluate(() => window.model.session.personid); + await removeLocalStorage(page, `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`); + await page.reload({ waitUntil: 'networkidle0' }); + + strictEqual(rowCount, 3); }); await testParent.test('should have a button to sort by (default "Name" ASC)', async () => { @@ -134,6 +141,12 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) 'should maintain panel width from localStorage on page reload', { timeout }, async () => { + // Ideally, tests should be isolated and not depend on each other. + // Currently, some tests rely on shared localStorage or page state changes from previous tests. + // As a workaround, we do targeted cleanup here to prevent issues in later tests. + const personid = await page.evaluate(() => window.model.session.personid); + await removeLocalStorage(page, `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`); + const dragAmount = 35; await page.reload({ waitUntil: 'networkidle0' }); await page.evaluate(() => document.querySelector('tr.object-selectable:nth-child(2)').click()); From 3f38aaa03353ab7c0c9dcdcc011865f3bbd43370 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:22:39 +0100 Subject: [PATCH 3/5] Add a test to make sure the local storage is updated upon clicking a tree node element --- .../test/public/pages/object-tree.test.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 573af3500..3bd40b50f 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -43,6 +43,30 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) ok(rowsCount > 1); // more than 1 object in the tree }); + await testParent.test('should update local storage when tree node is clicked', { timeout }, async () => { + const selector = 'section > div > div > div > table > tbody > tr:nth-child(2)'; + const personid = await page.evaluate(() => window.model.session.personid); + const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`; + + await page.locator(selector).click(); + const localStorageBefore = await getLocalStorageAsJson(page, storageKey); + + await page.locator(selector).click(); + const localStorageAfter = await getLocalStorageAsJson(page, storageKey); + + // Ideally, tests should be isolated and not depend on each other. + // Currently, some tests rely on shared localStorage or page state changes from previous tests. + // As a workaround, we do targeted cleanup here to prevent issues in later tests. + await removeLocalStorage(page, storageKey); + await page.reload({ waitUntil: 'networkidle0' }); + + notDeepStrictEqual( + localStorageBefore, + localStorageAfter, + 'local storage should have changed after clicking a tree node', + ); + }); + await testParent.test('should preserve state if refreshed', { timeout }, async () => { const tbodyPath = 'section > div > div > div > table > tbody'; await page.locator(`${tbodyPath} > tr:nth-child(2)`).click(); From 8a0a5a8bd36764d83c268402ce92778d3d3298ed Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:40:19 +0100 Subject: [PATCH 4/5] Refactor tests --- .../test/public/features/filterTest.test.js | 7 -- .../test/public/pages/object-tree.test.js | 68 +++++++------------ 2 files changed, 26 insertions(+), 49 deletions(-) diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index 382641cb7..c157fc4b7 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -86,18 +86,11 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { }); await testParent.test('ObjectTreePage should apply filters for the objects', { timeout }, async () => { - // Ideally, tests should be isolated and not depend on each other. - // Currently, some tests rely on shared localStorage or page state changes from previous tests. - // As a workaround, we do targeted cleanup here to prevent issues in later tests. - const personid = await page.evaluate(() => window.model.session.personid); - await removeLocalStorage(page, `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`); - await page.goto( `${url}?page=objectTree`, { waitUntil: 'networkidle0' }, ); - await extendTree(3, 5); let rowCount = await page.evaluate(() => document.querySelectorAll('tr').length); strictEqual(rowCount, 7); diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 3bd40b50f..a4cfadff3 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -13,7 +13,7 @@ import { strictEqual, ok, deepStrictEqual, notDeepStrictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; -import { getLocalStorage, getLocalStorageAsJson, removeLocalStorage } from '../../testUtils/localStorage.js'; +import { getLocalStorage, getLocalStorageAsJson } from '../../testUtils/localStorage.js'; import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; const OBJECT_TREE_PAGE_PARAM = '?page=objectTree'; @@ -43,46 +43,22 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) ok(rowsCount > 1); // more than 1 object in the tree }); - await testParent.test('should update local storage when tree node is clicked', { timeout }, async () => { + await testParent.test('should preserve state if refreshed', { timeout }, async () => { const selector = 'section > div > div > div > table > tbody > tr:nth-child(2)'; - const personid = await page.evaluate(() => window.model.session.personid); - const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`; - - await page.locator(selector).click(); - const localStorageBefore = await getLocalStorageAsJson(page, storageKey); - await page.locator(selector).click(); - const localStorageAfter = await getLocalStorageAsJson(page, storageKey); - - // Ideally, tests should be isolated and not depend on each other. - // Currently, some tests rely on shared localStorage or page state changes from previous tests. - // As a workaround, we do targeted cleanup here to prevent issues in later tests. - await removeLocalStorage(page, storageKey); await page.reload({ waitUntil: 'networkidle0' }); - notDeepStrictEqual( - localStorageBefore, - localStorageAfter, - 'local storage should have changed after clicking a tree node', - ); - }); + const rowCountExpanded = await page.evaluate(() => + document.querySelectorAll('section > div > div > div > table > tbody > tr').length); - await testParent.test('should preserve state if refreshed', { timeout }, async () => { - const tbodyPath = 'section > div > div > div > table > tbody'; - await page.locator(`${tbodyPath} > tr:nth-child(2)`).click(); + await page.locator(selector).click(); await page.reload({ waitUntil: 'networkidle0' }); - const rowCount = await page.evaluate(() => + const rowCountCollapsed = await page.evaluate(() => document.querySelectorAll('section > div > div > div > table > tbody > tr').length); - // Ideally, tests should be isolated and not depend on each other. - // Currently, some tests rely on shared localStorage or page state changes from previous tests. - // As a workaround, we do targeted cleanup here to prevent issues in later tests. - const personid = await page.evaluate(() => window.model.session.personid); - await removeLocalStorage(page, `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`); - await page.reload({ waitUntil: 'networkidle0' }); - - strictEqual(rowCount, 3); + strictEqual(rowCountExpanded, 3); + strictEqual(rowCountCollapsed, 2); }); await testParent.test('should have a button to sort by (default "Name" ASC)', async () => { @@ -165,18 +141,8 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) 'should maintain panel width from localStorage on page reload', { timeout }, async () => { - // Ideally, tests should be isolated and not depend on each other. - // Currently, some tests rely on shared localStorage or page state changes from previous tests. - // As a workaround, we do targeted cleanup here to prevent issues in later tests. - const personid = await page.evaluate(() => window.model.session.personid); - await removeLocalStorage(page, `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`); - const dragAmount = 35; await page.reload({ waitUntil: 'networkidle0' }); - await page.evaluate(() => document.querySelector('tr.object-selectable:nth-child(2)').click()); - await delay(500); - await page.evaluate(() => document.querySelector('tr.object-selectable:nth-child(3)').click()); - await delay(500); await page.evaluate(() => document.querySelector('tr.object-selectable:nth-child(4)').click()); await delay(1000); const panelWidth = await page.evaluate(() => @@ -265,6 +231,24 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) } ); + await testParent.test('should update local storage when tree node is clicked', { timeout }, async () => { + const selector = 'section > div > div > div > table > tbody > tr:nth-child(2)'; + const personid = await page.evaluate(() => window.model.session.personid); + const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`; + + await page.locator(selector).click(); + const localStorageBefore = await getLocalStorageAsJson(page, storageKey); + + await page.locator(selector).click(); + const localStorageAfter = await getLocalStorageAsJson(page, storageKey); + + notDeepStrictEqual( + localStorageBefore, + localStorageAfter, + 'local storage should have changed after clicking a tree node', + ); + }); + await testParent.test('should sort list of histograms by name in descending order', async () => { await page.locator('#sortTreeButton').click(); const sortingByNameOptionPath = '#sortTreeButton > div > a:nth-child(2)'; From c10f0ed14e334515bcb8a59fe12dd5eb2cf22f93 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:43:33 +0100 Subject: [PATCH 5/5] Remove unused imports --- QualityControl/test/public/features/filterTest.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index c157fc4b7..142fc33b9 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -13,8 +13,6 @@ import { strictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; -import { removeLocalStorage } from '../../testUtils/localStorage.js'; -import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; export const filterTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('filter should persist between pages', { timeout }, async () => {