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 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/lib/dtos/QCObjectNameDto.js b/QualityControl/lib/dtos/QCObjectNameDto.js new file mode 100644 index 000000000..660cfcf05 --- /dev/null +++ b/QualityControl/lib/dtos/QCObjectNameDto.js @@ -0,0 +1,80 @@ +/** + * @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'; +import { LogManager } from '@aliceo2/web-ui'; + +const logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'qcg'}/qcObj-Dto`); + +/** + * 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 isValidPath(path) { + if (typeof path !== 'string' || path === '') { + logger.debugMessage(`Invalid path: Path is empty or not a string (${path})`); + return false; + } + + if (path.endsWith('/')) { + logger.debugMessage(`Invalid path: Path ends with a slash (${path})`); + return false; + } + + 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); + 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 true; +} + +/** + * Joi schema that validates the path + */ +export const qcObjectNameDto = Joi.string() + .custom((value) => { + 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) + .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') + .options({ stripUnknown: { arrays: true } }); diff --git a/QualityControl/lib/services/ccdb/CcdbService.js b/QualityControl/lib/services/ccdb/CcdbService.js index 8e2f8bd13..96eba0294 100644 --- a/QualityControl/lib/services/ccdb/CcdbService.js +++ b/QualityControl/lib/services/ccdb/CcdbService.js @@ -17,6 +17,7 @@ import { httpHeadJson, httpGetJson } from '../../utils/httpRequests.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, @@ -107,12 +108,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/package-lock.json b/QualityControl/package-lock.json index 0cb0e08af..391fc2c52 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", @@ -2019,11 +2020,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", @@ -3745,7 +3763,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": { @@ -4844,7 +4861,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" @@ -5626,7 +5642,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" @@ -5639,7 +5654,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" @@ -6561,7 +6575,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 99f8ad88e..4cae59f82 100644 --- a/QualityControl/package.json +++ b/QualityControl/package.json @@ -18,8 +18,8 @@ "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": "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", @@ -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", diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index c716b9c03..dceab43fe 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; } @@ -62,6 +67,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; +} + + .folderHeader, .cardHeader { border-radius: .5rem .5rem 0 0; } @@ -75,6 +104,14 @@ .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 } @@ -100,3 +137,7 @@ 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; } 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/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 6acb28dd3..edf8b791b 100644 --- a/QualityControl/public/index.js +++ b/QualityControl/public/index.js @@ -12,18 +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'; - -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; +// 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); 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), diff --git a/QualityControl/public/layout/view/page.js b/QualityControl/public/layout/view/page.js index dd2f4180b..22177380d 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/layout/view/panels/objectTreeSidebar.js b/QualityControl/public/layout/view/panels/objectTreeSidebar.js index 46d96a1da..e4d42a115 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 @@ -25,13 +26,15 @@ import virtualTable from '../../../object/virtualTable.js'; * @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())); @@ -42,7 +45,7 @@ export default (model) => '.scroll-y', searchInput.trim() !== '' ? virtualTable(model, 'side', objectsToDisplay) - : treeTable(model), + : ObjectTreeComponent(sideTree, branchItem, (leafObject) => sideTreeLeafItem(leafObject, object, layout)), ), objectPreview(model), ]; @@ -52,6 +55,7 @@ export default (model) => h('', error.message), ]), }); +}; /** * An input which allows users to search though objects; @@ -68,122 +72,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: () => { - model.object.select(sideTree.object); - 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 a27b8a0ba..85c7ccf1c 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -13,9 +13,9 @@ */ 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,10 +48,10 @@ 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'); + this.sideTree = new ObjectTreeModel('database'); this.sideTree.bubbleTo(this); this.queryingObjects = false; this.scrollTop = 0; @@ -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 deleted file mode 100644 index 1707b42c5..000000000 --- a/QualityControl/public/object/objectTreePage.js +++ /dev/null @@ -1,237 +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 { 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) => !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 []; - } 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, - ]; - } - 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/pages/objectTreeView/component/ObjectTreeComponent.js b/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js new file mode 100644 index 000000000..bf6d5fa7b --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/component/ObjectTreeComponent.js @@ -0,0 +1,41 @@ +/** + * @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 } 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, branchItem, leafItem) { + return [ + h('.heading', 'Name'), + 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, 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/objectPanel.js b/QualityControl/public/pages/objectTreeView/component/objectPanel.js new file mode 100644 index 000000000..498e1a0be --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/component/objectPanel.js @@ -0,0 +1,103 @@ +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 { objects, selected } = model.object; + const selectedObjectName = selected.name; + + if (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; +} + +/** + * 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 + * @param {JSON} object - {qcObject, info, timestamps} + * @returns {vnode} - virtual node element + */ +export const drawPlot = (model, object) => { + const { router } = model; + const { name, validFrom, id } = object; + const href = `?page=objectView&objectName=${name}${validFrom ? `&ts=${validFrom}&id=${id}` : ''}`; + + return h('.h100.flex-column', [ + resizeButton(router, href), + plotSelection(model, name), + infoPanel(model), + ]); +}; + +/** + * 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) { + const { currentList, searchInput, searchResult } = object; + const itemsInfo = searchInput ? + `${searchResult.length} found of ${currentList.length} items` : `${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; diff --git a/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js new file mode 100644 index 000000000..222a1cf3e --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/component/objectTreeItem.js @@ -0,0 +1,79 @@ +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, pathString } = treeModel; + + return h('li.object-tree-branch', { key: pathString, title: pathString, id: pathString }, [ + 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 leafObject (end node that represents an object) + * @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, 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: () => qcObject.select(leafObject), + title: name, + }, [ + h('span', iconBarChart()), + ' ', + displayName, + ]), + ]); +}; + +/** + * Creates a list item for a leafObject (end node that represents an object) + * @param {object} leafObject - the leafObject object + * @param {QCObject} qcObject - object managing model + * @param {QCObject} layout - layout managing model + * @returns {vnode} - virtual node element + */ +export const sideTreeLeafItem = (leafObject, qcObject, layout) => { + const { name } = leafObject; + const displayName = name.split('/').pop(); + const className = leafObject === qcObject.selected ? 'bg-primary white' : ''; + + const attr = { + key: name, + title: name, + id: name, + className, + onclick: () => qcObject.select(leafObject), + draggable: true, + ondragstart: () => { + qcObject.select(leafObject); + const newItem = layout.addItem(name); + layout.moveTabObjectStart(newItem); + }, + ondblclick: () => layout.addItem(name), + }; + + return h('li.object-tree-leaf', [ + h('div.object-selectable', attr, [ + h('span', iconBarChart()), + ' ', + displayName, + ]), + ]); +}; diff --git a/QualityControl/public/object/ObjectTree.class.js b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js similarity index 62% rename from QualityControl/public/object/ObjectTree.class.js rename to QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js index 4135b5e9b..95a9350c4 100644 --- a/QualityControl/public/object/ObjectTree.class.js +++ b/QualityControl/public/pages/objectTreeView/model/ObjectTreeModel.js @@ -19,11 +19,11 @@ import { Observable } from '/js/src/index.js'; * 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 { +export default class ObjectTreeModel extends Observable { /** * Instantiate tree with a root node called `name`, empty by default * @param {string} name - root name - * @param {ObjectTree} parent - optional parent node + * @param {ObjectTreeModel} parent - optional parent node */ constructor(name, parent) { super(); @@ -38,10 +38,9 @@ export default class ObjectTree extends Observable { */ initTree(name, parent) { this.name = name || ''; // Like 'B' - this.object = null; this.open = name === 'qc' ? true : false; - this.children = []; // > - this.parent = parent || null; // + this.children = []; // > + this.parent = parent || null; // this.path = []; // Like ['A', 'B'] for node at path 'A/B' called 'B' this.pathString = ''; // 'A/B' } @@ -84,11 +83,11 @@ export default class ObjectTree extends Observable { } /** - * 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 + * Add recursively an objectModel inside a tree + * @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 * * Example of recursive call: * addChild(o) // begin insert 'A/B' @@ -97,21 +96,21 @@ export default class ObjectTree extends Observable { * addChild(o, [], ['A', 'B']) // end inserting, affecting B * @returns {undefined} */ - addChild(object, path, pathParent) { + addChild(objectModel, path, pathParent) { // Fill the path argument through recursive call if (!path) { - if (!object.name) { + if (!objectModel.name) { throw new Error('Object name must exist'); } - path = object.name.split('/'); - this.addChild(object, path, []); + 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; } - // Case end of path, associate the object to 'this' node if (path.length === 0) { - this.object = object; + this.children.push(objectModel); return; } @@ -126,7 +125,7 @@ export default class ObjectTree extends Observable { * Create it and push as child * Listen also for changes to bubble it until root */ - subtree = new ObjectTree(name, this); + subtree = new ObjectTreeModel(name, this); subtree.path = fullPath; subtree.pathString = fullPath.join('/'); this.children.push(subtree); @@ -134,7 +133,7 @@ export default class ObjectTree extends Observable { } // Pass to child - subtree.addChild(object, path, fullPath); + subtree.addChild(objectModel, path, fullPath); } /** @@ -145,4 +144,34 @@ export default class ObjectTree extends Observable { 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.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; + } } diff --git a/QualityControl/public/pages/objectTreeView/objectTreePage.js b/QualityControl/public/pages/objectTreeView/objectTreePage.js new file mode 100644 index 000000000..cab6ca9c8 --- /dev/null +++ b/QualityControl/public/pages/objectTreeView/objectTreePage.js @@ -0,0 +1,58 @@ +/** + * @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 { 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'; + +/** + * 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) => { + const { object, router } = model; + const { selected } = object; + const treeWidthClass = selected ? '.w-50' : '.w-100'; + + return h('.h-100.flex-column', { key: router.params.page }, [ + h('.flex-row.flex-grow', [ + 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')]), + 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, (leafObject) => leafItem(leafObject, object)); + }, + Failure: () => null, // Notification is displayed + })), + selected && h(`.animate-width.scroll-y${treeWidthClass}`, objectPanel(model)), + ]), + h('.f6.status-bar.ph1.flex-row', [ + statusBarLeft(object), + statusBarRight(object), + ]), + ]); +}; 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/public/view.js b/QualityControl/public/view.js index a188e2579..35f9a8299 100644 --- a/QualityControl/public/view.js +++ b/QualityControl/public/view.js @@ -21,10 +21,10 @@ 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 LayoutListPage from './pages/layoutListView/LayoutListPage.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/lib/services/CcdbService.test.js b/QualityControl/test/lib/services/CcdbService.test.js index 85ce2f1bf..b97b5c395 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,26 @@ export const ccdbServiceTestSuite = async () => { deepStrictEqual(objectsRetrieved, expectedObjects, 'Received objects are not alike'); }); + 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/four/', + 'object/valid', + ]; + const expectedObjects = [{ path: 'object/valid' }]; + + 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 +249,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 +333,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 +414,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 +426,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 +437,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 +448,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 +475,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 +512,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' + ); }); }); }); 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/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 4d1d3697e..b9535e6cc 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -270,11 +270,15 @@ 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(); + await delay(50); // to let the list unfold const rowsCount = await page.evaluate((secondElementPath) => document.querySelectorAll(secondElementPath).length, secondElementPath); - strictEqual(rowsCount, 1); + + 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, three if you expand the one after. }, ); @@ -396,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-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 59e9a3f36..8d100e8ed 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 objectTitles = await page.evaluate((rowPath) => + [...document.querySelectorAll(rowPath)].map((e)=> e.title), VIRTUAL_TABLEROW_PATH); + + deepStrictEqual(objectTitles, 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 () => { 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']); }, ); }; 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]); }, ); 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 };