From 140c55509dfdfab215da6e4e88c99a7cbcb7b1e2 Mon Sep 17 00:00:00 2001 From: Guust Date: Mon, 5 May 2025 23:35:31 +0200 Subject: [PATCH 01/44] fix: temporary setTimout fix for ObjectTree sort issue --- QualityControl/public/object/ObjectTree.class.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/QualityControl/public/object/ObjectTree.class.js b/QualityControl/public/object/ObjectTree.class.js index 4135b5e9b..ce3ff1b07 100644 --- a/QualityControl/public/object/ObjectTree.class.js +++ b/QualityControl/public/object/ObjectTree.class.js @@ -51,6 +51,8 @@ export default class ObjectTree extends Observable { * @returns {undefined} */ toggle() { + // TODO: fix 'parent tree' collapsing bug by adding:. + // setTimeout(() => this.notify(), 100); this.open = !this.open; this.notify(); } @@ -144,5 +146,6 @@ export default class ObjectTree extends Observable { */ addChildren(objects) { objects.forEach((object) => this.addChild(object)); + setTimeout(() => this.notify(), 100); // TODO: create more dynamic solution. } } From a07103c8d2b083d2705b5683551adc54e1b79696 Mon Sep 17 00:00:00 2001 From: Guust Date: Mon, 12 May 2025 17:43:24 +0200 Subject: [PATCH 02/44] feat: rebuild ObjectTree sorting mechanism --- .../public/object/ObjectTree.class.js | 30 ++++++++- QualityControl/public/object/QCObject.js | 3 +- .../public/object/objectTreePage.js | 64 +++++++++---------- 3 files changed, 58 insertions(+), 39 deletions(-) diff --git a/QualityControl/public/object/ObjectTree.class.js b/QualityControl/public/object/ObjectTree.class.js index ce3ff1b07..1e8f245e8 100644 --- a/QualityControl/public/object/ObjectTree.class.js +++ b/QualityControl/public/object/ObjectTree.class.js @@ -44,6 +44,9 @@ export default class ObjectTree extends Observable { this.parent = parent || null; // this.path = []; // Like ['A', 'B'] for node at path 'A/B' called 'B' this.pathString = ''; // 'A/B' + if (parent) { + this.bubbleTo(parent); + } } /** @@ -107,7 +110,6 @@ export default class ObjectTree extends Observable { } path = object.name.split('/'); this.addChild(object, path, []); - this.notify(); return; } @@ -132,7 +134,6 @@ export default class ObjectTree extends Observable { subtree.path = fullPath; subtree.pathString = fullPath.join('/'); this.children.push(subtree); - subtree.observe(() => this.notify()); } // Pass to child @@ -146,6 +147,29 @@ export default class ObjectTree extends Observable { */ addChildren(objects) { objects.forEach((object) => this.addChild(object)); - setTimeout(() => this.notify(), 100); // TODO: create more dynamic solution. + this.notify(); + } + + sortChildren(field, order) { + this.open = this.name === 'qc' ? true : false; + this.children = this.children.sort((child1, child2) => this._compareStrings(child1[field], child2[field], order)); + this.children.forEach((child) => { + if (child.children.length > 1) { + child.sortChildren(field, order); + } + }); + + this.notify(); + } + + /** + * Helper method for sortListByField for sorting strings + * @param {string} a - first string to be sorted + * @param {string} b - second string to be sorted + * @param {number} order - acending (1) or decending (-1) + * @returns {undefined} + */ + _compareStrings(a, b, order) { + return a.toUpperCase().localeCompare(b.toUpperCase()) * order; } } diff --git a/QualityControl/public/object/QCObject.js b/QualityControl/public/object/QCObject.js index b56e7ec32..b8b1c1319 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -160,8 +160,7 @@ export default class QCObject extends Observable { */ sortTree(title, field, order, icon) { this.sortListByField(this.currentList, field, order); - this.tree.initTree('database'); - this.tree.addChildren(this.currentList); + this.tree.sortChildren(field, order); this._computeFilters(); diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 1707b42c5..2ae5a2579 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -1,17 +1,3 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * In applying this license CERN does not waive the privileges and immunities - * granted to it by virtue of its status as an Intergovernmental Organization - * or submit itself to any jurisdiction. - */ - import { h, iconBarChart, iconCaretRight, iconResizeBoth, iconCaretBottom, iconCircleX } from '/js/src/index.js'; import { spinner } from '../common/spinner.js'; import { draw } from './objectDraw.js'; @@ -154,13 +140,15 @@ const tableShow = (model) => * @param {Model} model - root model of the application * @returns {vnode} - virtual node element */ -const treeRows = (model) => !model.object.tree ? - null - : - - model.object.tree.children.length === 0 - ? h('.w-100.text-center', 'No objects found') - : model.object.tree.children.map((children) => treeRow(model, children, 0)); +const treeRows = (model) => { + const { order } = model.object.sortBy; + return !model.object.tree + ? null + : model.object.tree.children.length === 0 + ? h('.w-100.text-center', 'No objects found') + : model.object.tree.children + .map((children) => treeRow(model, children, 0)); +}; /** * Shows a line of object represented by parent node `tree`, also shows @@ -181,21 +169,21 @@ function treeRow(model, tree, level) { if (model.object.searchInput) { return []; - } else { - if (tree.object && tree.children.length === 0) { - return [leafRow(path, () => model.object.select(tree.object), className, padding, tree.name)]; - } else if (tree.object && tree.children.length > 0) { - return [ - leafRow(path, () => model.object.select(tree.object), className, padding, tree.name), - branchRow(path, tree, padding), - children, - ]; - } + } + + if (tree.object && tree.children.length === 0) { + return [leafRow(path, () => model.object.select(tree.object), className, padding, tree.name)]; + } else if (tree.object && tree.children.length > 0) { return [ + leafRow(path, () => model.object.select(tree.object), className, padding, tree.name), branchRow(path, tree, padding), - children, + ...children, ]; } + return [ + branchRow(path, tree, padding), + ...children, + ]; } /** @@ -210,7 +198,11 @@ function treeRow(model, tree, level) { */ const leafRow = (path, selectItem, className, padding, leafName) => h('tr.object-selectable', { - key: path, title: path, onclick: selectItem, class: className, id: path, + key: path, + title: path, + onclick: selectItem, + class: className, + id: path, }, [ h('td.highlight', [ h('span', { style: { paddingLeft: padding } }, iconBarChart()), @@ -228,7 +220,11 @@ const leafRow = (path, selectItem, className, padding, leafName) => * @returns {vnode} - virtual node element */ const branchRow = (path, tree, padding) => - h('tr.object-selectable', { key: path, title: path, onclick: () => tree.toggle() }, [ + h('tr.object-selectable', { + key: path, + title: path, + onclick: () => tree.toggle(), + }, [ h('td.highlight', [ h('span', { style: { paddingLeft: padding } }, tree.open ? iconCaretBottom() : iconCaretRight()), ' ', From abff6e184507fdfdcf554f12be2254ef75136798 Mon Sep 17 00:00:00 2001 From: Guust Date: Tue, 13 May 2025 19:35:37 +0200 Subject: [PATCH 03/44] feat: create ObjectTreeModel --- .../objectTreeView/model/ObjectTreeModel.js | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js diff --git a/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js new file mode 100644 index 000000000..9a56ec4f7 --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Observable } from '/js/src/index.js'; + +/** + * This class allows to transforms objectModels names (A/B/C) into a tree that can have + * some behaviours like open/close nodes. It also allows to update all those objectModels without creating + * a new tree. + */ +export default class ObjectTreeModel extends Observable { + /** + * Instantiate tree with a root node called `name`, empty by default + * @param {string} name - root name + * @param {ObjectTreeModel} parent - optional parent node + */ + constructor(name, parent) { + super(); + this.initTree(name, parent); + } + + /** + * Method to instantiate/reset the tree + * @param {string} name - name of the tree to be initialized + * @param {string} parent - parent of the tree + * @returns {undefined} + */ + initTree(name, parent) { + this.name = name || ''; // Like 'B' + this.open = name === 'qc' ? true : false; + this.children = []; // > + this.parent = parent || null; // + this.path = []; // Like ['A', 'B'] for node at path 'A/B' called 'B' + this.pathString = ''; // 'A/B' + } + + /** + * Toggle this node (open/close) + * @returns {undefined} + */ + toggle() { + this.open = !this.open; + this.notify(); + } + + /** + * Open all or close all nodes of the tree + * @returns {undefined} + */ + toggleAll() { + this.open ? this.closeAll() : this.openAll(); + } + + /** + * Open all nodes of the tree + * @returns {undefined} + */ + openAll() { + this.open = true; + this.children.forEach((child) => child.openAll()); + this.notify(); + } + + /** + * Close all nodes of the tree + * @returns {undefined} + */ + closeAll() { + this.open = false; + this.children.forEach((child) => child.closeAll()); + this.notify(); + } + + /** + * Add recursively an objectModel inside a tree + * @param {ObjectModel} objectModel - The objectModel to be inserted, property name must exist + * @param {Array.} path - Path of the objectModel to dig in before assigning to a tree node, + * if null objectModel.name is used + * @param {Array.} pathParent - Path of the current tree node, if null objectModel.name is used + * + * Example of recursive call: + * addChild(o) // begin insert 'A/B' + * addChild(o, ['A', 'B'], []) + * addChild(o, ['B'], ['A']) + * addChild(o, [], ['A', 'B']) // end inserting, affecting B + * @returns {undefined} + */ + addChild(objectModel, path, pathParent) { + // Fill the path argument through recursive call + if (!path) { + if (!objectModel.name) { + throw new Error('Object name must exist'); + } + path = objectModel.name.split('/'); + path.length--; // The last one is the object name, which isn't needed for the path + this.addChild(objectModel, path, []); + this.notify(); + return; + } + + if (path.length === 0) { + this.children.push(objectModel); + return; + } + + // Case we need to pass to subtree + const name = path.shift(); + const fullPath = [...pathParent, name]; + let subtree = this.children.find((children) => children.name === name); + + // Subtree does not exist yet + if (!subtree) { + /* + * Create it and push as child + * Listen also for changes to bubble it until root + */ + subtree = new ObjectTreeModel(name, this); + subtree.path = fullPath; + subtree.pathString = fullPath.join('/'); + this.children.push(subtree); + subtree.observe(() => this.notify()); + } + + // Pass to child + subtree.addChild(objectModel, path, fullPath); + } + + /** + * Add a list of objectModels by calling `addChild` + * @param {Array} objectModels - children to be added + * @returns {undefined} + */ + addChildren(objectModels) { + objectModels.forEach((objectModel) => this.addChild(objectModel)); + } + + sortChildren(field, order) { + this.open = this.name === 'qc' ? true : false; + this.children = this.children.sort((child1, child2) => this._compareStrings(child1[field], child2[field], order)); + this.children.forEach((child) => { + if (child instanceof ObjectTreeModel) { + child.sortChildren(field, order); + } + }); + + this.notify(); + } + + /** + * Helper method for sortListByField for sorting strings + * @param {string} a - first string to be sorted + * @param {string} b - second string to be sorted + * @param {number} order - acending (1) or decending (-1) + * @returns {undefined} + */ + _compareStrings(a, b, order) { + return a.toUpperCase().localeCompare(b.toUpperCase()) * order; + } +} From d868c82b1c76276368a928ec1eb77f1c80beab34 Mon Sep 17 00:00:00 2001 From: Guust Date: Tue, 13 May 2025 19:36:34 +0200 Subject: [PATCH 04/44] feat: create ObjectTreeComponent --- QualityControl/public/app.css | 31 +++++++- .../component/ObjectTreeComponent.js | 78 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 62d56d37d..e11a8bba6 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -23,7 +23,12 @@ 100% { opacity: 1; } } -.object-selectable { cursor: pointer; text-decoration: none; } +.object-selectable { + cursor: pointer; + text-decoration: none; + display: block; + padding: 0.3em; +} .object-selectable:hover { cursor: pointer; background-color: var(--color-gray-dark) !important; color: var(--color-gray-lighter); } .layout-selectable { border: 0.0em solid var(--color-primary); transition: border 0.1s; } @@ -65,6 +70,30 @@ height: 100%; } +.heading { + font-weight: bold; + padding: 0.3em; + background-color: var(--color-gray-light); +} + +.root-tree { + list-style-type: none; + margin: 0; + padding-left: 0.1em; +} + +ul.object-tree-list { + list-style-type: none; + margin: 0; + padding-left: 1em; +} + +li.object-tree-branch, ul.object-tree-list { + display: flex; + flex-direction: column; +} + + .status-bar { border-top: 1px solid var(--color-gray-dark); } /* Show plots gradually */ diff --git a/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js b/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js new file mode 100644 index 000000000..12f21991a --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h, iconBarChart, iconCaretRight, iconCaretBottom } from '/js/src/index.js'; +import ObjectTreeModel from '../model/ObjectTreeModel.js'; + +/** + * Shows a tree of objects using nested lists + * @param {ObjectTreeModel} treeModel - the model that controls this tree's state + * @returns {vnode} - virtual node element + */ +export default function (treeModel) { + return [ + h('.heading', 'Name'), + h('ul.root-tree', [treeItems(treeModel)]), + ]; +} + +/** + * Recursively renders tree items + * @param {ObjectTreeModel} treeModel - the model that controls this tree's state + * @returns {Array} - array of virtual node elements + */ +const treeItems = (treeModel) => + treeModel.children.map((child) => + child instanceof ObjectTreeModel + ? branchItem(child) + : leafItem(child)); + +/** + * Creates a list item for a branch (folder-like node that can be expanded/collapsed) + * @param {ObjectTreeModel} treeModel - current tree branch + * @returns {vnode} - virtual node element + */ +const branchItem = (treeModel) => { + const { name, open } = treeModel; + + return h('li.object-tree-branch', { title: name }, [ + h('div.object-selectable', { onclick: () => treeModel.toggle() }, [ + h('span', open ? iconCaretBottom() : iconCaretRight()), + ' ', + name, + ]), + open ? h('ul.object-tree-list', treeItems(treeModel)) : null, + ]); +}; + +/** + * Creates a list item for a leaf (end node that represents an object) + * @param {object} leaf - the leaf object + * @returns {vnode} - virtual node element + */ +const leafItem = (leaf) => { + const { name, path } = leaf; + const displayName = name.split('/').pop(); + + return h('li.object-tree-leaf', { key: path }, [ + h('div.object-selectable', { + onclick: () => model.object.select(leaf), + title: path, + }, [ + h('span', iconBarChart()), + ' ', + displayName, + ]), + ]); +}; From 92b3b6887df80dce0d150d1ba6fc72736f0cbc61 Mon Sep 17 00:00:00 2001 From: Guust Date: Tue, 13 May 2025 19:43:04 +0200 Subject: [PATCH 05/44] feat: add objectPanel file. --- .../objectTreeView/component/objectPanel.js | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 QualityControl/public/pages/objectTreeView/component/objectPanel.js diff --git a/QualityControl/public/pages/objectTreeView/component/objectPanel.js b/QualityControl/public/pages/objectTreeView/component/objectPanel.js new file mode 100644 index 000000000..b3e99775f --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/component/objectPanel.js @@ -0,0 +1,84 @@ +import { h, iconResizeBoth, iconCircleX } from '/js/src/index.js'; +import { draw } from '../../../object/objectDraw.js'; +import timestampSelectForm from '../../../common/timestampSelectForm.js'; +import { qcObjectInfoPanel } from '../../../common/object/objectInfoCard.js'; +import { spinner } from '../../../common/spinner.js'; + +/** + * Method to tackle various states for the selected objects + * @param {Model} model - root model of the application + * @returns {vnode} - virtual node element + */ +export function objectPanel(model) { + const selectedObjectName = model.object.selected.name; + if (model.object.objects && model.object.objects[selectedObjectName]) { + return model.object.objects[selectedObjectName].match({ + NotAsked: () => null, + Loading: () => + h('.h-100.w-100.flex-column.items-center.justify-center.f5', [spinner(3), h('', 'Loading Object')]), + Success: (data) => drawPlot(model, data), + Failure: (error) => + h('.h-100.w-100.flex-column.items-center.justify-center.f5', [h('.f1', iconCircleX()), error]), + }); + } + return null; +} + +/** + * Draw the object including the info button and history dropdown + * @param {Model} model - root model of the application + * @param {JSON} object - {qcObject, info, timestamps} + * @returns {vnode} - virtual node element + */ +export const drawPlot = (model, object) => { + const { name, validFrom, id } = object; + const href = validFrom ? + `?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}` + : `?page=objectView&objectName=${name}`; + const info = object; + return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ + h('.resize-button.flex-row', [ + h('.p1.text-left', { style: 'padding-bottom: 0;' }, h( + 'a.btn', + { + title: 'Open object plot in full screen', + href, + onclick: (e) => model.router.handleLinkEvent(e), + }, + iconResizeBoth(), + )), + ]), + h('', { style: 'height:77%;' }, draw(model, name, { stat: true })), + h('.scroll-y', {}, [ + h('.w-100.flex-row', { style: 'justify-content: center' }, h('.w-80', timestampSelectForm(model))), + qcObjectInfoPanel(info, { 'font-size': '.875rem;' }), + ]), + ]); +}; + +/** + * Shows status of current tree with its options (online, loaded, how many) + * @param {QCObject} object - object model that handles state around object + * @returns {vnode} - virtual node element + */ +export function statusBarLeft(object) { + let itemsInfo = ''; + if (!object.currentList) { + itemsInfo = 'Loading objects...'; + } else if (object.searchInput) { + itemsInfo = `${object.searchResult.length} found of ${object.currentList.length} items`; + } else { + itemsInfo = `${object.currentList.length} items`; + } + + return h('span.flex-grow', itemsInfo); +} + +/** + * Shows current selected object path + * @param {QCObject} object - object model that handles state around object + * @returns {vnode} - virtual node element + */ +export const statusBarRight = (object) => object.selected + ? h('span.right', object.selected.name) + : null; From a57351c55ae36d467bb6e83d6f7cc2d80a3689fe Mon Sep 17 00:00:00 2001 From: Guust Date: Tue, 13 May 2025 19:43:53 +0200 Subject: [PATCH 06/44] feat: create new objectTreePage.js --- .../pages/objectTreeView/objectTreePage.js | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 QualityControl/public/pages/objectTreeView/objectTreePage.js diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js new file mode 100644 index 000000000..74eafafa7 --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { spinner } from '../../common/spinner.js'; +import virtualTable from '../../object/virtualTable.js'; +import { objectPanel, statusBarLeft, statusBarRight } from './component/objectPanel.js'; +import ObjectTreeComponent from './component/ObjectTreeComponent.js'; +import { h } from '/js/src/index.js'; + +/** + * Shows a page to explore though a tree of objects with a preview on the right if clicked + * and a status bar for selected object name and # of objects + * @param {Model} model - root model of the application + * @returns {vnode} - virtual node element + */ +export default (model) => h('.h-100.flex-column', { key: model.router.params.page }, [ + h('.flex-row.flex-grow', [ + h('.scroll-y.flex-column', { + style: { + width: model.object.selected ? '50%' : '100%', + }, + }, model.object.objectsRemote.match({ + NotAsked: () => null, + Loading: () => + h('.absolute-fill.flex-column.items-center.justify-center.f5', [spinner(5), h('', 'Loading Objects')]), + Success: () => { + const searchInput = model.object?.searchInput?.trim() ?? ''; + if (searchInput !== '') { + const objectsLoaded = model.object.list; + const objectsToDisplay = objectsLoaded.filter((qcObject) => + qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); + return virtualTable(model, 'main', objectsToDisplay); + } + return ObjectTreeComponent(model.object.tree); + }, + Failure: () => null, // Notification is displayed + })), + h('.animate-width.scroll-y', { + style: { + width: model.object.selected ? '50%' : 0, + }, + }, model.object.selected ? objectPanel(model) : null), + ]), + h('.f6.status-bar.ph1.flex-row', [ + statusBarLeft(model), + statusBarRight(model), + ]), +]); From a6b04c7f11dfa74dea1c0ec425521d315d9f90e0 Mon Sep 17 00:00:00 2001 From: Guust Date: Tue, 13 May 2025 19:46:01 +0200 Subject: [PATCH 07/44] feat: replace old objectTreePage with new objectTreePage --- QualityControl/public/object/QCObject.js | 3 +- .../public/object/objectTreePage.js | 233 ------------------ QualityControl/public/view.js | 2 +- .../test/public/pages/object-tree.test.js | 18 +- 4 files changed, 12 insertions(+), 244 deletions(-) delete mode 100644 QualityControl/public/object/objectTreePage.js diff --git a/QualityControl/public/object/QCObject.js b/QualityControl/public/object/QCObject.js index b8b1c1319..5ef6348c4 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -16,6 +16,7 @@ import { Observable, RemoteData, iconArrowTop } from '/js/src/index.js'; import ObjectTree from './ObjectTree.class.js'; import { prettyFormatDate, setBrowserTabTitle } from './../common/utils.js'; import { isObjectOfTypeChecker } from './../library/qcObject/utils.js'; +import ObjectTreeModel from '../pages/objectTreeView/model/ObjectTreeModel.js'; /** * Model namespace for all about QC's objects (not javascript objects) @@ -48,7 +49,7 @@ export default class QCObject extends Observable { open: false, }; - this.tree = new ObjectTree('database'); + this.tree = new ObjectTreeModel('database'); this.tree.bubbleTo(this); this.sideTree = new ObjectTree('database'); diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js deleted file mode 100644 index 2ae5a2579..000000000 --- a/QualityControl/public/object/objectTreePage.js +++ /dev/null @@ -1,233 +0,0 @@ -import { h, iconBarChart, iconCaretRight, iconResizeBoth, iconCaretBottom, iconCircleX } from '/js/src/index.js'; -import { spinner } from '../common/spinner.js'; -import { draw } from './objectDraw.js'; -import timestampSelectForm from './../common/timestampSelectForm.js'; -import virtualTable from './virtualTable.js'; -import { qcObjectInfoPanel } from '../common/object/objectInfoCard.js'; - -/** - * Shows a page to explore though a tree of objects with a preview on the right if clicked - * and a status bar for selected object name and # of objects - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -export default (model) => h('.h-100.flex-column', { key: model.router.params.page }, [ - h('.flex-row.flex-grow', [ - h('.scroll-y.flex-column', { - style: { - width: model.object.selected ? '50%' : '100%', - }, - }, model.object.objectsRemote.match({ - NotAsked: () => null, - Loading: () => - h('.absolute-fill.flex-column.items-center.justify-center.f5', [spinner(5), h('', 'Loading Objects')]), - Success: () => { - const searchInput = model.object?.searchInput?.trim() ?? ''; - if (searchInput !== '') { - const objectsLoaded = model.object.list; - const objectsToDisplay = objectsLoaded.filter((qcObject) => - qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); - return virtualTable(model, 'main', objectsToDisplay); - } - return tableShow(model); - }, - Failure: () => null, // Notification is displayed - })), - h('.animate-width.scroll-y', { - style: { - width: model.object.selected ? '50%' : 0, - }, - }, model.object.selected ? objectPanel(model) : null), - ]), - h('.f6.status-bar.ph1.flex-row', [ - statusBarLeft(model), - statusBarRight(model), - ]), -]); - -/** - * Method to tackle various states for the selected objects - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -function objectPanel(model) { - const selectedObjectName = model.object.selected.name; - if (model.object.objects && model.object.objects[selectedObjectName]) { - return model.object.objects[selectedObjectName].match({ - NotAsked: () => null, - Loading: () => - h('.h-100.w-100.flex-column.items-center.justify-center.f5', [spinner(3), h('', 'Loading Object')]), - Success: (data) => drawPlot(model, data), - Failure: (error) => - h('.h-100.w-100.flex-column.items-center.justify-center.f5', [h('.f1', iconCircleX()), error]), - }); - } - return null; -} - -/** - * Draw the object including the info button and history dropdown - * @param {Model} model - root model of the application - * @param {JSON} object - {qcObject, info, timestamps} - * @returns {vnode} - virtual node element - */ -const drawPlot = (model, object) => { - const { name, validFrom, id } = object; - const href = validFrom ? - `?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}` - : `?page=objectView&objectName=${name}`; - const info = object; - return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ - h('.resize-button.flex-row', [ - h('.p1.text-left', { style: 'padding-bottom: 0;' }, h( - 'a.btn', - { - title: 'Open object plot in full screen', - href, - onclick: (e) => model.router.handleLinkEvent(e), - }, - iconResizeBoth(), - )), - ]), - h('', { style: 'height:77%;' }, draw(model, name, { stat: true })), - h('.scroll-y', {}, [ - h('.w-100.flex-row', { style: 'justify-content: center' }, h('.w-80', timestampSelectForm(model))), - qcObjectInfoPanel(info, { 'font-size': '.875rem;' }), - ]), - ]); -}; - -/** - * Shows status of current tree with its options (online, loaded, how many) - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -function statusBarLeft(model) { - let itemsInfo = ''; - if (!model.object.currentList) { - itemsInfo = 'Loading objects...'; - } else if (model.object.searchInput) { - itemsInfo = `${model.object.searchResult.length} found of ${model.object.currentList.length} items`; - } else { - itemsInfo = `${model.object.currentList.length} items`; - } - - return h('span.flex-grow', itemsInfo); -} - -/** - * Shows current selected object path - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -const statusBarRight = (model) => model.object.selected - ? h('span.right', model.object.selected.name) - : null; - -/** - * Shows a tree of objects inside a table with indentation - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -const tableShow = (model) => - h('table.table.table-sm.text-no-select', [ - h('thead', [h('tr', [h('th', 'Name')])]), - h('tbody', [treeRows(model)]), - ]); - -/** - * Shows a list of lines of objects - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -const treeRows = (model) => { - const { order } = model.object.sortBy; - return !model.object.tree - ? null - : model.object.tree.children.length === 0 - ? h('.w-100.text-center', 'No objects found') - : model.object.tree.children - .map((children) => treeRow(model, children, 0)); -}; - -/** - * Shows a line of object represented by parent node `tree`, also shows - * sub-nodes of `tree` as additional lines if they are open in the tree. - * Indentation is added according to tree level during recursive call of treeRow - * Tree is traversed in depth-first with pre-order (root then subtrees) - * @param {Model} model - root model of the application - * @param {ObjectTree} tree - data-structure containing an object per node - * @param {number} level - used for indentation within recursive call of treeRow - * @returns {vnode} - virtual node element - */ -function treeRow(model, tree, level) { - const padding = `${level}em`; - const levelDeeper = level + 1; - const children = tree.open ? tree.children.map((children) => treeRow(model, children, levelDeeper)) : []; - const path = tree.name; - const className = tree.object && tree.object === model.object.selected ? 'table-primary' : ''; - - if (model.object.searchInput) { - return []; - } - - if (tree.object && tree.children.length === 0) { - return [leafRow(path, () => model.object.select(tree.object), className, padding, tree.name)]; - } else if (tree.object && tree.children.length > 0) { - return [ - leafRow(path, () => model.object.select(tree.object), className, padding, tree.name), - branchRow(path, tree, padding), - ...children, - ]; - } - return [ - branchRow(path, tree, padding), - ...children, - ]; -} - -/** - * Creates a row containing specific visuals for leaf object and on selection - * it will plot the object with JSRoot - * @param {string} path - full name of the object - * @param {Action} selectItem - action for plotting the object - * @param {string} className - name of the row class - * @param {number} padding - space needed to be displayed so that leaf is within its parent - * @param {string} leafName - name of the object - * @returns {vnode} - virtual node element - */ -const leafRow = (path, selectItem, className, padding, leafName) => - h('tr.object-selectable', { - key: path, - title: path, - onclick: selectItem, - class: className, - id: path, - }, [ - h('td.highlight', [ - h('span', { style: { paddingLeft: padding } }, iconBarChart()), - ' ', - leafName, - ]), - ]); - -/** - * Creates a row containing specific visuals for branch object and on selection - * it will open its children - * @param {string} path - full name of the object - * @param {ObjectTree} tree - current selected tree - * @param {number} padding - space needed to be displayed so that branch is within its parent - * @returns {vnode} - virtual node element - */ -const branchRow = (path, tree, padding) => - h('tr.object-selectable', { - key: path, - title: path, - onclick: () => tree.toggle(), - }, [ - h('td.highlight', [ - h('span', { style: { paddingLeft: padding } }, tree.open ? iconCaretBottom() : iconCaretRight()), - ' ', - tree.name, - ]), - ]); diff --git a/QualityControl/public/view.js b/QualityControl/public/view.js index 6eca4cfd0..4f885d774 100644 --- a/QualityControl/public/view.js +++ b/QualityControl/public/view.js @@ -22,9 +22,9 @@ import layoutViewPage from './layout/view/page.js'; import layoutImportModal from './layout/panels/importModal.js'; import layoutEditModal from './layout/panels/editModal.js'; -import objectTreePage from './object/objectTreePage.js'; import ObjectViewPage from './pages/objectView/ObjectViewPage.js'; import AboutViewPage from './pages/aboutView/AboutViewPage.js'; +import objectTreePage from './pages/objectTreeView/objectTreePage.js'; /** * Entry point to generate view of QCG as a tree of function calls diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 59e9a3f36..78b692e65 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -14,6 +14,9 @@ import { strictEqual, ok } from 'node:assert'; const OBJECT_TREE_PAGE_PARAM = '?page=objectTree'; const SORTING_BUTTON_PATH = 'header > div > div:nth-child(3) > div > button'; +const LIST_ITEM_PATH = 'ul > li'; +const sortOptionPath = (index) => `header > div > div:nth-child(3) > div > div > a:nth-child(${index})`; +const [NAME_ASC_INDEX, NAME_DEC_INDEX] = [1, 2]; /** * Initial page setup tests @@ -29,12 +32,11 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) strictEqual(location.search, OBJECT_TREE_PAGE_PARAM); }); - await testParent.test('should have a tree as a table', { timeout }, async () => { - const tableRowPath = 'section > div > div > div > table > tbody > tr'; - await page.waitForSelector(tableRowPath, { timeout: 1000 }); + await testParent.test('should have a tree as a list', { timeout }, async () => { + await page.waitForSelector(LIST_ITEM_PATH, { timeout: 1000 }); const rowsCount = await page.evaluate( - (tableRowPath) => document.querySelectorAll(tableRowPath).length, - tableRowPath, + (LIST_ITEM_PATH) => document.querySelectorAll(LIST_ITEM_PATH).length, + LIST_ITEM_PATH, ); ok(rowsCount > 1); // more than 1 object in the tree }); @@ -52,8 +54,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) await testParent.test('should sort list of histograms by name in descending order', async () => { await page.locator(SORTING_BUTTON_PATH).click(); - const sortingByNameOptionPath = 'header > div > div:nth-child(3) > div > div > a:nth-child(2)'; - await page.locator(sortingByNameOptionPath).click(); + await page.locator(sortOptionPath(NAME_DEC_INDEX)).click(); const sorted = await page.evaluate(() => ({ list: window.model.object.currentList, @@ -67,8 +68,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) await testParent.test('should sort list of histograms by name in ascending order', async () => { await page.locator(SORTING_BUTTON_PATH).click(); - const sortingByNameOptionPath = 'header > div > div:nth-child(3) > div > div > a:nth-child(1)'; - await page.locator(sortingByNameOptionPath).click(); + await page.locator(sortOptionPath(NAME_ASC_INDEX)).click(); const sorted = await page.evaluate(() => ({ list: window.model.object.currentList, sort: window.model.object.sortBy, From ea8445dab6b32bde8c279c1707687d15db7c77b2 Mon Sep 17 00:00:00 2001 From: Guust Date: Wed, 14 May 2025 12:17:48 +0200 Subject: [PATCH 08/44] refractor: put objectTree item functions in their own file. and make treeCompoenent receive itemFunctions for its use. --- .../component/ObjectTreeComponent.js | 57 ++++--------------- .../component/objectTreeItem.js | 54 ++++++++++++++++++ .../pages/objectTreeView/objectTreePage.js | 3 +- 3 files changed, 66 insertions(+), 48 deletions(-) create mode 100644 QualityControl/public/pages/objectTreeView/component/objectTreeItem.js diff --git a/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js b/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js index 12f21991a..bf6d5fa7b 100644 --- a/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js +++ b/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js @@ -12,67 +12,30 @@ * or submit itself to any jurisdiction. */ -import { h, iconBarChart, iconCaretRight, iconCaretBottom } from '/js/src/index.js'; +import { h } from '/js/src/index.js'; import ObjectTreeModel from '../model/ObjectTreeModel.js'; /** * Shows a tree of objects using nested lists * @param {ObjectTreeModel} treeModel - the model that controls this tree's state + * @param {Function} branchItem - function that receives an ObjectTreeModel and returns a vnode + * @param {Function} leafItem - function that receives a object and returns a vnode * @returns {vnode} - virtual node element */ -export default function (treeModel) { +export default function (treeModel, branchItem, leafItem) { return [ h('.heading', 'Name'), - h('ul.root-tree', [treeItems(treeModel)]), + h('ul.root-tree', treeItems(treeModel, branchItem, leafItem)), ]; } /** * Recursively renders tree items * @param {ObjectTreeModel} treeModel - the model that controls this tree's state + * @param {Function} branchItem - function that receives an ObjectTreeModel and returns a vnode + * @param {Function} leafItem - function that receives a object and returns a vnode * @returns {Array} - array of virtual node elements */ -const treeItems = (treeModel) => - treeModel.children.map((child) => - child instanceof ObjectTreeModel - ? branchItem(child) - : leafItem(child)); - -/** - * Creates a list item for a branch (folder-like node that can be expanded/collapsed) - * @param {ObjectTreeModel} treeModel - current tree branch - * @returns {vnode} - virtual node element - */ -const branchItem = (treeModel) => { - const { name, open } = treeModel; - - return h('li.object-tree-branch', { title: name }, [ - h('div.object-selectable', { onclick: () => treeModel.toggle() }, [ - h('span', open ? iconCaretBottom() : iconCaretRight()), - ' ', - name, - ]), - open ? h('ul.object-tree-list', treeItems(treeModel)) : null, - ]); -}; - -/** - * Creates a list item for a leaf (end node that represents an object) - * @param {object} leaf - the leaf object - * @returns {vnode} - virtual node element - */ -const leafItem = (leaf) => { - const { name, path } = leaf; - const displayName = name.split('/').pop(); - - return h('li.object-tree-leaf', { key: path }, [ - h('div.object-selectable', { - onclick: () => model.object.select(leaf), - title: path, - }, [ - h('span', iconBarChart()), - ' ', - displayName, - ]), - ]); -}; +const treeItems = (treeModel, branchItem, leafItem) => + treeModel.children.map((child) => child instanceof ObjectTreeModel ? + branchItem(child, () => treeItems(child, branchItem, leafItem)) : leafItem(child)); diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js new file mode 100644 index 000000000..353d6e622 --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -0,0 +1,54 @@ +import { h, iconBarChart, iconCaretRight, iconCaretBottom } from '/js/src/index.js'; + +/** + * Creates a list item for a branch (folder-like node that can be expanded/collapsed) + * @param {ObjectTreeModel} treeModel - current tree branch + * @param {Function} treeItems - function that receives an ObjectTreeModel and returns a vnode + * @returns {vnode} - virtual node element + */ +export const branchItem = (treeModel, treeItems) => { + const { name, open } = treeModel; + + return h('li.object-tree-branch', { title: name }, [ + h('div.object-selectable', { onclick: () => treeModel.toggle() }, [ + h('span', open ? iconCaretBottom() : iconCaretRight()), + ' ', + name, + ]), + open ? h('ul.object-tree-list', treeItems(treeModel)) : null, + ]); +}; + +/** + * Creates a list item for a leaf (end node that represents an object) + * @param {object} leaf - the leaf object + * @returns {vnode} - virtual node element + */ +export const leafItem = (leaf) => { + const { name, path } = leaf; + const displayName = name.split('/').pop(); + + return h('li.object-tree-leaf', { key: path }, [ + h('div.object-selectable', { + onclick: () => model.object.select(leaf), + title: path, + }, [ + h('span', iconBarChart()), + ' ', + displayName, + ]), + ]); +}; + +// /** +// * Recursively renders tree items +// * @param {ObjectTreeModel} treeModel - the model that controls this tree's state +// * @param {Function} branchItem - function that receives an ObjectTreeModel and returns a vnode +// * @param {Function} leafItem - function that receives a object and returns a vnode +// * @returns {Array} - array of virtual node elements +// */ +// export const treeItems = (treeModel, branchItem, leafItem) => +// treeModel.children.map((child) => +// child instanceof ObjectTreeModel +// ? branchItem(child) +// : leafItem(child)); diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js index 74eafafa7..1ed636c7e 100644 --- a/QualityControl/public/pages/objectTreeView/objectTreePage.js +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -14,6 +14,7 @@ import { spinner } from '../../common/spinner.js'; import virtualTable from '../../object/virtualTable.js'; +import { branchItem, leafItem } from './component/objectTreeItem.js'; import { objectPanel, statusBarLeft, statusBarRight } from './component/objectPanel.js'; import ObjectTreeComponent from './component/ObjectTreeComponent.js'; import { h } from '/js/src/index.js'; @@ -42,7 +43,7 @@ export default (model) => h('.h-100.flex-column', { key: model.router.params.pag qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); return virtualTable(model, 'main', objectsToDisplay); } - return ObjectTreeComponent(model.object.tree); + return ObjectTreeComponent(model.object.tree, branchItem, leafItem); }, Failure: () => null, // Notification is displayed })), From 199e85c907f565178c3e13020eec0bf4e6cc6098 Mon Sep 17 00:00:00 2001 From: Guust Date: Wed, 14 May 2025 12:30:45 +0200 Subject: [PATCH 09/44] fix: pass object to statusbar left and right. --- .../pages/objectTreeView/objectTreePage.js | 70 ++++++++++--------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js index 1ed636c7e..2eada3564 100644 --- a/QualityControl/public/pages/objectTreeView/objectTreePage.js +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -25,36 +25,40 @@ import { h } from '/js/src/index.js'; * @param {Model} model - root model of the application * @returns {vnode} - virtual node element */ -export default (model) => h('.h-100.flex-column', { key: model.router.params.page }, [ - h('.flex-row.flex-grow', [ - h('.scroll-y.flex-column', { - style: { - width: model.object.selected ? '50%' : '100%', - }, - }, model.object.objectsRemote.match({ - NotAsked: () => null, - Loading: () => - h('.absolute-fill.flex-column.items-center.justify-center.f5', [spinner(5), h('', 'Loading Objects')]), - Success: () => { - const searchInput = model.object?.searchInput?.trim() ?? ''; - if (searchInput !== '') { - const objectsLoaded = model.object.list; - const objectsToDisplay = objectsLoaded.filter((qcObject) => - qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); - return virtualTable(model, 'main', objectsToDisplay); - } - return ObjectTreeComponent(model.object.tree, branchItem, leafItem); - }, - Failure: () => null, // Notification is displayed - })), - h('.animate-width.scroll-y', { - style: { - width: model.object.selected ? '50%' : 0, - }, - }, model.object.selected ? objectPanel(model) : null), - ]), - h('.f6.status-bar.ph1.flex-row', [ - statusBarLeft(model), - statusBarRight(model), - ]), -]); +export default (model) => { + const { object } = model; + + return h('.h-100.flex-column', { key: model.router.params.page }, [ + h('.flex-row.flex-grow', [ + h('.scroll-y.flex-column', { + style: { + width: object.selected ? '50%' : '100%', + }, + }, object.objectsRemote.match({ + NotAsked: () => null, + Loading: () => + h('.absolute-fill.flex-column.items-center.justify-center.f5', [spinner(5), h('', 'Loading Objects')]), + Success: () => { + const searchInput = object?.searchInput?.trim() ?? ''; + if (searchInput !== '') { + const objectsLoaded = object.list; + const objectsToDisplay = objectsLoaded.filter((qcObject) => + qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); + return virtualTable(model, 'main', objectsToDisplay); + } + return ObjectTreeComponent(object.tree, branchItem, leafItem); + }, + Failure: () => null, // Notification is displayed + })), + h('.animate-width.scroll-y', { + style: { + width: object.selected ? '50%' : 0, + }, + }, object.selected ? objectPanel(model) : null), + ]), + h('.f6.status-bar.ph1.flex-row', [ + statusBarLeft(object), + statusBarRight(object), + ]), + ]); +} From 35b2cdcd1e94248758bcb16023d48b80ba024354 Mon Sep 17 00:00:00 2001 From: Guust Date: Wed, 14 May 2025 13:15:53 +0200 Subject: [PATCH 10/44] feat(objectTreeSideBar): use objectTreeComponent --- .../layout/view/panels/objectTreeSidebar.js | 120 +----------------- QualityControl/public/object/QCObject.js | 3 +- .../component/objectTreeItem.js | 55 +++++--- .../pages/objectTreeView/objectTreePage.js | 2 +- .../test/public/pages/layout-show.test.js | 7 +- 5 files changed, 47 insertions(+), 140 deletions(-) diff --git a/QualityControl/public/layout/view/panels/objectTreeSidebar.js b/QualityControl/public/layout/view/panels/objectTreeSidebar.js index 986f7c121..44e997f09 100644 --- a/QualityControl/public/layout/view/panels/objectTreeSidebar.js +++ b/QualityControl/public/layout/view/panels/objectTreeSidebar.js @@ -15,8 +15,9 @@ import { h } from '/js/src/index.js'; import { spinner } from '../../../common/spinner.js'; import { draw } from '../../../object/objectDraw.js'; -import { iconCaretBottom, iconCaretRight, iconBarChart } from '/js/src/icons.js'; import virtualTable from '../../../object/virtualTable.js'; +import ObjectTreeComponent from '../../../pages/objectTreeView/component/ObjectTreeComponent.js'; +import { branchItem, sideTreeLeafItem } from '../../../pages/objectTreeView/component/objectTreeItem.js'; /** * Tree of object, searchable, inside the sidebar. Used to find objects and add them inside a layout @@ -42,7 +43,7 @@ export default (model) => '.scroll-y', searchInput.trim() !== '' ? virtualTable(model, 'side', objectsToDisplay) - : treeTable(model), + : ObjectTreeComponent(model.object.sideTree, branchItem, sideTreeLeafItem), ), objectPreview(model), ]; @@ -68,121 +69,6 @@ const searchForm = (model) => h('.flex-column.w-100.mv1', [ }), ]); -/** - * Shows table of objects - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -function treeTable(model) { - const attrs = { - - /** - * Handler when a drag&drop has ended, when moving an object from the table - * @returns {undefined} - */ - ondragend() { - model.layout.moveTabObjectStop(); - }, - }; - - return h('table.table.table-sm.text-no-select.flex-grow.f6', attrs, [h('tbody', [treeRows(model)])]); -} - -/** - * Shows a list of lines of objects - * @param {Model} model - root model of the application - * @returns {vnode} - virtual node element - */ -const treeRows = (model) => !model.object.sideTree - ? null - : model.object.sideTree.children.map((children) => treeRow(model, children, 0)); - -/** - * Shows a line of object represented by parent node `tree`, also shows - * sub-nodes of `tree` as additional lines if they are open in the tree. - * Indentation is added according to tree level during recursive call of treeRow - * Tree is traversed in depth-first with pre-order (root then subtrees) - * @param {Model} model - root model of the application - * @param {ObjectTree} sideTree - data-structure containing an object per node - * @param {number} level - used for indentation within recursive call of treeRow - * @returns {vnode} - virtual node element - */ -function treeRow(model, sideTree, level) { - if (sideTree.object && sideTree.children.length === 0) { - return [leafRow(model, sideTree, level)]; - } else if (sideTree.object && sideTree.children.length > 0) { - return [ - leafRow(model, sideTree, level), - branchRow(model, sideTree, level), - ]; - } else { - return [branchRow(model, sideTree, level)]; - } -} - -/** - * Shows a line of object represented by parent node `tree`, also shows - * sub-nodes of `tree` as additional lines if they are open in the tree. - * Indentation is added according to tree level during recursive call of treeRow - * Tree is traversed in depth-first with pre-order (root then subtrees) - * @param {Model} model - root model of the application - * @param {ObjectTree} sideTree - data-structure containing an object per node - * @param {number} level - used for indentation within recursive call of treeRow - * @returns {vnode} - virtual node element - */ -const branchRow = (model, sideTree, level) => { - const levelDeeper = level + 1; - const subtree = sideTree.open ? sideTree.children.map((children) => treeRow(model, children, levelDeeper)) : []; - - const icon = sideTree.open ? iconCaretBottom() : iconCaretRight(); - const iconWrapper = h('span', { style: { paddingLeft: `${level}em` } }, icon); - const path = sideTree.name; - - const attr = { - key: `key-sidebar-tree-${path}`, - title: path, - onclick: () => sideTree.toggle(), - }; - - return [ - h('tr.object-selectable', attr, [h('td.text-ellipsis', [iconWrapper, ' ', sideTree.name])]), - ...subtree, - ]; -}; - -/** - * Shows a line of object represented by parent node `tree`, also shows - * sub-nodes of `tree` as additional lines if they are open in the tree. - * Indentation is added according to tree level during recursive call of treeRow - * Tree is traversed in depth-first with pre-order (root then subtrees) - * @param {Model} model - root model of the application - * @param {ObjectTree} sideTree - data-structure containing an object per node - * @param {number} level - used for indentation within recursive call of treeRow - * @returns {vnode} - virtual node element - */ -const leafRow = (model, sideTree, level) => { - // UI construction - const iconWrapper = h('span', { style: { paddingLeft: `${level}em` } }, iconBarChart()); - const path = sideTree.name; - const className = sideTree.object && sideTree.object === model.object.selected ? 'table-primary' : ''; - const draggable = Boolean(sideTree.object); - - const attr = { - key: `key-sidebar-tree-${path}`, - title: path, - onclick: () => model.object.select(sideTree.object), - class: className, - draggable, - ondragstart: () => { - const newItem = model.layout.addItem(sideTree.object.name); - model.layout.moveTabObjectStart(newItem); - }, - ondblclick: () => model.layout.addItem(sideTree.object.name), - }; - - return h('tr.object-selectable', attr, h('td.text-ellipsis', [iconWrapper, ' ', sideTree.name])); -}; - /** * Shows a JSROOT plot of selected object inside the tree of sidebar allowing the user to preview object and decide * if it should be added to layout diff --git a/QualityControl/public/object/QCObject.js b/QualityControl/public/object/QCObject.js index 5ef6348c4..168701b74 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -13,7 +13,6 @@ */ import { Observable, RemoteData, iconArrowTop } from '/js/src/index.js'; -import ObjectTree from './ObjectTree.class.js'; import { prettyFormatDate, setBrowserTabTitle } from './../common/utils.js'; import { isObjectOfTypeChecker } from './../library/qcObject/utils.js'; import ObjectTreeModel from '../pages/objectTreeView/model/ObjectTreeModel.js'; @@ -52,7 +51,7 @@ export default class QCObject extends Observable { this.tree = new ObjectTreeModel('database'); this.tree.bubbleTo(this); - this.sideTree = new ObjectTree('database'); + this.sideTree = new ObjectTreeModel('database'); this.sideTree.bubbleTo(this); this.queryingObjects = false; this.scrollTop = 0; diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js index 353d6e622..f1634bd85 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -20,17 +20,17 @@ export const branchItem = (treeModel, treeItems) => { }; /** - * Creates a list item for a leaf (end node that represents an object) - * @param {object} leaf - the leaf object + * Creates a list item for a leafObject (end node that represents an object) + * @param {object} leafObject - the leaf object * @returns {vnode} - virtual node element */ -export const leafItem = (leaf) => { - const { name, path } = leaf; +export const leafItem = (leafObject) => { + const { name, path } = leafObject; const displayName = name.split('/').pop(); - return h('li.object-tree-leaf', { key: path }, [ + return h('li.object-tree-leafObject', { key: path }, [ h('div.object-selectable', { - onclick: () => model.object.select(leaf), + onclick: () => model.object.select(leafObject), title: path, }, [ h('span', iconBarChart()), @@ -40,15 +40,34 @@ export const leafItem = (leaf) => { ]); }; -// /** -// * Recursively renders tree items -// * @param {ObjectTreeModel} treeModel - the model that controls this tree's state -// * @param {Function} branchItem - function that receives an ObjectTreeModel and returns a vnode -// * @param {Function} leafItem - function that receives a object and returns a vnode -// * @returns {Array} - array of virtual node elements -// */ -// export const treeItems = (treeModel, branchItem, leafItem) => -// treeModel.children.map((child) => -// child instanceof ObjectTreeModel -// ? branchItem(child) -// : leafItem(child)); +/** + * Creates a list item for a leafObject (end node that represents an object) + * @param {object} leafObject - the leafObject object + * @returns {vnode} - virtual node element + */ +export const sideTreeLeafItem = (leafObject) => { + const { name, path } = leafObject; + const { object, layout } = model; + const displayName = name.split('/').pop(); + const className = leafObject === object.selected ? 'primary' : ''; + + const attr = { + title: path, + className, + onclick: () => object.select(leafObject), + draggable: true, + ondragstart: () => { + const newItem = layout.addItem(leafObject.name); + layout.moveTabObjectStart(newItem); + }, + ondblclick: () => layout.addItem(leafObject.name), + }; + + return h('li.object-tree-leaf', [ + h('div.object-selectable', attr, [ + h('span', iconBarChart()), + ' ', + displayName, + ]), + ]); +}; diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js index 2eada3564..ff978d6de 100644 --- a/QualityControl/public/pages/objectTreeView/objectTreePage.js +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -61,4 +61,4 @@ export default (model) => { statusBarRight(object), ]), ]); -} +}; diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 4d1d3697e..bfb3d8c12 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -270,11 +270,14 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => 'should have a tree sidebar in edit mode', { timeout }, async () => { - const secondElementPath = 'nav table tbody tr:nth-child(2)'; + const secondElementPath = '.scroll-y ul'; await page.locator(secondElementPath).click(); const rowsCount = await page.evaluate((secondElementPath) => document.querySelectorAll(secondElementPath).length, secondElementPath); - strictEqual(rowsCount, 1); + + // Each subtree will have its own nested list so qc/test/object/1 + // will inititially have 1 ul, and two after you expand the first tree + strictEqual(rowsCount, 2); }, ); From 2e624578305ea2c2db0c900e32f48aec9bde7ed4 Mon Sep 17 00:00:00 2001 From: Guust Date: Wed, 14 May 2025 14:05:50 +0200 Subject: [PATCH 11/44] chore: delete ObjectTree.class --- .../public/object/ObjectTree.class.js | 175 ------------------ 1 file changed, 175 deletions(-) delete mode 100644 QualityControl/public/object/ObjectTree.class.js diff --git a/QualityControl/public/object/ObjectTree.class.js b/QualityControl/public/object/ObjectTree.class.js deleted file mode 100644 index 1e8f245e8..000000000 --- a/QualityControl/public/object/ObjectTree.class.js +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * In applying this license CERN does not waive the privileges and immunities - * granted to it by virtue of its status as an Intergovernmental Organization - * or submit itself to any jurisdiction. - */ - -import { Observable } from '/js/src/index.js'; - -/** - * This class allows to transforms objects names (A/B/C) into a tree that can have - * some behaviours like open/close nodes. It also allows to update all those objects without creating - * a new tree. - */ -export default class ObjectTree extends Observable { - /** - * Instantiate tree with a root node called `name`, empty by default - * @param {string} name - root name - * @param {ObjectTree} parent - optional parent node - */ - constructor(name, parent) { - super(); - this.initTree(name, parent); - } - - /** - * Method to instantiate/reset the tree - * @param {string} name - name of the tree to be initialized - * @param {string} parent - parent of the tree - * @returns {undefined} - */ - initTree(name, parent) { - this.name = name || ''; // Like 'B' - this.object = null; - this.open = name === 'qc' ? true : false; - this.children = []; // > - this.parent = parent || null; // - this.path = []; // Like ['A', 'B'] for node at path 'A/B' called 'B' - this.pathString = ''; // 'A/B' - if (parent) { - this.bubbleTo(parent); - } - } - - /** - * Toggle this node (open/close) - * @returns {undefined} - */ - toggle() { - // TODO: fix 'parent tree' collapsing bug by adding:. - // setTimeout(() => this.notify(), 100); - this.open = !this.open; - this.notify(); - } - - /** - * Open all or close all nodes of the tree - * @returns {undefined} - */ - toggleAll() { - this.open ? this.closeAll() : this.openAll(); - } - - /** - * Open all nodes of the tree - * @returns {undefined} - */ - openAll() { - this.open = true; - this.children.forEach((child) => child.openAll()); - this.notify(); - } - - /** - * Close all nodes of the tree - * @returns {undefined} - */ - closeAll() { - this.open = false; - this.children.forEach((child) => child.closeAll()); - this.notify(); - } - - /** - * Add recursively an object inside a tree - * @param {object} object - The object to be inserted, property name must exist - * @param {Array.} path - Path of the object to dig in before assigning to a tree node, - * if null object.name is used - * @param {Array.} pathParent - Path of the current tree node, if null object.name is used - * - * Example of recursive call: - * addChild(o) // begin insert 'A/B' - * addChild(o, ['A', 'B'], []) - * addChild(o, ['B'], ['A']) - * addChild(o, [], ['A', 'B']) // end inserting, affecting B - * @returns {undefined} - */ - addChild(object, path, 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, []); - return; - } - - // Case end of path, associate the object to 'this' node - if (path.length === 0) { - this.object = object; - return; - } - - // Case we need to pass to subtree - const name = path.shift(); - const fullPath = [...pathParent, name]; - let subtree = this.children.find((children) => children.name === name); - - // Subtree does not exist yet - if (!subtree) { - /* - * Create it and push as child - * Listen also for changes to bubble it until root - */ - subtree = new ObjectTree(name, this); - subtree.path = fullPath; - subtree.pathString = fullPath.join('/'); - this.children.push(subtree); - } - - // Pass to child - subtree.addChild(object, path, fullPath); - } - - /** - * Add a list of objects by calling `addChild` - * @param {Array} objects - children to be added - * @returns {undefined} - */ - addChildren(objects) { - objects.forEach((object) => this.addChild(object)); - this.notify(); - } - - sortChildren(field, order) { - this.open = this.name === 'qc' ? true : false; - this.children = this.children.sort((child1, child2) => this._compareStrings(child1[field], child2[field], order)); - this.children.forEach((child) => { - if (child.children.length > 1) { - child.sortChildren(field, order); - } - }); - - this.notify(); - } - - /** - * Helper method for sortListByField for sorting strings - * @param {string} a - first string to be sorted - * @param {string} b - second string to be sorted - * @param {number} order - acending (1) or decending (-1) - * @returns {undefined} - */ - _compareStrings(a, b, order) { - return a.toUpperCase().localeCompare(b.toUpperCase()) * order; - } -} From 19055f46a807057c1957fc574c877145011cc4e2 Mon Sep 17 00:00:00 2001 From: Guust Date: Wed, 14 May 2025 14:11:36 +0200 Subject: [PATCH 12/44] chore(ObjectTreeModel): add documentation and rename some variables. --- .../objectTreeView/model/ObjectTreeModel.js | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js index 9a56ec4f7..9ef612ddf 100644 --- a/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js +++ b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js @@ -15,8 +15,8 @@ import { Observable } from '/js/src/index.js'; /** - * This class allows to transforms objectModels names (A/B/C) into a tree that can have - * some behaviours like open/close nodes. It also allows to update all those objectModels without creating + * This class allows to transforms objects names (A/B/C) into a tree that can have + * some behaviours like open/close nodes. It also allows to update all those objects without creating * a new tree. */ export default class ObjectTreeModel extends Observable { @@ -39,7 +39,7 @@ export default class ObjectTreeModel extends Observable { initTree(name, parent) { this.name = name || ''; // Like 'B' this.open = name === 'qc' ? true : false; - this.children = []; // > + this.children = []; // > this.parent = parent || null; // this.path = []; // Like ['A', 'B'] for node at path 'A/B' called 'B' this.pathString = ''; // 'A/B' @@ -84,7 +84,7 @@ export default class ObjectTreeModel extends Observable { /** * Add recursively an objectModel inside a tree - * @param {ObjectModel} objectModel - The objectModel to be inserted, property name must exist + * @param {object} objectModel - The objectModel to be inserted, property name must exist * @param {Array.} path - Path of the objectModel to dig in before assigning to a tree node, * if null objectModel.name is used * @param {Array.} pathParent - Path of the current tree node, if null objectModel.name is used @@ -137,14 +137,22 @@ export default class ObjectTreeModel extends Observable { } /** - * Add a list of objectModels by calling `addChild` - * @param {Array} objectModels - children to be added + * Add a list of objects by calling `addChild` + * @param {Array} objects - children to be added * @returns {undefined} */ - addChildren(objectModels) { - objectModels.forEach((objectModel) => this.addChild(objectModel)); + addChildren(objects) { + objects.forEach((object) => this.addChild(object)); } + /** + * Recursively sorts the children of this tree node by a specified field and order, + * and maintains the sort throughout the entire subtree. Updates the tree state + * and triggers a notification after sorting. + * @param {string} field - The property name of child objects to sort by + * @param {number} order - acending (1) or decending (-1) + * @returns {undefined} + */ sortChildren(field, order) { this.open = this.name === 'qc' ? true : false; this.children = this.children.sort((child1, child2) => this._compareStrings(child1[field], child2[field], order)); From 52a7829e52aa1dd68c34ab7689415b270abf66a4 Mon Sep 17 00:00:00 2001 From: Guust Date: Wed, 14 May 2025 14:29:57 +0200 Subject: [PATCH 13/44] chore(ObjectTreeModel): change open status upon initialisation and remove tree collapsing upon sorting --- .../public/pages/objectTreeView/model/ObjectTreeModel.js | 1 - 1 file changed, 1 deletion(-) diff --git a/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js index 9ef612ddf..95a9350c4 100644 --- a/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js +++ b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js @@ -154,7 +154,6 @@ export default class ObjectTreeModel extends Observable { * @returns {undefined} */ sortChildren(field, order) { - this.open = this.name === 'qc' ? true : false; this.children = this.children.sort((child1, child2) => this._compareStrings(child1[field], child2[field], order)); this.children.forEach((child) => { if (child instanceof ObjectTreeModel) { From 81cea1e97d10af586ef3b6318dbd6a0fc21d50df Mon Sep 17 00:00:00 2001 From: Guust Date: Wed, 14 May 2025 14:33:52 +0200 Subject: [PATCH 14/44] chore(objectTreeItem): swap out blue collored text for white collored text and a blue background --- .../public/pages/objectTreeView/component/objectTreeItem.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js index f1634bd85..9850b62aa 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -49,7 +49,8 @@ export const sideTreeLeafItem = (leafObject) => { const { name, path } = leafObject; const { object, layout } = model; const displayName = name.split('/').pop(); - const className = leafObject === object.selected ? 'primary' : ''; + // const className = leafObject === object.selected ? 'primary' : ''; + const className = leafObject === object.selected ? 'bg-primary white' : ''; const attr = { title: path, From 2022447ddc9beac8054bd2658e127c5d096c1229 Mon Sep 17 00:00:00 2001 From: Guust Date: Thu, 15 May 2025 09:39:11 +0200 Subject: [PATCH 15/44] chore: set key to path instead of name --- .../objectTreeView/component/objectTreeItem.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js index 9850b62aa..dd222a0bf 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -7,9 +7,9 @@ import { h, iconBarChart, iconCaretRight, iconCaretBottom } from '/js/src/index. * @returns {vnode} - virtual node element */ export const branchItem = (treeModel, treeItems) => { - const { name, open } = treeModel; + const { name, open, pathString } = treeModel; - return h('li.object-tree-branch', { title: name }, [ + return h('li.object-tree-branch', { key: pathString, title: pathString, id: pathString }, [ h('div.object-selectable', { onclick: () => treeModel.toggle() }, [ h('span', open ? iconCaretBottom() : iconCaretRight()), ' ', @@ -25,13 +25,13 @@ export const branchItem = (treeModel, treeItems) => { * @returns {vnode} - virtual node element */ export const leafItem = (leafObject) => { - const { name, path } = leafObject; + const { name } = leafObject; const displayName = name.split('/').pop(); - return h('li.object-tree-leafObject', { key: path }, [ + return h('li.object-tree-leafObject', { key: name, title: name, id: name }, [ h('div.object-selectable', { onclick: () => model.object.select(leafObject), - title: path, + title: name, }, [ h('span', iconBarChart()), ' ', @@ -46,14 +46,15 @@ export const leafItem = (leafObject) => { * @returns {vnode} - virtual node element */ export const sideTreeLeafItem = (leafObject) => { - const { name, path } = leafObject; + const { name } = leafObject; const { object, layout } = model; const displayName = name.split('/').pop(); - // const className = leafObject === object.selected ? 'primary' : ''; const className = leafObject === object.selected ? 'bg-primary white' : ''; const attr = { - title: path, + key: name, + title: name, + id: name, className, onclick: () => object.select(leafObject), draggable: true, From 5ed637df20730bd224319644920c1f3f4a5f1b72 Mon Sep 17 00:00:00 2001 From: Guust Date: Thu, 15 May 2025 10:14:31 +0200 Subject: [PATCH 16/44] refactor: remove unneeded object.name lookup --- .../public/pages/objectTreeView/component/objectTreeItem.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js index dd222a0bf..7eb35b98c 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -59,10 +59,10 @@ export const sideTreeLeafItem = (leafObject) => { onclick: () => object.select(leafObject), draggable: true, ondragstart: () => { - const newItem = layout.addItem(leafObject.name); + const newItem = layout.addItem(name); layout.moveTabObjectStart(newItem); }, - ondblclick: () => layout.addItem(leafObject.name), + ondblclick: () => layout.addItem(name), }; return h('li.object-tree-leaf', [ From a8455e6e2d472b37c13182a40ea26e8397f7e074 Mon Sep 17 00:00:00 2001 From: Guust Date: Thu, 15 May 2025 11:07:42 +0200 Subject: [PATCH 17/44] refactor: fix eslint issue and refactor objectTree items to no longer require model to be exposed to the window --- .../layout/view/panels/objectTreeSidebar.js | 11 +++++++---- .../component/objectTreeItem.js | 10 ++++++---- .../pages/objectTreeView/objectTreePage.js | 19 ++++++------------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/QualityControl/public/layout/view/panels/objectTreeSidebar.js b/QualityControl/public/layout/view/panels/objectTreeSidebar.js index 44e997f09..e4d42a115 100644 --- a/QualityControl/public/layout/view/panels/objectTreeSidebar.js +++ b/QualityControl/public/layout/view/panels/objectTreeSidebar.js @@ -26,13 +26,15 @@ import { branchItem, sideTreeLeafItem } from '../../../pages/objectTreeView/comp * @param {Model} model - root model of the application * @returns {vnode} - virtual node element */ -export default (model) => - model.services.object.list.match({ +export default (model) =>{ + const { object, layout } = model; + const { searchInput = '', sideTree } = object; + + return model.services.object.list.match({ NotAsked: () => null, Loading: () => h('.flex-column.items-center', [spinner(2), h('.f6', 'Loading Objects')]), Success: (objects) => { let objectsToDisplay = []; - const { searchInput = '' } = model.object; if (searchInput.trim() !== '') { objectsToDisplay = objects.filter((qcObject) => qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); @@ -43,7 +45,7 @@ export default (model) => '.scroll-y', searchInput.trim() !== '' ? virtualTable(model, 'side', objectsToDisplay) - : ObjectTreeComponent(model.object.sideTree, branchItem, sideTreeLeafItem), + : ObjectTreeComponent(sideTree, branchItem, (leafObject) => sideTreeLeafItem(leafObject, object, layout)), ), objectPreview(model), ]; @@ -53,6 +55,7 @@ export default (model) => h('', error.message), ]), }); +}; /** * An input which allows users to search though objects; diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js index 7eb35b98c..c8488c820 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -22,15 +22,16 @@ export const branchItem = (treeModel, treeItems) => { /** * Creates a list item for a leafObject (end node that represents an object) * @param {object} leafObject - the leaf object + * @param {QCObject} object - object managing model * @returns {vnode} - virtual node element */ -export const leafItem = (leafObject) => { +export const leafItem = (leafObject, object) => { const { name } = leafObject; const displayName = name.split('/').pop(); return h('li.object-tree-leafObject', { key: name, title: name, id: name }, [ h('div.object-selectable', { - onclick: () => model.object.select(leafObject), + onclick: () => object.select(leafObject), title: name, }, [ h('span', iconBarChart()), @@ -43,11 +44,12 @@ export const leafItem = (leafObject) => { /** * Creates a list item for a leafObject (end node that represents an object) * @param {object} leafObject - the leafObject object + * @param {QCObject} object - object managing model + * @param {QCObject} layout - layout managing model * @returns {vnode} - virtual node element */ -export const sideTreeLeafItem = (leafObject) => { +export const sideTreeLeafItem = (leafObject, object, layout) => { const { name } = leafObject; - const { object, layout } = model; const displayName = name.split('/').pop(); const className = leafObject === object.selected ? 'bg-primary white' : ''; diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js index ff978d6de..5b168f5ba 100644 --- a/QualityControl/public/pages/objectTreeView/objectTreePage.js +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -26,15 +26,12 @@ import { h } from '/js/src/index.js'; * @returns {vnode} - virtual node element */ export default (model) => { - const { object } = model; + const { object, router } = model; + const treeWidthClass = object.selected ? '.w-50' : '.w-100'; - return h('.h-100.flex-column', { key: model.router.params.page }, [ + return h('.h-100.flex-column', { key: router.params.page }, [ h('.flex-row.flex-grow', [ - h('.scroll-y.flex-column', { - style: { - width: object.selected ? '50%' : '100%', - }, - }, object.objectsRemote.match({ + h(`.scroll-y.flex-column${treeWidthClass}`, object.objectsRemote.match({ NotAsked: () => null, Loading: () => h('.absolute-fill.flex-column.items-center.justify-center.f5', [spinner(5), h('', 'Loading Objects')]), @@ -46,15 +43,11 @@ export default (model) => { qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); return virtualTable(model, 'main', objectsToDisplay); } - return ObjectTreeComponent(object.tree, branchItem, leafItem); + return ObjectTreeComponent(object.tree, branchItem, (leafObject) => leafItem(leafObject, object)); }, Failure: () => null, // Notification is displayed })), - h('.animate-width.scroll-y', { - style: { - width: object.selected ? '50%' : 0, - }, - }, object.selected ? objectPanel(model) : null), + h(`.animate-width.scroll-y${treeWidthClass}`, object.selected ? objectPanel(model) : null), ]), h('.f6.status-bar.ph1.flex-row', [ statusBarLeft(object), From 6aa0c5848470b825bb0f757d7a82e7cfd1589f74 Mon Sep 17 00:00:00 2001 From: Guust Date: Fri, 16 May 2025 13:22:56 +0200 Subject: [PATCH 18/44] refactor: change object function argument to qcObject --- .../objectTreeView/component/objectTreeItem.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js index c8488c820..a7c0e7eb3 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -21,17 +21,18 @@ export const branchItem = (treeModel, treeItems) => { /** * Creates a list item for a leafObject (end node that represents an object) - * @param {object} leafObject - the leaf object - * @param {QCObject} object - object managing model + * @param {object} leafObject - a leaf object that has a name property in the path format + * eg. 'qc/test/object/1' + * @param {QCObject} qcObject - object managing model * @returns {vnode} - virtual node element */ -export const leafItem = (leafObject, object) => { +export const leafItem = (leafObject, qcObject) => { const { name } = leafObject; const displayName = name.split('/').pop(); return h('li.object-tree-leafObject', { key: name, title: name, id: name }, [ h('div.object-selectable', { - onclick: () => object.select(leafObject), + onclick: () => qcObject.select(leafObject), title: name, }, [ h('span', iconBarChart()), @@ -44,21 +45,21 @@ export const leafItem = (leafObject, object) => { /** * Creates a list item for a leafObject (end node that represents an object) * @param {object} leafObject - the leafObject object - * @param {QCObject} object - object managing model + * @param {QCObject} qcObject - object managing model * @param {QCObject} layout - layout managing model * @returns {vnode} - virtual node element */ -export const sideTreeLeafItem = (leafObject, object, layout) => { +export const sideTreeLeafItem = (leafObject, qcObject, layout) => { const { name } = leafObject; const displayName = name.split('/').pop(); - const className = leafObject === object.selected ? 'bg-primary white' : ''; + const className = leafObject === qcObject.selected ? 'bg-primary white' : ''; const attr = { key: name, title: name, id: name, className, - onclick: () => object.select(leafObject), + onclick: () => qcObject.select(leafObject), draggable: true, ondragstart: () => { const newItem = layout.addItem(name); From dd81502367bc17f20056c3f729ad3d7f425c65d6 Mon Sep 17 00:00:00 2001 From: Guust Date: Fri, 16 May 2025 15:19:33 +0200 Subject: [PATCH 19/44] feat: create QCObjectNameDto --- QualityControl/lib/dtos/QCObjectNameDto.js | 53 +++++++++++++++++++ .../pages/objectTreeView/objectTreePage.js | 6 +-- 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 QualityControl/lib/dtos/QCObjectNameDto.js diff --git a/QualityControl/lib/dtos/QCObjectNameDto.js b/QualityControl/lib/dtos/QCObjectNameDto.js new file mode 100644 index 000000000..6a43e05dc --- /dev/null +++ b/QualityControl/lib/dtos/QCObjectNameDto.js @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import Joi from 'joi'; + +/** + * Sanitizes path segments containing whitespace or empty segments + * @param {string} path - The path to sanitize + * @returns {string} Sanitized path with invalid segments replaced + */ +function sanitizePath(path) { + if (typeof path !== 'string' || path === '') { + return ''; + } + + const result = []; + let segmentStart = 0; + + for (let i = 0; i <= path.length; i++) { + const char = path[i]; + if (i === path.length || char === '/') { + const segment = path.slice(segmentStart, i); + const hasWhitespace = /\s/.test(segment); + result.push(segment.length > 0 && !hasWhitespace ? segment : ''); + segmentStart = i + 1; + } + } + + return result.join('/'); +} + +/** + * Joi schema that first sanitizes then validates the path + */ +export const qcObjectNameDto = Joi.string() + .custom((value) => { + const sanitized = sanitizePath(value); + return sanitized === value ? value : sanitized; + }); + +export const qcObjectNameArrayDto = Joi.array() + .items(qcObjectNameDto); diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js index 5b168f5ba..2a781e7d3 100644 --- a/QualityControl/public/pages/objectTreeView/objectTreePage.js +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -26,8 +26,8 @@ import { h } from '/js/src/index.js'; * @returns {vnode} - virtual node element */ export default (model) => { - const { object, router } = model; - const treeWidthClass = object.selected ? '.w-50' : '.w-100'; + const { object, router, selected } = model; + const treeWidthClass = selected ? '.w-50' : '.w-100'; return h('.h-100.flex-column', { key: router.params.page }, [ h('.flex-row.flex-grow', [ @@ -47,7 +47,7 @@ export default (model) => { }, Failure: () => null, // Notification is displayed })), - h(`.animate-width.scroll-y${treeWidthClass}`, object.selected ? objectPanel(model) : null), + selected && h(`.animate-width.scroll-y${treeWidthClass}`, objectPanel(model)), ]), h('.f6.status-bar.ph1.flex-row', [ statusBarLeft(object), From 51086c9ec47fb2b0d5558660dff8e45ae41447cc Mon Sep 17 00:00:00 2001 From: Guust Date: Fri, 16 May 2025 16:19:41 +0200 Subject: [PATCH 20/44] feat implement qcObjectNameDto in CcdbService::getObjectsTreeList --- .../lib/services/ccdb/CcdbService.js | 6 +- .../test/lib/services/CcdbService.test.js | 142 +++++++++++++----- 2 files changed, 110 insertions(+), 38 deletions(-) diff --git a/QualityControl/lib/services/ccdb/CcdbService.js b/QualityControl/lib/services/ccdb/CcdbService.js index a75409029..373c977e2 100644 --- a/QualityControl/lib/services/ccdb/CcdbService.js +++ b/QualityControl/lib/services/ccdb/CcdbService.js @@ -17,6 +17,7 @@ import { httpHeadJson, httpGetJson } from '../../utils/utils.js'; import { CCDB_MONITOR, CCDB_VERSION_KEY, CCDB_RESPONSE_BODY_KEYS, CCDB_FILTER_FIELDS, CCDB_RESPONSE_HEADER_KEYS, } from './CcdbConstants.js'; +import { qcObjectNameArrayDto } from '../../dtos/QCObjectNameDto.js'; const { LAST_MODIFIED, VALID_FROM, VALID_UNTIL, CREATED, PATH, SIZE, FILE_NAME, METADATA, ID, @@ -105,12 +106,13 @@ export class CcdbService { * @rejects {Error} */ async getObjectsTreeList(prefix = this._PREFIX) { - const { subfolders } = await httpGetJson(this._hostname, this._port, `/tree/${prefix}.*`); + let { subfolders } = await httpGetJson(this._hostname, this._port, `/tree/${prefix}.*`); if (!Array.isArray(subfolders)) { throw new FailedDependencyError('Invalid response format from server - expected subfolders array'); } - // console.log(await this.getObjectsLatestVersionList(prefix)); + + subfolders = await qcObjectNameArrayDto.validateAsync(subfolders); return subfolders.map((folder) => ({ path: folder })); } diff --git a/QualityControl/test/lib/services/CcdbService.test.js b/QualityControl/test/lib/services/CcdbService.test.js index 85ce2f1bf..0a3fc60d2 100644 --- a/QualityControl/test/lib/services/CcdbService.test.js +++ b/QualityControl/test/lib/services/CcdbService.test.js @@ -12,8 +12,6 @@ * or submit itself to any jurisdiction. */ -/* eslint-disable require-jsdoc */ - import { deepStrictEqual, strictEqual, rejects } from 'node:assert'; import { suite, test, before } from 'node:test'; import nock from 'nock'; @@ -62,7 +60,7 @@ export const ccdbServiceTestSuite = async () => { }); suite('`getVersion()` tests', () => { - let ccdb; + let ccdb = undefined; let CCDB_URL_HEALTH_POINT = ''; let CCDB_HOSTNAME = ''; before(() => { @@ -196,6 +194,28 @@ export const ccdbServiceTestSuite = async () => { deepStrictEqual(objectsRetrieved, expectedObjects, 'Received objects are not alike'); }); + test('should sanitise empty strings and whitespaces', async () => { + const ccdb = new CcdbService(ccdbConfig); + const subfolders = [ + 'object/ one', + 'object /two', + 'object/three/', + ]; + const expectedObjects = [ + { path: 'object/' }, + { path: '/two' }, + { path: 'object/three/' }, + ]; + + nock('http://ccdb-local:8083', { + reqheaders: { Accept: 'application/json' }, + }) + .get(`/tree/${ccdbConfig.prefix}.*`) + .reply(200, { subfolders }); + const objectsRetrieved = await ccdb.getObjectsTreeList(ccdbConfig.prefix); + deepStrictEqual(objectsRetrieved, expectedObjects, 'Received objects are not alike'); + }); + test('should throw error when response has invalid subfolders format', async () => { const ccdb = new CcdbService(ccdbConfig); const invalidResponse = { subfolders: 'not-an-array' }; @@ -231,7 +251,11 @@ export const ccdbServiceTestSuite = async () => { { path: 'object/one', Created: '101', 'Valid-From': '103', ETag: 'id103', metadata: [] }, { path: 'object/one', Created: '101', 'Valid-From': '104', ETag: 'id104', metadata: [] }, ]; - const expectedVersions = [{ 'Valid-From': 102, Created: 101, ETag: 'id102' }, { 'Valid-From': 103, Created: 101, ETag: 'id103' }, { 'Valid-From': 104, Created: 101, ETag: 'id104' }]; + const expectedVersions = [ + { 'Valid-From': 102, Created: 101, ETag: 'id102' }, + { 'Valid-From': 103, Created: 101, ETag: 'id103' }, + { 'Valid-From': 104, Created: 101, ETag: 'id104' }, + ]; nock('http://ccdb-local:8083') .get('/browse/object/one') .reply(200, { objects, subfolders: [] }); @@ -311,42 +335,59 @@ export const ccdbServiceTestSuite = async () => { }); suite('`getObjectDetails()` tests', () => { - let ccdb; + let ccdb = undefined; before(() => { ccdb = new CcdbService(ccdbConfig); }); test('should throw error due to missing mandatory parameters (path, timestamp, id)', async () => { await rejects(async () => ccdb.getObjectDetails(), new Error('Missing mandatory parameters: path & validFrom')); - await rejects(async () => ccdb.getObjectDetails(null), new Error('Missing mandatory parameters: path & validFrom')); - await rejects(async () => ccdb.getObjectDetails(undefined), new Error('Missing mandatory parameters: path & validFrom')); - await rejects(async () => ccdb.getObjectDetails({ path: '' }), new Error('Missing mandatory parameters: path & validFrom')); - await rejects(async () => ccdb.getObjectDetails({ path: null, validFrom: 213 }, null), new Error('Missing mandatory parameters: path & validFrom')); - }); - - test('should successfully return content-location field on status >=200 <= 399 and add path if missing', async () => { - const path = 'qc/some/test/'; - nock('http://ccdb-local:8083') - .defaultReplyHeaders({ 'content-location': '/download/123123-123123', location: '/download/some-id' }) - .head(`/${path}/123455432/id1`) - .reply(303); - const content = await ccdb.getObjectDetails({ path, validFrom: 123455432, id: 'id1' }); - strictEqual(content.location, '/download/123123-123123'); - strictEqual(content.path, path); + await rejects(async () => + ccdb.getObjectDetails(null), new Error('Missing mandatory parameters: path & validFrom')); + await rejects(async () => + ccdb.getObjectDetails(undefined), new Error('Missing mandatory parameters: path & validFrom')); + await rejects(async () => + ccdb.getObjectDetails({ path: '' }), new Error('Missing mandatory parameters: path & validFrom')); + await rejects( + async () => + ccdb.getObjectDetails({ path: null, validFrom: 213 }, null), + new Error('Missing mandatory parameters: path & validFrom'), + ); }); - test('should successfully return content-location field if is string as array with "alien" second item on status >=200 <= 399', async () => { + test( + 'should successfully return content-location field on status >=200 <= 399 and add path if missing', + async () => { + const path = 'qc/some/test/'; + nock('http://ccdb-local:8083') + .defaultReplyHeaders({ 'content-location': '/download/123123-123123', location: '/download/some-id' }) + .head(`/${path}/123455432/id1`) + .reply(303); + const content = await ccdb.getObjectDetails({ path, validFrom: 123455432, id: 'id1' }); + strictEqual(content.location, '/download/123123-123123'); + strictEqual(content.path, path); + }, + ); + + test(`should successfully return content-location field if is + string as array with "alien" second item on status >=200 <= 399`, async () => { nock('http://ccdb-local:8083') - .defaultReplyHeaders({ 'content-location': '/download/123123-123123, alien://', location: '/download/some-id' }) + .defaultReplyHeaders({ + 'content-location': '/download/123123-123123, alien://', + location: '/download/some-id', + }) .head('/qc/some/test/123455432/id1') .reply(200); const content = await ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }); strictEqual(content.location, '/download/123123-123123'); }); - test('should successfully return content-location field if is string as array with "alien" first item and "location" second on status >=200 <= 399', async () => { + test(`should successfully return content-location field if is string as + array with "alien" first item and "location" second on status >=200 <= 399`, async () => { nock('http://ccdb-local:8083') - .defaultReplyHeaders({ 'content-location': 'alien://, file/some/object, /download/123123-123123', location: '/download/some-id' }) + .defaultReplyHeaders({ + 'content-location': 'alien://, file/some/object, /download/123123-123123', + location: '/download/some-id' }) .head('/qc/some/test/123455432/id1') .reply(303); const content = await ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }); @@ -375,7 +416,11 @@ export const ccdbServiceTestSuite = async () => { nock('http://ccdb-local:8083') .head('/qc/some/test/123455432/id1') .reply(404); - await rejects(async () => ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), new Error('Unable to retrieve object: qc/some/test due to status: 404')); + await rejects( + async () => ccdb.getObjectDetails({ + path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), + new Error('Unable to retrieve object: qc/some/test due to status: 404'), + ); }); test('should reject with error due no content-location without alien', async () => { @@ -383,7 +428,10 @@ export const ccdbServiceTestSuite = async () => { .defaultReplyHeaders({ 'content-location': 'alien/some-id' }) .head('/qc/some/test/123455432/id1') .reply(200); - await rejects(async () => ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), new Error('No location provided by CCDB for object with path: qc/some/test')); + await rejects( + async () => ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), + new Error('No location provided by CCDB for object with path: qc/some/test'), + ); }); test('should reject with empty content-location', async () => { @@ -391,7 +439,10 @@ export const ccdbServiceTestSuite = async () => { .defaultReplyHeaders({ 'content-location': '' }) .head('/qc/some/test/123455432/id1') .reply(200); - await rejects(async () => ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), new Error('No location provided by CCDB for object with path: qc/some/test')); + await rejects( + async () => ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), + new Error('No location provided by CCDB for object with path: qc/some/test'), + ); }); test('should reject with missing content-location', async () => { @@ -399,12 +450,16 @@ export const ccdbServiceTestSuite = async () => { .defaultReplyHeaders({ 'content-location': '' }) .head('/qc/some/test/123455432/id1') .reply(200); - await rejects(async () => ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), new Error('No location provided by CCDB for object with path: qc/some/test')); + await rejects( + async () => + ccdb.getObjectDetails({ path: 'qc/some/test', validFrom: 123455432, id: 'id1' }), + new Error('No location provided by CCDB for object with path: qc/some/test'), + ); }); }); suite('`_parsePrefix()` tests', () => { - let ccdb; + let ccdb = undefined; before(() => { ccdb = new CcdbService(ccdbConfig); }); @@ -422,18 +477,30 @@ export const ccdbServiceTestSuite = async () => { }); suite('`_buildCcdbUrlPath()` tests', () => { - let ccdb; + let ccdb = undefined; before(() => { ccdb = new CcdbService(ccdbConfig); }); test('successfully build URL path with partial identification fields only', () => { strictEqual(ccdb._buildCcdbUrlPath({ path: 'qc/TPC/object' }), '/qc/TPC/object'); - strictEqual(ccdb._buildCcdbUrlPath({ path: 'qc/TPC/object', validFrom: 1231231231 }), '/qc/TPC/object/1231231231'); - strictEqual(ccdb._buildCcdbUrlPath({ path: 'qc/TPC/object', validUntil: 1231231231 }), '/qc/TPC/object/1231231231'); - strictEqual(ccdb._buildCcdbUrlPath({ path: 'qc/TPC/object', validFrom: 12322222, validUntil: 1231231231 }), '/qc/TPC/object/12322222/1231231231'); - strictEqual(ccdb._buildCcdbUrlPath({ path: 'qc/TPC/object', validFrom: 12322222, id: '123-ffg' }), '/qc/TPC/object/12322222/123-ffg'); - strictEqual(ccdb._buildCcdbUrlPath({ path: 'qc/TPC/object', validFrom: 12322222, validUntil: 123332323, id: '123-ffg' }), '/qc/TPC/object/12322222/123332323/123-ffg'); + strictEqual(ccdb._buildCcdbUrlPath({ + path: 'qc/TPC/object', validFrom: 1231231231 }), '/qc/TPC/object/1231231231'); + strictEqual(ccdb._buildCcdbUrlPath({ + path: 'qc/TPC/object', validUntil: 1231231231 }), '/qc/TPC/object/1231231231'); + strictEqual(ccdb._buildCcdbUrlPath({ + path: 'qc/TPC/object', + validFrom: 12322222, + validUntil: 1231231231 }), '/qc/TPC/object/12322222/1231231231'); + strictEqual(ccdb._buildCcdbUrlPath({ + path: 'qc/TPC/object', + validFrom: 12322222, + id: '123-ffg' }), '/qc/TPC/object/12322222/123-ffg'); + strictEqual(ccdb._buildCcdbUrlPath({ + path: 'qc/TPC/object', + validFrom: 12322222, + validUntil: 123332323, + id: '123-ffg' }), '/qc/TPC/object/12322222/123332323/123-ffg'); }); test('successfully build URL path with complete identification fields only', () => { @@ -447,7 +514,10 @@ export const ccdbServiceTestSuite = async () => { PartName: 'Pass', }, }; - strictEqual(ccdb._buildCcdbUrlPath(identification), '/qc/TPC/object/12322222/123332323/123-ffg/RunNumber=123456/PartName=Pass'); + strictEqual( + ccdb._buildCcdbUrlPath(identification), + '/qc/TPC/object/12322222/123332323/123-ffg/RunNumber=123456/PartName=Pass' + ); }); }); }); From d96336c145b6515d7414b7b919afc534ef1054b7 Mon Sep 17 00:00:00 2001 From: Guust Date: Fri, 16 May 2025 17:15:00 +0200 Subject: [PATCH 21/44] fix: replace the sanitisation function of QCObjectNameDto with a exclusion function. --- QualityControl/lib/dtos/QCObjectNameDto.js | 48 ++++++++++++++----- .../test/lib/services/CcdbService.test.js | 9 ++-- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/QualityControl/lib/dtos/QCObjectNameDto.js b/QualityControl/lib/dtos/QCObjectNameDto.js index 6a43e05dc..41c0faf53 100644 --- a/QualityControl/lib/dtos/QCObjectNameDto.js +++ b/QualityControl/lib/dtos/QCObjectNameDto.js @@ -13,18 +13,21 @@ */ import Joi from 'joi'; +import { LogManager } from '@aliceo2/web-ui'; + +const logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'qcg'}/qcObj-Dto`); /** - * Sanitizes path segments containing whitespace or empty segments - * @param {string} path - The path to sanitize - * @returns {string} Sanitized path with invalid segments replaced + * Validates if a path segment is valid (non-empty and no whitespace) + * @param {string} path - The path to validate + * @returns {boolean} True if the path is valid */ -function sanitizePath(path) { +function isValidPath(path) { if (typeof path !== 'string' || path === '') { - return ''; + logger.debugMessage(`Invalid path: Path is empty or not a string (${path})`); + return false; } - const result = []; let segmentStart = 0; for (let i = 0; i <= path.length; i++) { @@ -32,22 +35,41 @@ function sanitizePath(path) { if (i === path.length || char === '/') { const segment = path.slice(segmentStart, i); const hasWhitespace = /\s/.test(segment); - result.push(segment.length > 0 && !hasWhitespace ? segment : ''); + if (segment.length === 0) { + logger.debugMessage(`Invalid path: Empty segment found in path (${path})`); + return false; + } + if (hasWhitespace) { + logger.debugMessage(`Invalid path: Segment contains whitespace (${segment}) in path (${path})`); + return false; + } segmentStart = i + 1; } } - return result.join('/'); + return true; } /** - * Joi schema that first sanitizes then validates the path + * Joi schema that validates the path */ export const qcObjectNameDto = Joi.string() .custom((value) => { - const sanitized = sanitizePath(value); - return sanitized === value ? value : sanitized; - }); + if (!isValidPath(value)) { + logger.debugMessage(`Found invalid QC object name: ${value}`); + return value; + } + return value; + }, 'QC Object Name Validator'); export const qcObjectNameArrayDto = Joi.array() - .items(qcObjectNameDto); + .items(qcObjectNameDto) + .custom((value) => { + const filtered = value.filter((item) => isValidPath(item)); + + if (filtered.length !== value.length) { + logger.debugMessage(`Filtered ${value.length - filtered.length} invalid QC object names from array`); + } + + return filtered; + }, 'QC Object Array Validator'); diff --git a/QualityControl/test/lib/services/CcdbService.test.js b/QualityControl/test/lib/services/CcdbService.test.js index 0a3fc60d2..2ac2b4517 100644 --- a/QualityControl/test/lib/services/CcdbService.test.js +++ b/QualityControl/test/lib/services/CcdbService.test.js @@ -194,18 +194,15 @@ export const ccdbServiceTestSuite = async () => { deepStrictEqual(objectsRetrieved, expectedObjects, 'Received objects are not alike'); }); - test('should sanitise empty strings and whitespaces', async () => { + test('should remove objects whos name contains empty strings and whitespaces', async () => { const ccdb = new CcdbService(ccdbConfig); const subfolders = [ 'object/ one', 'object /two', 'object/three/', + 'object/valid', ]; - const expectedObjects = [ - { path: 'object/' }, - { path: '/two' }, - { path: 'object/three/' }, - ]; + const expectedObjects = [{ path: 'object/valid' }]; nock('http://ccdb-local:8083', { reqheaders: { Accept: 'application/json' }, From 279e70fdb5e61e652ea9e22c25cbac397a4cba39 Mon Sep 17 00:00:00 2001 From: Guust Date: Fri, 16 May 2025 17:23:48 +0200 Subject: [PATCH 22/44] small refactor <= -> < --- QualityControl/lib/dtos/QCObjectNameDto.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/lib/dtos/QCObjectNameDto.js b/QualityControl/lib/dtos/QCObjectNameDto.js index 41c0faf53..337953946 100644 --- a/QualityControl/lib/dtos/QCObjectNameDto.js +++ b/QualityControl/lib/dtos/QCObjectNameDto.js @@ -30,7 +30,7 @@ function isValidPath(path) { let segmentStart = 0; - for (let i = 0; i <= path.length; i++) { + for (let i = 0; i < path.length; i++) { const char = path[i]; if (i === path.length || char === '/') { const segment = path.slice(segmentStart, i); From 9558c5b0a0c31ca8fda7f80957921997a650833f Mon Sep 17 00:00:00 2001 From: Guust Date: Fri, 16 May 2025 19:09:14 +0200 Subject: [PATCH 23/44] test: add test delay and fix the QCObjectNameTest --- QualityControl/lib/dtos/QCObjectNameDto.js | 13 ++++++++++--- .../test/lib/services/CcdbService.test.js | 3 ++- .../test/public/pages/layout-show.test.js | 5 +++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/QualityControl/lib/dtos/QCObjectNameDto.js b/QualityControl/lib/dtos/QCObjectNameDto.js index 337953946..7cfb4d482 100644 --- a/QualityControl/lib/dtos/QCObjectNameDto.js +++ b/QualityControl/lib/dtos/QCObjectNameDto.js @@ -29,9 +29,10 @@ function isValidPath(path) { } let segmentStart = 0; + let char = ''; + for (let i = 0; i <= path.length; i++) { + char = path[i]; - for (let i = 0; i < path.length; i++) { - const char = path[i]; if (i === path.length || char === '/') { const segment = path.slice(segmentStart, i); const hasWhitespace = /\s/.test(segment); @@ -47,6 +48,11 @@ function isValidPath(path) { } } + if (char === '/') { + logger.debugMessage(`Invalid path: Path ends with a slash (${path})`); + return false; + } + return true; } @@ -72,4 +78,5 @@ export const qcObjectNameArrayDto = Joi.array() } return filtered; - }, 'QC Object Array Validator'); + }, 'QC Object Array Validator') + .options({ stripUnknown: { arrays: true } }); diff --git a/QualityControl/test/lib/services/CcdbService.test.js b/QualityControl/test/lib/services/CcdbService.test.js index 2ac2b4517..b97b5c395 100644 --- a/QualityControl/test/lib/services/CcdbService.test.js +++ b/QualityControl/test/lib/services/CcdbService.test.js @@ -199,7 +199,8 @@ export const ccdbServiceTestSuite = async () => { const subfolders = [ 'object/ one', 'object /two', - 'object/three/', + 'object//three', + 'object/four/', 'object/valid', ]; const expectedObjects = [{ path: 'object/valid' }]; diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index bfb3d8c12..2e8e567b2 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -272,12 +272,13 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => async () => { const secondElementPath = '.scroll-y ul'; await page.locator(secondElementPath).click(); + await delay(50); // to let the list unfold const rowsCount = await page.evaluate((secondElementPath) => document.querySelectorAll(secondElementPath).length, secondElementPath); + strictEqual(rowsCount, 3); // Each subtree will have its own nested list so qc/test/object/1 - // will inititially have 1 ul, and two after you expand the first tree - strictEqual(rowsCount, 2); + // will inititially have 1 ul, and two after you expand the first tree, three if you expand the one after. }, ); From 8fb4d420a095442e8b21b96461a888eeedb0d14b Mon Sep 17 00:00:00 2001 From: Guust Date: Mon, 19 May 2025 11:40:04 +0200 Subject: [PATCH 24/44] feat: set development environment window exposion --- QualityControl/public/index.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/QualityControl/public/index.js b/QualityControl/public/index.js index 6acb28dd3..a26d0c5a2 100644 --- a/QualityControl/public/index.js +++ b/QualityControl/public/index.js @@ -17,13 +17,20 @@ import { mount, sessionService } from '/js/src/index.js'; import view from './view.js'; import Model from './Model.js'; +const { host } = window.location; +const envMap = { + 'localhost:8090': 'dev', + 'localhost:8081': 'test', +}; + sessionService.loadAndHideParameters(); -window.sessionService = sessionService; // Start application const model = new Model(); const debug = true; // Shows when redraw is done mount(document.body, view, model, debug); -// Expose model to interact with it the browser's console -window.model = model; +if (envMap[host] === 'dev') { + window.sessionService = sessionService; + window.model = model; +} From ce5f4742eeecd8a9fbd06fc78819a5e171beb833 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 20 May 2025 11:46:52 +0200 Subject: [PATCH 25/44] test(object-tree-test): remove window.model dependency --- .../test/public/pages/object-tree.test.js | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 78b692e65..1e2fddebc 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -11,7 +11,8 @@ * or submit itself to any jurisdiction. */ -import { strictEqual, ok } from 'node:assert'; +import { strictEqual, ok, deepStrictEqual } from 'node:assert'; +import { delay } from '../../testUtils/delay.js'; const OBJECT_TREE_PAGE_PARAM = '?page=objectTree'; const SORTING_BUTTON_PATH = 'header > div > div:nth-child(3) > div > button'; const LIST_ITEM_PATH = 'ul > li'; @@ -48,35 +49,36 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should have first element in tree as "qc/test/object/1"', async () => { - const { name } = await page.evaluate(() => window.model.object.currentList[0]); - strictEqual(name, 'qc/test/object/1'); + await page.locator('[title="qc/test"]>div').click(); + await page.locator('[title="qc/test/object"]>div').click(); + await delay(50); // Wait for expansion to finish + + const objectIds = await page.evaluate(() => + [...document.querySelectorAll('[title="qc/test/object"] li')].map((e)=> e.id)); + + deepStrictEqual(objectIds, ['qc/test/object/1', 'qc/test/object/11', 'qc/test/object/2']); }); - await testParent.test('should sort list of histograms by name in descending order', async () => { + await testParent.test('should sort list of objects by name in descending order', async () => { await page.locator(SORTING_BUTTON_PATH).click(); await page.locator(sortOptionPath(NAME_DEC_INDEX)).click(); + await delay(50); // Wait for sort to finish + + const objectIds = await page.evaluate(() => + [...document.querySelectorAll('[title="qc/test/object"] li')].map((e)=> e.id)); - const sorted = await page.evaluate(() => ({ - list: window.model.object.currentList, - sort: window.model.object.sortBy, - })); - strictEqual(sorted.sort.title, 'Name'); - strictEqual(sorted.sort.order, -1); - strictEqual(sorted.sort.field, 'name'); - strictEqual(sorted.list[0].name, 'qc/test/object/2'); + deepStrictEqual(objectIds, ['qc/test/object/2', 'qc/test/object/11', 'qc/test/object/1']); }); - await testParent.test('should sort list of histograms by name in ascending order', async () => { + await testParent.test('should sort list of objects by name in ascending order', async () => { await page.locator(SORTING_BUTTON_PATH).click(); await page.locator(sortOptionPath(NAME_ASC_INDEX)).click(); - const sorted = await page.evaluate(() => ({ - list: window.model.object.currentList, - sort: window.model.object.sortBy, - })); - strictEqual(sorted.sort.title, 'Name'); - strictEqual(sorted.sort.order, 1); - strictEqual(sorted.sort.field, 'name'); - strictEqual(sorted.list[0].name, 'qc/test/object/1'); + await delay(50); // Wait for sort to finish + + const objectIds = await page.evaluate(() => + [...document.querySelectorAll('[title="qc/test/object"] li')].map((e)=> e.id)); + + deepStrictEqual(objectIds, ['qc/test/object/1', 'qc/test/object/11', 'qc/test/object/2']); }); await testParent.test('should have filtered results on input search', async () => { From 3bc328e5a52d0f606a882a0cc2b54f8b2dec1505 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 20 May 2025 11:59:26 +0200 Subject: [PATCH 26/44] fix(objectTreePage): fix object not showing up. --- QualityControl/public/pages/objectTreeView/objectTreePage.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js index 2a781e7d3..cab6ca9c8 100644 --- a/QualityControl/public/pages/objectTreeView/objectTreePage.js +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -26,7 +26,8 @@ import { h } from '/js/src/index.js'; * @returns {vnode} - virtual node element */ export default (model) => { - const { object, router, selected } = model; + const { object, router } = model; + const { selected } = object; const treeWidthClass = selected ? '.w-50' : '.w-100'; return h('.h-100.flex-column', { key: router.params.page }, [ From fe763bdb8a3cf96d051fa136a3a441ebe702246c Mon Sep 17 00:00:00 2001 From: = Date: Wed, 21 May 2025 17:32:22 +0200 Subject: [PATCH 27/44] refactor(QCObjectNameDto): move false check to start of function --- QualityControl/lib/dtos/QCObjectNameDto.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/QualityControl/lib/dtos/QCObjectNameDto.js b/QualityControl/lib/dtos/QCObjectNameDto.js index 7cfb4d482..660cfcf05 100644 --- a/QualityControl/lib/dtos/QCObjectNameDto.js +++ b/QualityControl/lib/dtos/QCObjectNameDto.js @@ -28,10 +28,14 @@ function isValidPath(path) { return false; } + if (path.endsWith('/')) { + logger.debugMessage(`Invalid path: Path ends with a slash (${path})`); + return false; + } + let segmentStart = 0; - let char = ''; for (let i = 0; i <= path.length; i++) { - char = path[i]; + const char = path[i]; if (i === path.length || char === '/') { const segment = path.slice(segmentStart, i); @@ -47,12 +51,6 @@ function isValidPath(path) { segmentStart = i + 1; } } - - if (char === '/') { - logger.debugMessage(`Invalid path: Path ends with a slash (${path})`); - return false; - } - return true; } From c03a10df48491faf7dcdd5f15931a83a7936c101 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Fri, 23 May 2025 16:44:15 +0200 Subject: [PATCH 28/44] test(ObjectTreePath): add test for virtualTable --- .../test/public/pages/object-tree.test.js | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 1e2fddebc..d124e03af 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -15,9 +15,14 @@ import { strictEqual, ok, deepStrictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; const OBJECT_TREE_PAGE_PARAM = '?page=objectTree'; const SORTING_BUTTON_PATH = 'header > div > div:nth-child(3) > div > button'; -const LIST_ITEM_PATH = 'ul > li'; +const LIST_ITEM_PATH = 'ul > li'; // General path for checking existence +const LIST_OBJECT_PATH = '[title="qc/test/object"] li'; // Path specifically for finding objects const sortOptionPath = (index) => `header > div > div:nth-child(3) > div > div > a:nth-child(${index})`; const [NAME_ASC_INDEX, NAME_DEC_INDEX] = [1, 2]; +const VIRTUAL_TABLEROW_PATH = 'tbody > tr.object-selectable'; +const SEARCH_PATH = 'header > div > div:nth-child(3) > input'; +const OBJECTS_DESCENDING = ['qc/test/object/2', 'qc/test/object/11', 'qc/test/object/1']; +const OBJECTS_ASCENDING = ['qc/test/object/1', 'qc/test/object/11', 'qc/test/object/2']; /** * Initial page setup tests @@ -53,10 +58,10 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) await page.locator('[title="qc/test/object"]>div').click(); await delay(50); // Wait for expansion to finish - const objectIds = await page.evaluate(() => - [...document.querySelectorAll('[title="qc/test/object"] li')].map((e)=> e.id)); + const objectIds = await page.evaluate((path) => + [...document.querySelectorAll(path)].map((e)=> e.id), LIST_OBJECT_PATH); - deepStrictEqual(objectIds, ['qc/test/object/1', 'qc/test/object/11', 'qc/test/object/2']); + deepStrictEqual(objectIds, OBJECTS_ASCENDING); }); await testParent.test('should sort list of objects by name in descending order', async () => { @@ -64,21 +69,40 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) await page.locator(sortOptionPath(NAME_DEC_INDEX)).click(); await delay(50); // Wait for sort to finish - const objectIds = await page.evaluate(() => - [...document.querySelectorAll('[title="qc/test/object"] li')].map((e)=> e.id)); + const objectIds = await page.evaluate((path) => + [...document.querySelectorAll(path)].map((e)=> e.id), LIST_OBJECT_PATH); - deepStrictEqual(objectIds, ['qc/test/object/2', 'qc/test/object/11', 'qc/test/object/1']); + deepStrictEqual(objectIds, OBJECTS_DESCENDING); }); - await testParent.test('should sort list of objects by name in ascending order', async () => { + await testParent.test('should sort virtual table of objects by name in descending order', async () => { + await page.locator(SEARCH_PATH).fill('qc'); + await delay(50); // Wait for table to load + + const objectIds = await page.evaluate((rowPath) => + [...document.querySelectorAll(rowPath)].map((e)=> e.title), VIRTUAL_TABLEROW_PATH); + + deepStrictEqual(objectIds, OBJECTS_DESCENDING); + }); + + await testParent.test('should sort virtual table of objects by name in ascending order', async () => { await page.locator(SORTING_BUTTON_PATH).click(); await page.locator(sortOptionPath(NAME_ASC_INDEX)).click(); await delay(50); // Wait for sort to finish - const objectIds = await page.evaluate(() => - [...document.querySelectorAll('[title="qc/test/object"] li')].map((e)=> e.id)); + const objectIds = await page.evaluate((rowPath) => + [...document.querySelectorAll(rowPath)].map((e)=> e.title), VIRTUAL_TABLEROW_PATH); + + deepStrictEqual(objectIds, OBJECTS_ASCENDING); + await page.locator(SEARCH_PATH).fill(' '); // cleanup for the next test. Whitespace is required for some reason + await delay(50); // Wait object list to load + }); + + await testParent.test('should sort list of objects by name in ascending order', async () => { + const objectIds = await page.evaluate((path) => + [...document.querySelectorAll(path)].map((e)=> e.id), LIST_OBJECT_PATH); - deepStrictEqual(objectIds, ['qc/test/object/1', 'qc/test/object/11', 'qc/test/object/2']); + deepStrictEqual(objectIds, OBJECTS_ASCENDING); }); await testParent.test('should have filtered results on input search', async () => { From 9d60af1995d7b035ba341c4925788efe0a383905 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 12:56:18 +0200 Subject: [PATCH 29/44] wip --- .../public/layout/view/panels/filters.js | 2 +- .../test/public/pages/object-tree.test.js | 88 ++++++++++++------- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/QualityControl/public/layout/view/panels/filters.js b/QualityControl/public/layout/view/panels/filters.js index 837304afc..4eb3384fd 100644 --- a/QualityControl/public/layout/view/panels/filters.js +++ b/QualityControl/public/layout/view/panels/filters.js @@ -28,7 +28,7 @@ const layoutFiltersPanel = ({ layout: layoutModel }) => { const onInputCallback = setFilterValue.bind(layoutModel); const onEnterCallback = applyLayoutChanges.bind(layoutModel); const onChangeCallback = selectOption.bind(layoutModel); - const filterService = model.services.filter; + const filterService = layoutModel.model.services.filter; const filtersList = filtersConfig(filterService) || []; const createFilterElement = (config) => { let filterElement = null; diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 59e9a3f36..d124e03af 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -11,9 +11,18 @@ * or submit itself to any jurisdiction. */ -import { strictEqual, ok } from 'node:assert'; +import { strictEqual, ok, deepStrictEqual } from 'node:assert'; +import { delay } from '../../testUtils/delay.js'; const OBJECT_TREE_PAGE_PARAM = '?page=objectTree'; const SORTING_BUTTON_PATH = 'header > div > div:nth-child(3) > div > button'; +const LIST_ITEM_PATH = 'ul > li'; // General path for checking existence +const LIST_OBJECT_PATH = '[title="qc/test/object"] li'; // Path specifically for finding objects +const sortOptionPath = (index) => `header > div > div:nth-child(3) > div > div > a:nth-child(${index})`; +const [NAME_ASC_INDEX, NAME_DEC_INDEX] = [1, 2]; +const VIRTUAL_TABLEROW_PATH = 'tbody > tr.object-selectable'; +const SEARCH_PATH = 'header > div > div:nth-child(3) > input'; +const OBJECTS_DESCENDING = ['qc/test/object/2', 'qc/test/object/11', 'qc/test/object/1']; +const OBJECTS_ASCENDING = ['qc/test/object/1', 'qc/test/object/11', 'qc/test/object/2']; /** * Initial page setup tests @@ -29,12 +38,11 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) strictEqual(location.search, OBJECT_TREE_PAGE_PARAM); }); - await testParent.test('should have a tree as a table', { timeout }, async () => { - const tableRowPath = 'section > div > div > div > table > tbody > tr'; - await page.waitForSelector(tableRowPath, { timeout: 1000 }); + await testParent.test('should have a tree as a list', { timeout }, async () => { + await page.waitForSelector(LIST_ITEM_PATH, { timeout: 1000 }); const rowsCount = await page.evaluate( - (tableRowPath) => document.querySelectorAll(tableRowPath).length, - tableRowPath, + (LIST_ITEM_PATH) => document.querySelectorAll(LIST_ITEM_PATH).length, + LIST_ITEM_PATH, ); ok(rowsCount > 1); // more than 1 object in the tree }); @@ -46,37 +54,55 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should have first element in tree as "qc/test/object/1"', async () => { - const { name } = await page.evaluate(() => window.model.object.currentList[0]); - strictEqual(name, 'qc/test/object/1'); + await page.locator('[title="qc/test"]>div').click(); + await page.locator('[title="qc/test/object"]>div').click(); + await delay(50); // Wait for expansion to finish + + const objectIds = await page.evaluate((path) => + [...document.querySelectorAll(path)].map((e)=> e.id), LIST_OBJECT_PATH); + + deepStrictEqual(objectIds, OBJECTS_ASCENDING); }); - await testParent.test('should sort list of histograms by name in descending order', async () => { + await testParent.test('should sort list of objects by name in descending order', async () => { await page.locator(SORTING_BUTTON_PATH).click(); - const sortingByNameOptionPath = 'header > div > div:nth-child(3) > div > div > a:nth-child(2)'; - await page.locator(sortingByNameOptionPath).click(); - - const sorted = await page.evaluate(() => ({ - list: window.model.object.currentList, - sort: window.model.object.sortBy, - })); - strictEqual(sorted.sort.title, 'Name'); - strictEqual(sorted.sort.order, -1); - strictEqual(sorted.sort.field, 'name'); - strictEqual(sorted.list[0].name, 'qc/test/object/2'); + await page.locator(sortOptionPath(NAME_DEC_INDEX)).click(); + await delay(50); // Wait for sort to finish + + const objectIds = await page.evaluate((path) => + [...document.querySelectorAll(path)].map((e)=> e.id), LIST_OBJECT_PATH); + + deepStrictEqual(objectIds, OBJECTS_DESCENDING); }); - await testParent.test('should sort list of histograms by name in ascending order', async () => { + await testParent.test('should sort virtual table of objects by name in descending order', async () => { + await page.locator(SEARCH_PATH).fill('qc'); + await delay(50); // Wait for table to load + + const objectIds = await page.evaluate((rowPath) => + [...document.querySelectorAll(rowPath)].map((e)=> e.title), VIRTUAL_TABLEROW_PATH); + + deepStrictEqual(objectIds, OBJECTS_DESCENDING); + }); + + await testParent.test('should sort virtual table of objects by name in ascending order', async () => { await page.locator(SORTING_BUTTON_PATH).click(); - const sortingByNameOptionPath = 'header > div > div:nth-child(3) > div > div > a:nth-child(1)'; - await page.locator(sortingByNameOptionPath).click(); - const sorted = await page.evaluate(() => ({ - list: window.model.object.currentList, - sort: window.model.object.sortBy, - })); - strictEqual(sorted.sort.title, 'Name'); - strictEqual(sorted.sort.order, 1); - strictEqual(sorted.sort.field, 'name'); - strictEqual(sorted.list[0].name, 'qc/test/object/1'); + await page.locator(sortOptionPath(NAME_ASC_INDEX)).click(); + await delay(50); // Wait for sort to finish + + const objectIds = await page.evaluate((rowPath) => + [...document.querySelectorAll(rowPath)].map((e)=> e.title), VIRTUAL_TABLEROW_PATH); + + deepStrictEqual(objectIds, OBJECTS_ASCENDING); + await page.locator(SEARCH_PATH).fill(' '); // cleanup for the next test. Whitespace is required for some reason + await delay(50); // Wait object list to load + }); + + await testParent.test('should sort list of objects by name in ascending order', async () => { + const objectIds = await page.evaluate((path) => + [...document.querySelectorAll(path)].map((e)=> e.id), LIST_OBJECT_PATH); + + deepStrictEqual(objectIds, OBJECTS_ASCENDING); }); await testParent.test('should have filtered results on input search', async () => { From 02b98399f4f0d2cfe0c17663221f3a7f004e5184 Mon Sep 17 00:00:00 2001 From: Guust Date: Thu, 15 May 2025 11:58:00 +0200 Subject: [PATCH 30/44] refractor: always exclude render messages from frontend --- QualityControl/test/setup/testServerSetup.js | 26 ++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/QualityControl/test/setup/testServerSetup.js b/QualityControl/test/setup/testServerSetup.js index aaa249609..751e2f14a 100644 --- a/QualityControl/test/setup/testServerSetup.js +++ b/QualityControl/test/setup/testServerSetup.js @@ -27,6 +27,9 @@ import path from 'path'; */ export async function setupServerForIntegrationTests() { await copyMockDataFileToUse(); + const isDebug = process.argv.includes('--debug'); + const alwaysFilter = ['JSHandle:render', 'JSHandle:Usage of JSRoot.core.js', 'JSHandle:Set jsroot source_dir']; + const backendFilters = ['ID 0 Client disconnected', 'DB file updated']; let subprocessOutput = undefined; const url = `http://${config.http.hostname}:${config.http.port}/`; @@ -39,10 +42,22 @@ export async function setupServerForIntegrationTests() { }, }); subprocess.stdout.on('data', (chunk) => { - subprocessOutput += chunk.toString(); + const text = chunk.toString(); + subprocessOutput += text; + if (isDebug) { + if (!backendFilters.some((filter)=> text.includes(filter))) { + console.log('BACK-END', text); + } + } }); subprocess.stderr.on('data', (chunk) => { - subprocessOutput += chunk.toString(); + const text = chunk.toString(); + subprocessOutput += text; + if (isDebug) { + if (!backendFilters.some((filter)=> text.includes(filter))) { + console.log('BACK-END', text); + } + } }); // Start browser to test UI @@ -62,9 +77,10 @@ export async function setupServerForIntegrationTests() { console.error(' ', pageerror); }); page.on('console', (msg) => { - for (let i = 0; i < msg.args().length; ++i) { - console.log(` ${msg.args()[i]}`); - } + let lines = msg.args(); + lines = lines.filter((arg)=> !alwaysFilter.some((filter) => arg.toString().includes(filter))); + + lines.forEach((line) => console.log(` ${line}`)); }); return { url, page, browser, subprocess, subprocessOutput }; From abe091f4aecdd6749ec886084182ccff9c90110d Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 16:47:17 +0200 Subject: [PATCH 31/44] fix about page and layout show --- .../public/pages/objectView/ObjectViewPage.js | 2 +- .../test/public/pages/about-page.test.js | 24 ++++++++----------- .../object-view-from-layout-show.test.js | 18 ++++++-------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/QualityControl/public/pages/objectView/ObjectViewPage.js b/QualityControl/public/pages/objectView/ObjectViewPage.js index 900b3efbf..3bb9a28ab 100644 --- a/QualityControl/public/pages/objectView/ObjectViewPage.js +++ b/QualityControl/public/pages/objectView/ObjectViewPage.js @@ -122,7 +122,7 @@ const objectPlotAndInfo = (objectViewModel) => ))), h('.w-100.flex-row.g2.m2', { style: 'height: 0;flex-grow:1' }, [ h('.w-70', draw(qcObject, {}, drawingOptions)), - h('.w-30.scroll-y', [ + h('.w-30.scroll-y#objectInformation', [ h('h3.text-center', 'Object information'), qcObjectInfoPanel(qcObject, { gap: '.5em' }), ]), diff --git a/QualityControl/test/public/pages/about-page.test.js b/QualityControl/test/public/pages/about-page.test.js index 2fb102596..6bde35c5c 100644 --- a/QualityControl/test/public/pages/about-page.test.js +++ b/QualityControl/test/public/pages/about-page.test.js @@ -11,8 +11,7 @@ * or submit itself to any jurisdiction. */ -import { strictEqual } from 'node:assert'; -import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; +import { deepStrictEqual, strictEqual } from 'node:assert'; const ABOUT_PAGE_PARAM = '?page=about'; @@ -22,25 +21,22 @@ export const aboutPageTests = async (url, page, timeout = 5000, testParent) => { const location = await page.evaluate(() => window.location); strictEqual(location.search, '?page=about'); }); - await testServiceStatus(testParent, page, 'qcg', timeout); - await testServiceStatus(testParent, page, 'qc', timeout); - await testServiceStatus(testParent, page, 'ccdb', timeout); + + await testServiceStatus(testParent, page, timeout); + await testServiceStatus(testParent, page, timeout); + await testServiceStatus(testParent, page, timeout); }; -const testServiceStatus = async (testParent, page, serviceName, timeout = 5000) => { +const testServiceStatus = async (testParent, page, timeout = 5000) => { await testParent .test( - `should request info about ${serviceName.toUpperCase()} and store in statuses as RemoteData`, + 'should list successfull services below the Sucessfull header', { timeout }, async () => { - const kind = await page.evaluate( - (service, serviceStatus) => - window.model.aboutViewModel.services[serviceStatus.SUCCESS][service].kind, - serviceName, - ServiceStatus, - ); + const serviceIds = await page.evaluate(() => + [...document.querySelectorAll('.success + div > div')].map((e) => e.id)); - strictEqual(kind, 'Success'); + deepStrictEqual(serviceIds, ['CCDB', 'QC', 'QCG']); }, ); }; diff --git a/QualityControl/test/public/pages/object-view-from-layout-show.test.js b/QualityControl/test/public/pages/object-view-from-layout-show.test.js index dbcaa3583..5aa6d45e7 100644 --- a/QualityControl/test/public/pages/object-view-from-layout-show.test.js +++ b/QualityControl/test/public/pages/object-view-from-layout-show.test.js @@ -15,6 +15,8 @@ import { strictEqual, deepStrictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; const OBJECT_VIEW_PAGE_PARAM = '?page=objectView&objectId=123456'; +const objectDetailRow = (index) => `#objectInformation > div > .flex-row:nth-child(${index}) > div`; +const OBJ_PATH_INDEX = 2; export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, testParent) => { await testParent.test( @@ -95,18 +97,12 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t `${url}?page=objectView&objectId=${objectId}&layoutId=${layoutId}`, { waitUntil: 'networkidle0' }, ); - const result = await page.evaluate(() => { + const result = await page.evaluate((path) => { const title = document.querySelector('div div b').textContent; - const rootPlotClassList = document - .querySelector('body > div > div:nth-child(2) > div:nth-child(2) > div > div').classList; - const selectedObjectPath = window.model.objectViewModel.selected.payload.path; - return { - title, rootPlotClassList, selectedObjectPath, - }; - }); - strictEqual(result.title, 'qc/test/object/1 (from layout: a-test)'); - deepStrictEqual(result.rootPlotClassList, { 0: 'relative', 1: 'jsroot-container' }); - strictEqual(result.selectedObjectPath, 'qc/test/object/1'); + const selectedObjectPath = document.querySelector(path).textContent; + return [title, selectedObjectPath]; + }, objectDetailRow(OBJ_PATH_INDEX)); + deepStrictEqual(result, ['qc/test/object/1 (from layout: a-test)', 'qc/test/object/1']); }, ); }; From 42d0293c0d03ff78d34d562e68e5c8173a93a95d Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 20:01:19 +0200 Subject: [PATCH 32/44] views: add id's and remove window.model dependencies. --- QualityControl/public/common/object/objectInfoCard.js | 2 +- QualityControl/public/layout/list/page.js | 8 +++++--- QualityControl/public/layout/panels/editModal.js | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/QualityControl/public/common/object/objectInfoCard.js b/QualityControl/public/common/object/objectInfoCard.js index 2d818d094..65bf085f5 100644 --- a/QualityControl/public/common/object/objectInfoCard.js +++ b/QualityControl/public/common/object/objectInfoCard.js @@ -25,7 +25,7 @@ const TO_REMOVE_FIELDS = ['qcObject', 'versions', 'name', 'location']; * @returns {vnode} - panel with information about the object */ export const qcObjectInfoPanel = (qcObject, style = {}) => - h('.flex-column.scroll-y', { style }, [ + h('.flex-column.scroll-y#objectInfoPanel', { style }, [ Object.keys(qcObject) .filter((key) => !TO_REMOVE_FIELDS.includes(key)) .map((key) => infoRow(key, qcObject[key])), diff --git a/QualityControl/public/layout/list/page.js b/QualityControl/public/layout/list/page.js index 7804e560d..2dbd5a574 100644 --- a/QualityControl/public/layout/list/page.js +++ b/QualityControl/public/layout/list/page.js @@ -81,6 +81,7 @@ function createHeaderOfFolder(model, folder) { * @returns {vnode} - A virtual DOM node representing the card group layout. */ function layoutCards(model, layouts, searchBy) { + const { router } = model; return layouts.match({ NotAsked: () => null, Loading: () => h('div', 'Loading...'), @@ -98,7 +99,7 @@ function layoutCards(model, layouts, searchBy) { const toggleOfficialFunction = (id) => model.layout.toggleOfficial(id); return h('.card', [ - cardHeader({ ...layout, isMinimumGlobal, toggleOfficialFunction }), + cardHeader({ ...layout, isMinimumGlobal, toggleOfficialFunction, router }), cardBody(owner_name, description), ]); }), @@ -115,9 +116,10 @@ function layoutCards(model, layouts, searchBy) { * @param {string} params.name - Display name of the layout * @param {boolean} params.isMinimumGlobal - Flag for user's global permissions * @param {Function} params.toggleOfficialFunction - Callback for official status toggle + * @param {Function} params.router - Model router functionallity * @returns {vnode} Virtual DOM node representing the layout card header */ -function cardHeader({ isOfficial, id, name, isMinimumGlobal, toggleOfficialFunction }) { +function cardHeader({ isOfficial, id, name, isMinimumGlobal, toggleOfficialFunction, router }) { const bgColor = isOfficial ? 'bg-primary' : 'bg-gray'; const textColor = isOfficial ? 'white' : 'black'; @@ -125,7 +127,7 @@ function cardHeader({ isOfficial, id, name, isMinimumGlobal, toggleOfficialFunct h('h5', [ h(`a.${textColor}`, { href: `?page=layoutShow&layoutId=${id}`, - onclick: (e) => model.router.handleLinkEvent(e), + onclick: (e) => router.handleLinkEvent(e), }, name), ]), isMinimumGlobal ? diff --git a/QualityControl/public/layout/panels/editModal.js b/QualityControl/public/layout/panels/editModal.js index 96892aaa4..2c9a8d9bc 100644 --- a/QualityControl/public/layout/panels/editModal.js +++ b/QualityControl/public/layout/panels/editModal.js @@ -22,7 +22,7 @@ import { h } from '/js/src/index.js'; export default (model) => h('.o2-modal', [ h('.o2-modal-content', [ h('.p2.text-center.flex-column', [ - h('h4.pv1', 'Edit JSON file of a layout'), + h('h4.pv1#editModal', 'Edit JSON file of a layout'), h('', h('textarea.form-control.w-100.resize-vertical', { rows: 15, oninput: (e) => model.layout.checkLayoutToUpdate(e.target.value), From 80243e72077a1db3cfadf81a6025186b7511c758 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 20:01:50 +0200 Subject: [PATCH 33/44] test: simplify and change tests to accomodate the removal of window.model --- .../test/public/pages/layout-show.test.js | 8 ++---- .../object-view-from-object-tree.test.js | 27 +++++++++---------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 2e8e567b2..b9535e6cc 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -400,12 +400,8 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => const cancelButtonPath = 'body > div > div > div > div > button:nth-child(2)'; await page.locator(cancelButtonPath).click(); await delay(50); - const childrenCount = await page.evaluate(() => { - const bodyPath = 'body'; - const body = document.querySelector(bodyPath); - return body.children.length; - }); - strictEqual(childrenCount, 2); + const jsonEditor = await page.evaluate(() => document.querySelector('#editModal')); + strictEqual(jsonEditor, null); }, ); diff --git a/QualityControl/test/public/pages/object-view-from-object-tree.test.js b/QualityControl/test/public/pages/object-view-from-object-tree.test.js index 4436e038b..993aa650a 100644 --- a/QualityControl/test/public/pages/object-view-from-object-tree.test.js +++ b/QualityControl/test/public/pages/object-view-from-object-tree.test.js @@ -11,7 +11,7 @@ * or submit itself to any jurisdiction. */ -import { strictEqual } from 'node:assert'; +import { deepStrictEqual, strictEqual } from 'node:assert'; const OBJECT_VIEW_PAGE_PARAM = '?page=objectView'; export const objectViewFromObjectTreeTests = async (url, page, timeout = 5000, testParent) => { @@ -44,12 +44,14 @@ export const objectViewFromObjectTreeTests = async (url, page, timeout = 5000, t async () => { const backButtonElement = 'div div div a'; await page.locator(backButtonElement).click(); - const result = await page.evaluate(() => ({ - location: window.location.search, - objectSelected: window.model.object.selected, - })); - strictEqual(result.location, '?page=objectTree'); - strictEqual(result.objectSelected, null); + const result = await page.evaluate(() => { + const location = window.location.search; + const objectSelected = document.querySelector('#objectInfoPanel > div:nth-child(2) > div'); + + return [location, objectSelected]; + }); + + deepStrictEqual(result, ['?page=objectTree', null]); }, ); @@ -75,15 +77,12 @@ export const objectViewFromObjectTreeTests = async (url, page, timeout = 5000, t await page.goto(`${url}?page=objectView&objectName=${path}`, { waitUntil: 'networkidle0' }); const result = await page.evaluate(() => { const title = document.querySelector('div div b').innerText; - const rootPlotClassList = - document.querySelector('body > div > div:nth-child(2) > div:nth-child(2) > div > div').classList; - return { title, rootPlotClassList, selectedObjectPath: window.model.objectViewModel.selected.payload.path }; + + const selectedObjectPath = document.querySelector('#objectInfoPanel > div:nth-child(2) > div').textContent; + return [title, selectedObjectPath]; }); - strictEqual(result.title, path); - strictEqual(result.rootPlotClassList[0], 'relative'); - strictEqual(result.rootPlotClassList[1], 'jsroot-container'); - strictEqual(result.selectedObjectPath, path); + deepStrictEqual(result, [path, path]); }, ); From 1f5ffcf143f4af6c0fc1281c3d33e32f55b60030 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 20:11:25 +0200 Subject: [PATCH 34/44] feat: set debug to false if not in development mode. --- QualityControl/public/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/QualityControl/public/index.js b/QualityControl/public/index.js index a26d0c5a2..bb72b854e 100644 --- a/QualityControl/public/index.js +++ b/QualityControl/public/index.js @@ -27,10 +27,12 @@ sessionService.loadAndHideParameters(); // Start application const model = new Model(); -const debug = true; // Shows when redraw is done -mount(document.body, view, model, debug); +let debug = false; // Shows when redraw is done if (envMap[host] === 'dev') { window.sessionService = sessionService; window.model = model; + debug = true; } + +mount(document.body, view, model, debug); From 92830fc6fe39a581338834bc25ba0a55be6c46c8 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 22:38:46 +0200 Subject: [PATCH 35/44] chore: set environment variables in package.json --- QualityControl/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/QualityControl/package.json b/QualityControl/package.json index af03ed22b..4df9edf76 100644 --- a/QualityControl/package.json +++ b/QualityControl/package.json @@ -18,12 +18,12 @@ "type": "module", "homepage": "https://alice-o2-project.web.cern.ch/", "scripts": { - "start": "node index.js", - "dev": "nodemon --watch index.js --watch lib --watch config.js index.js", + "start": "NODE_ENV=dev; node index.js", + "dev": "NODE_ENV=dev; nodemon --watch index.js --watch lib --watch config.js index.js", "lint": "./node_modules/.bin/eslint --config eslint.config.js lib public", "coverage-lcov": "node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info test/test-index.js", "coverage-local": "node --test --experimental-test-coverage --test-reporter=spec test/test-index.js", - "test": "node --test test/test-index.js", + "test": "NODE_ENV=test; node --test test/test-index.js", "copy-config": "sh scripts/copy-config.sh", "docker-dev": "npm run copy-config && docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build", "docker-test": "docker compose -f docker-compose.yml -f docker-compose.test.yml up --build --abort-on-container-exit", From 6619c343320df3d113335b12151fc299e5d61301 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 22:53:15 +0200 Subject: [PATCH 36/44] feat(Index.js): set public/index.js based on the NODE_ENV environment variable --- QualityControl/environmentSetup.js | 47 +++++++++++++++++++++++ QualityControl/environments.js | 5 +++ QualityControl/index.js | 45 ++++++++-------------- QualityControl/package.json | 6 +-- QualityControl/public/index.dev.js | 29 ++++++++++++++ QualityControl/public/index.js | 25 +----------- QualityControl/public/index.production.js | 26 +++++++++++++ QualityControl/public/index.test.js | 26 +++++++++++++ 8 files changed, 153 insertions(+), 56 deletions(-) create mode 100644 QualityControl/environmentSetup.js create mode 100644 QualityControl/environments.js create mode 100644 QualityControl/public/index.dev.js create mode 100644 QualityControl/public/index.production.js create mode 100644 QualityControl/public/index.test.js diff --git a/QualityControl/environmentSetup.js b/QualityControl/environmentSetup.js new file mode 100644 index 000000000..d91fe8300 --- /dev/null +++ b/QualityControl/environmentSetup.js @@ -0,0 +1,47 @@ +import { LogManager } from '@aliceo2/web-ui'; +import { fileURLToPath } from 'url'; +import { envIndexFiles } from './environments.js'; +import { join, dirname } from 'path'; +import fs from 'fs'; + +/** + * Initializes environment-specific JavaScript file and logging configuration. + * - Copies the appropriate environment-specific index.js file (dev/prod/test) to index.js + * - Sets up test environment mocks when in test mode + * @async + * @function initializeEnvironment + * @returns {Promise} logger instance + * @description + * This function performs environment setup tasks: + * 1. Determines correct environment-specific JS file based on NODE_ENV + * 2. Copies the environment-specific file to index.js in public directory + * 3. Initializes test mocks when in test environment + * 4. Returns a configured logger instance + */ +export default async function () { + const { NODE_ENV, npm_config_log_label } = process.env; + const logger = LogManager.getLogger(`${npm_config_log_label ?? 'qcg'}/index`); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + + const envIndexFile = envIndexFiles[NODE_ENV]; + + const publicDir = join(__dirname, 'public'); + const sourceJsPath = + join(publicDir, envIndexFiles[NODE_ENV]); // These files will have different properties per environment + const targetJsPath = join(publicDir, 'index.js'); // the former file will be overwrite this file. + + fs.copyFileSync(sourceJsPath, targetJsPath); + logger.infoMessage(`Using ${envIndexFile} as index.js for environment: ${process.env.NODE_ENV}`); + + if (NODE_ENV === 'test') { + // Initialize nock for CCDB and Bookkeeping only if we are in test environment + const { initializeNockForCcdb } = await import('./test/setup/testSetupForCcdb.js'); + const { initializeNockForBkp } = await import('./test/setup/testSetupForBkp.js'); + + initializeNockForCcdb(); + initializeNockForBkp(); + } + + return logger; +} diff --git a/QualityControl/environments.js b/QualityControl/environments.js new file mode 100644 index 000000000..030088a72 --- /dev/null +++ b/QualityControl/environments.js @@ -0,0 +1,5 @@ +export const envIndexFiles = Object.freeze({ + development: 'index.dev.js', + test: 'index.test.js', + production: 'index.production.js', +}); diff --git a/QualityControl/index.js b/QualityControl/index.js index 64cf6f68e..45a25bae2 100644 --- a/QualityControl/index.js +++ b/QualityControl/index.js @@ -12,49 +12,36 @@ * or submit itself to any jurisdiction. */ -import { LogManager, HttpServer, WebSocket } from '@aliceo2/web-ui'; -const logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'qcg'}/index`); -import path from 'path'; +import { HttpServer, WebSocket } from '@aliceo2/web-ui'; +import { join, dirname } from 'path'; import { setup } from './lib/api.js'; - -// Reading config file +import { fileURLToPath } from 'url'; import { config } from './lib/config/configProvider.js'; +import { createRequire } from 'module'; +import environmentSetup from './environmentSetup.js'; -// Quick check config at start - +const logger = await environmentSetup(); +// Reading config file if (config.http.tls) { - logger.info(`HTTPS endpoint: https://${config.http.hostname}:${config.http.portSecure}`); + logger.infoMessage(`HTTPS endpoint: https://${config.http.hostname}:${config.http.portSecure}`); } -logger.info(`HTTP endpoint: http://${config.http.hostname}:${config.http.port}`); +logger.infoMessage(`HTTP endpoint: http://${config.http.hostname}:${config.http.port}`); if (typeof config.demoData != 'undefined' && config.demoData) { - logger.info('Using demo data'); + logger.infoMessage('Using demo data'); } else { config.demoData = false; } -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - const __dirname = dirname(fileURLToPath(import.meta.url)); -// Start servers -const http = new HttpServer(config.http, config.jwt, config.openId); -http.addStaticPath(path.join(__dirname, 'common')); -http.addStaticPath(path.join(__dirname, 'public')); - -import { createRequire } from 'module'; - const require = createRequire(import.meta.url); const pathName = require.resolve('jsroot'); -http.addStaticPath(path.join(pathName, '../..'), 'jsroot'); -const ws = new WebSocket(http); +// Start servers +const http = new HttpServer(config.http, config.jwt, config.openId); +http.addStaticPath(join(__dirname, 'common')); +http.addStaticPath(join(__dirname, 'public')); +http.addStaticPath(join(pathName, '../..'), 'jsroot'); -if (process.env.NODE_ENV === 'test') { - // Initialize nock for CCDB and Bookkeeping only if we are in test environment - const { initializeNockForCcdb } = await import('./test/setup/testSetupForCcdb.js'); - const { initializeNockForBkp } = await import('./test/setup/testSetupForBkp.js'); +const ws = new WebSocket(http); - initializeNockForCcdb(); - initializeNockForBkp(); -} setup(http, ws); diff --git a/QualityControl/package.json b/QualityControl/package.json index 4df9edf76..9e42cfcc0 100644 --- a/QualityControl/package.json +++ b/QualityControl/package.json @@ -18,12 +18,12 @@ "type": "module", "homepage": "https://alice-o2-project.web.cern.ch/", "scripts": { - "start": "NODE_ENV=dev; node index.js", - "dev": "NODE_ENV=dev; nodemon --watch index.js --watch lib --watch config.js index.js", + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development nodemon --watch index.js --watch lib --watch config.js index.js", "lint": "./node_modules/.bin/eslint --config eslint.config.js lib public", "coverage-lcov": "node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info test/test-index.js", "coverage-local": "node --test --experimental-test-coverage --test-reporter=spec test/test-index.js", - "test": "NODE_ENV=test; node --test test/test-index.js", + "test": "node --test test/test-index.js", "copy-config": "sh scripts/copy-config.sh", "docker-dev": "npm run copy-config && docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build", "docker-test": "docker compose -f docker-compose.yml -f docker-compose.test.yml up --build --abort-on-container-exit", diff --git a/QualityControl/public/index.dev.js b/QualityControl/public/index.dev.js new file mode 100644 index 000000000..98cd8dcf8 --- /dev/null +++ b/QualityControl/public/index.dev.js @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +// Import QCG Public Configuration +import { mount, sessionService } from '/js/src/index.js'; +import view from './view.js'; +import Model from './Model.js'; + +sessionService.loadAndHideParameters(); + +// Start application +const model = new Model(); +const debug = true; + +window.sessionService = sessionService; +window.model = model; + +mount(document.body, view, model, debug); diff --git a/QualityControl/public/index.js b/QualityControl/public/index.js index bb72b854e..edf8b791b 100644 --- a/QualityControl/public/index.js +++ b/QualityControl/public/index.js @@ -12,27 +12,4 @@ * or submit itself to any jurisdiction. */ -// Import QCG Public Configuration -import { mount, sessionService } from '/js/src/index.js'; -import view from './view.js'; -import Model from './Model.js'; - -const { host } = window.location; -const envMap = { - 'localhost:8090': 'dev', - 'localhost:8081': 'test', -}; - -sessionService.loadAndHideParameters(); - -// Start application -const model = new Model(); -let debug = false; // Shows when redraw is done - -if (envMap[host] === 'dev') { - window.sessionService = sessionService; - window.model = model; - debug = true; -} - -mount(document.body, view, model, debug); +// This file will be overwritten at runtime diff --git a/QualityControl/public/index.production.js b/QualityControl/public/index.production.js new file mode 100644 index 000000000..7646f7fce --- /dev/null +++ b/QualityControl/public/index.production.js @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +// Import QCG Public Configuration +import { mount, sessionService } from '/js/src/index.js'; +import view from './view.js'; +import Model from './Model.js'; + +sessionService.loadAndHideParameters(); + +// Start application +const model = new Model(); +const debug = false; // Shows when redraw is done + +mount(document.body, view, model, debug); diff --git a/QualityControl/public/index.test.js b/QualityControl/public/index.test.js new file mode 100644 index 000000000..1ef12c638 --- /dev/null +++ b/QualityControl/public/index.test.js @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +// Import QCG Public Configuration +import { mount, sessionService } from '/js/src/index.js'; +import view from './view.js'; +import Model from './Model.js'; + +sessionService.loadAndHideParameters(); + +// Start application +const model = new Model(); +const debug = true; // Shows when redraw is done + +mount(document.body, view, model, debug); From ad4411d9d845f0aa645f7541622d64c692d3e623 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Sun, 25 May 2025 22:58:32 +0200 Subject: [PATCH 37/44] chore: put public/index.js in gitignore --- QualityControl/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/QualityControl/.gitignore b/QualityControl/.gitignore index fc417b32a..d96e43221 100644 --- a/QualityControl/.gitignore +++ b/QualityControl/.gitignore @@ -1,5 +1,6 @@ /config.js public/config.cjs +public/index.js test/setup/seeders/qcg-mock-data.json *~ .DS_Store From 63b6781a9bc977ffe0af34ead79461bdfbe4d1e2 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Mon, 26 May 2025 11:14:38 +0200 Subject: [PATCH 38/44] refactor: refactor changes based on feedback --- Framework/Frontend/css/src/bootstrap.css | 2 + QualityControl/public/app.css | 8 ++ QualityControl/public/layout/view/page.js | 12 +-- .../objectTreeView/component/objectPanel.js | 83 ++++++++++++------- .../test/public/pages/object-tree.test.js | 4 +- 5 files changed, 66 insertions(+), 43 deletions(-) diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index c5415d655..c4b71bcf2 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -157,10 +157,12 @@ h6, .f6 { font-size: .875rem; } .p2 { padding: var(--space-s); } .p3 { padding: var(--space-m); } .p4 { padding: var(--space-l); } +.ph0 { padding-left: 0; padding-right: 0; } .ph1 { padding-left: var(--space-xs); padding-right: var(--space-xs); } .ph2 { padding-left: var(--space-s); padding-right: var(--space-s); } .ph3 { padding-left: var(--space-m); padding-right: var(--space-m); } .ph4 { padding-left: var(--space-l); padding-right: var(--space-l); } +.pv0 { padding-top: 0; padding-bottom: 0; } .pv1 { padding-top: var(--space-xs); padding-bottom: var(--space-xs); } .pv2 { padding-top: var(--space-s); padding-bottom: var(--space-s); } .pv3 { padding-top: var(--space-m); padding-bottom: var(--space-m); } diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index e11a8bba6..581e949d8 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -103,6 +103,14 @@ li.object-tree-branch, ul.object-tree-list { .jsroot-container {} .jsroot-container pre { background-color: initial; } .jsrootdiv:hover + .resize-element, .resize-element:hover{ display: flex !important; } +.jsrootdiv { + z-index: 90; + overflow: hidden; + height: 100%; + display: flex; + flex-direction: column; +} + .resize-button { position: absolute; right: 0%; z-index: 100 } diff --git a/QualityControl/public/layout/view/page.js b/QualityControl/public/layout/view/page.js index 8c3e21eaf..7f765326b 100644 --- a/QualityControl/public/layout/view/page.js +++ b/QualityControl/public/layout/view/page.js @@ -195,16 +195,8 @@ function chartView(model, tabObject) { * @param {object} tabObject - to be drawn with jsroot * @returns {vnode} - virtual node element */ -const drawComponent = (model, tabObject) => h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ - h('.jsrootdiv', { - style: { - 'z-index': 90, - overflow: 'hidden', - height: '100%', - display: 'flex', - 'flex-direction': 'column', - }, - }, draw(model, tabObject, {})), +const drawComponent = (model, tabObject) => h('.h-100.flex-column', [ + h('.jsrootdiv', draw(model, tabObject, {})), objectInfoResizePanel(model, tabObject), model.layout.item && model.layout.item.displayTimestamp && minimalObjectInfo(model, tabObject), diff --git a/QualityControl/public/pages/objectTreeView/component/objectPanel.js b/QualityControl/public/pages/objectTreeView/component/objectPanel.js index b3e99775f..78e4c1e4e 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectPanel.js +++ b/QualityControl/public/pages/objectTreeView/component/objectPanel.js @@ -10,8 +10,10 @@ import { spinner } from '../../../common/spinner.js'; * @returns {vnode} - virtual node element */ export function objectPanel(model) { - const selectedObjectName = model.object.selected.name; - if (model.object.objects && model.object.objects[selectedObjectName]) { + const { objects, selected } = model.object; + const selectedObjectName = selected.name; + + if (objects?.[selectedObjectName]) { return model.object.objects[selectedObjectName].match({ NotAsked: () => null, Loading: () => @@ -24,6 +26,42 @@ export function objectPanel(model) { return null; } +/** + * Creates the resize button element + * @param {Model.router} router - the application router + * @param {string} href - link for the full screen view + * @returns {vnode} - virtual node element for resize button + */ +const resizeButton = (router, href) => h('.resize-button.flex-row', [ + h('.p1.text-left.pv0', h( + 'a.btn', + { + title: 'Open object plot in full screen', + href, + onclick: (e) => router.handleLinkEvent(e), + }, + iconResizeBoth(), + )), +]); + +/** + * Creates the main plot container + * @param {Model} model - root model of the application + * @param {string} name - name of the object + * @returns {vnode} - virtual node element for the plot + */ +const plotSelection = (model, name) => h('', { style: 'height:77%;' }, draw(model, name, { stat: true })); + +/** + * Creates the info panel container with timestamp selector + * @param {Model} model - root model of the application + * @returns {vnode} - virtual node element for the info panel + */ +const infoPanel = (model) => h('.scroll-y', {}, [ + h('.w-100.flex-row.justify-center', h('.w-80', timestampSelectForm(model))), + qcObjectInfoPanel(model, { 'font-size': '.875rem;' }), +]); + /** * Draw the object including the info button and history dropdown * @param {Model} model - root model of the application @@ -31,28 +69,14 @@ export function objectPanel(model) { * @returns {vnode} - virtual node element */ export const drawPlot = (model, object) => { + const { router } = model; const { name, validFrom, id } = object; - const href = validFrom ? - `?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}` - : `?page=objectView&objectName=${name}`; - const info = object; - return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ - h('.resize-button.flex-row', [ - h('.p1.text-left', { style: 'padding-bottom: 0;' }, h( - 'a.btn', - { - title: 'Open object plot in full screen', - href, - onclick: (e) => model.router.handleLinkEvent(e), - }, - iconResizeBoth(), - )), - ]), - h('', { style: 'height:77%;' }, draw(model, name, { stat: true })), - h('.scroll-y', {}, [ - h('.w-100.flex-row', { style: 'justify-content: center' }, h('.w-80', timestampSelectForm(model))), - qcObjectInfoPanel(info, { 'font-size': '.875rem;' }), - ]), + const href = `?page=objectView&objectName=${name}${validFrom ? `&ts=${validFrom}&id=${id}` : ''}`; + + return h('.h100.flex-column', [ + resizeButton(router, href), + plotSelection(model, name), + infoPanel(model), ]); }; @@ -62,14 +86,11 @@ export const drawPlot = (model, object) => { * @returns {vnode} - virtual node element */ export function statusBarLeft(object) { - let itemsInfo = ''; - if (!object.currentList) { - itemsInfo = 'Loading objects...'; - } else if (object.searchInput) { - itemsInfo = `${object.searchResult.length} found of ${object.currentList.length} items`; - } else { - itemsInfo = `${object.currentList.length} items`; - } + const { currentList, searchInput, searchResult } = object; + let itemsInfo = 'Loading objects...'; + + itemsInfo = searchInput ? + `${searchResult.length} found of ${currentList.length} items` : `${currentList.length} items`; return h('span.flex-grow', itemsInfo); } diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index d124e03af..8d100e8ed 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -79,10 +79,10 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) await page.locator(SEARCH_PATH).fill('qc'); await delay(50); // Wait for table to load - const objectIds = await page.evaluate((rowPath) => + const objectTitles = await page.evaluate((rowPath) => [...document.querySelectorAll(rowPath)].map((e)=> e.title), VIRTUAL_TABLEROW_PATH); - deepStrictEqual(objectIds, OBJECTS_DESCENDING); + deepStrictEqual(objectTitles, OBJECTS_DESCENDING); }); await testParent.test('should sort virtual table of objects by name in ascending order', async () => { From 019cc1585af119bb55b2f7ee9a42d0d04070e7a6 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Mon, 26 May 2025 15:54:02 +0200 Subject: [PATCH 39/44] refactor: initialise itemsInfo dynamically --- .../public/pages/objectTreeView/component/objectPanel.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/QualityControl/public/pages/objectTreeView/component/objectPanel.js b/QualityControl/public/pages/objectTreeView/component/objectPanel.js index 78e4c1e4e..498e1a0be 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectPanel.js +++ b/QualityControl/public/pages/objectTreeView/component/objectPanel.js @@ -87,9 +87,7 @@ export const drawPlot = (model, object) => { */ export function statusBarLeft(object) { const { currentList, searchInput, searchResult } = object; - let itemsInfo = 'Loading objects...'; - - itemsInfo = searchInput ? + const itemsInfo = searchInput ? `${searchResult.length} found of ${currentList.length} items` : `${currentList.length} items`; return h('span.flex-grow', itemsInfo); From 215ccc24bc4d23d49cd921c92e78bab49354245c Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Mon, 26 May 2025 15:58:35 +0200 Subject: [PATCH 40/44] refactor --- .../public/pages/objectTreeView/component/objectPanel.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/QualityControl/public/pages/objectTreeView/component/objectPanel.js b/QualityControl/public/pages/objectTreeView/component/objectPanel.js index 498e1a0be..382fb4696 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectPanel.js +++ b/QualityControl/public/pages/objectTreeView/component/objectPanel.js @@ -98,6 +98,4 @@ export function statusBarLeft(object) { * @param {QCObject} object - object model that handles state around object * @returns {vnode} - virtual node element */ -export const statusBarRight = (object) => object.selected - ? h('span.right', object.selected.name) - : null; +export const statusBarRight = (object) => object.selected && h('span.right', object.selected.name); From 6f7f7f76c6df5da33bfbbc73b68aadd41ca394f5 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Mon, 26 May 2025 16:13:48 +0200 Subject: [PATCH 41/44] experiment 1 --- .../public/pages/objectTreeView/component/objectPanel.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/QualityControl/public/pages/objectTreeView/component/objectPanel.js b/QualityControl/public/pages/objectTreeView/component/objectPanel.js index 382fb4696..498e1a0be 100644 --- a/QualityControl/public/pages/objectTreeView/component/objectPanel.js +++ b/QualityControl/public/pages/objectTreeView/component/objectPanel.js @@ -98,4 +98,6 @@ export function statusBarLeft(object) { * @param {QCObject} object - object model that handles state around object * @returns {vnode} - virtual node element */ -export const statusBarRight = (object) => object.selected && h('span.right', object.selected.name); +export const statusBarRight = (object) => object.selected + ? h('span.right', object.selected.name) + : null; From b578bd97497232f848963d694209d0c15896ee25 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Mon, 26 May 2025 16:46:59 +0200 Subject: [PATCH 42/44] style: move bootstrap alterations to app.css --- Framework/Frontend/css/src/bootstrap.css | 2 -- QualityControl/public/app.css | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index c4b71bcf2..c5415d655 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -157,12 +157,10 @@ h6, .f6 { font-size: .875rem; } .p2 { padding: var(--space-s); } .p3 { padding: var(--space-m); } .p4 { padding: var(--space-l); } -.ph0 { padding-left: 0; padding-right: 0; } .ph1 { padding-left: var(--space-xs); padding-right: var(--space-xs); } .ph2 { padding-left: var(--space-s); padding-right: var(--space-s); } .ph3 { padding-left: var(--space-m); padding-right: var(--space-m); } .ph4 { padding-left: var(--space-l); padding-right: var(--space-l); } -.pv0 { padding-top: 0; padding-bottom: 0; } .pv1 { padding-top: var(--space-xs); padding-bottom: var(--space-xs); } .pv2 { padding-top: var(--space-s); padding-bottom: var(--space-s); } .pv3 { padding-top: var(--space-m); padding-bottom: var(--space-m); } diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 581e949d8..ec9215006 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -136,3 +136,7 @@ li.object-tree-branch, ul.object-tree-list { border: 1px solid #ddd; background: var(--color-gray-light); } + +/* Standardised styles to potentially be moved to bootstrap */ +.ph0 { padding-left: 0; padding-right: 0; } +.pv0 { padding-top: 0; padding-bottom: 0; } From 808bd6a0cb4bf9321703a0c6544dd0e8052ccd23 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Tue, 27 May 2025 10:03:26 +0200 Subject: [PATCH 43/44] option stealth fix --- QualityControl/public/layout/view/header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/public/layout/view/header.js b/QualityControl/public/layout/view/header.js index 789f16dee..7c22ea8f5 100644 --- a/QualityControl/public/layout/view/header.js +++ b/QualityControl/public/layout/view/header.js @@ -155,7 +155,7 @@ const resizeGridTabDropDown = (layout, tab) => title: 'Resize grid of the tab', onchange: (e) => layout.resizeGridByXY(e.target.value), }, [1, 2, 3, 4, 5].map((i) => - h('option', { selected: tab?.columns === i, title: `Resize layout to ${i} columns`, value: 1 }, `${i} cols`))); + h('option', { selected: tab?.columns === i, title: `Resize layout to ${i} columns`, value: i }, `${i} cols`))); /** * Single tab button From c385f4d90e18077c89f3e75181ce6396cfad343c Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Tue, 27 May 2025 11:59:20 +0200 Subject: [PATCH 44/44] chore: install cross-env dependency --- QualityControl/package-lock.json | 25 +++++++++++++++++++------ QualityControl/package.json | 1 + 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/QualityControl/package-lock.json b/QualityControl/package-lock.json index fba3195a7..502ca0ad9 100644 --- a/QualityControl/package-lock.json +++ b/QualityControl/package-lock.json @@ -15,6 +15,7 @@ "license": "GPL-3.0", "dependencies": { "@aliceo2/web-ui": "2.8.4", + "cross-env": "^7.0.3", "joi": "17.13.3", "jsroot": "7.9.0", "mariadb": "^3.4.1", @@ -2016,11 +2017,28 @@ } } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3750,7 +3768,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jackspeak": { @@ -4846,7 +4863,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5628,7 +5644,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5641,7 +5656,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6587,7 +6601,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/QualityControl/package.json b/QualityControl/package.json index b2cc3781b..d2d8bec1d 100644 --- a/QualityControl/package.json +++ b/QualityControl/package.json @@ -38,6 +38,7 @@ ], "dependencies": { "@aliceo2/web-ui": "2.8.4", + "cross-env": "^7.0.3", "joi": "17.13.3", "jsroot": "7.9.0", "mariadb": "^3.4.1",