diff --git a/QualityControl/public/common/downloadRootImageButton.js b/QualityControl/public/common/downloadRootImageButton.js deleted file mode 100644 index 76f69d313..000000000 --- a/QualityControl/public/common/downloadRootImageButton.js +++ /dev/null @@ -1,39 +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, imagE } from '/js/src/index.js'; -import { downloadRoot, getFileExtensionFromName } from './utils.js'; -import { isObjectOfTypeChecker } from '../../library/qcObject/utils.js'; - -/** - * Download root image button. - * @param {string} filename - The name of the downloaded file including its extension. - * @param {RootObject} root - The JSROOT RootObject to render. - * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. - * @returns {vnode} - Download root image button element. - */ -export function downloadRootImageButton(filename, root, drawingOptions = []) { - const filetype = getFileExtensionFromName(filename); - return !isObjectOfTypeChecker(root) && h(`button.btn.download-root-image-${filetype}-button`, { - title: `Download as ${filetype.toUpperCase()}`, - onclick: async (event) => { - try { - event.target.disabled = true; - await downloadRoot(filename, root, drawingOptions); - } finally { - event.target.disabled = false; - } - }, - }, imagE()); -} diff --git a/QualityControl/public/common/downloadRootImageDropdown.js b/QualityControl/public/common/downloadRootImageDropdown.js new file mode 100644 index 000000000..2afdb83f1 --- /dev/null +++ b/QualityControl/public/common/downloadRootImageDropdown.js @@ -0,0 +1,70 @@ +/** + * @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, DropdownComponent, imagE } from '/js/src/index.js'; +import { downloadRoot } from './utils.js'; +import { isObjectOfTypeChecker } from '../../library/qcObject/utils.js'; +import { SUPPORTED_ROOT_IMAGE_FILE_TYPES } from './enums/rootImageMimes.enum.js'; + +/** + * Download root image button. + * @param {string} filename - The name of the downloaded file excluding its file extension. + * @param {RootObject} root - The JSROOT RootObject to render. + * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. + * @param {(visible: boolean) => void} [onVisibilityChange=()=>{}] - Callback for any change in + * visibility of the dropdown. + * @param {string|undefined} [uniqueIdentifier=undefined] - An unique identifier for the dropdown, + * or the `filename` if `undefined`. + * @returns {vnode|undefined} - Download root image button element. + */ +export function downloadRootImageDropdown( + filename, + root, + drawingOptions = [], + onVisibilityChange = () => {}, + uniqueIdentifier = undefined, +) { + if (isObjectOfTypeChecker(root)) { + return undefined; + } + + const deduplicated = Object.entries(SUPPORTED_ROOT_IMAGE_FILE_TYPES).reduce( + (acc, [key, value]) => { + if (!acc.seen.has(value)) { + acc.seen.add(value); + acc.result[key] = value; + } + return acc; + }, + { seen: new Set(), result: {} }, + ).result; + + return DropdownComponent( + h('button.btn.save-root-as-image-button', { title: 'Save root as image' }, imagE()), + Object.keys(deduplicated).map((filetype) => h('button.btn.d-block.w-100', { + key: `${uniqueIdentifier ?? filename}.${filetype}`, + id: `${uniqueIdentifier ?? filename}.${filetype}`, + title: `Save root as image (${filetype})`, + onclick: async (event) => { + try { + event.target.disabled = true; + await downloadRoot(filename, filetype, root, drawingOptions); + } finally { + event.target.disabled = false; + } + }, + }, filetype)), + { onVisibilityChange }, + ); +} diff --git a/QualityControl/public/common/enums/rootImageMimes.enum.js b/QualityControl/public/common/enums/rootImageMimes.enum.js new file mode 100644 index 000000000..8e001ee4a --- /dev/null +++ b/QualityControl/public/common/enums/rootImageMimes.enum.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. + */ + +/** + * Enumeration for allowed `ROOT.makeImage` file extensions to MIME types + * @enum {string} + * @readonly + */ +export const SUPPORTED_ROOT_IMAGE_FILE_TYPES = Object.freeze({ + svg: 'image/svg+xml', + png: 'file/png', + jpg: 'file/jpeg', + jpeg: 'file/jpeg', + webp: 'file/webp', +}); diff --git a/QualityControl/public/common/utils.js b/QualityControl/public/common/utils.js index 85b05dce6..8cfd19104 100644 --- a/QualityControl/public/common/utils.js +++ b/QualityControl/public/common/utils.js @@ -14,21 +14,10 @@ import { isUserRoleSufficient } from '../../../../library/userRole.enum.js'; import { generateDrawingOptionString } from '../../library/qcObject/utils.js'; +import { SUPPORTED_ROOT_IMAGE_FILE_TYPES } from './enums/rootImageMimes.enum.js'; /* global JSROOT */ -/** - * Map of allowed `ROOT.makeImage` file extensions to MIME types - * @type {Map} - */ -const SUPPORTED_ROOT_IMAGE_FILE_TYPES = new Map([ - ['svg', 'image/svg+xml'], - ['png', 'file/png'], - ['jpg', 'file/jpeg'], - ['jpeg', 'file/jpeg'], - ['webp', 'file/webp'], -]); - /** * Generates a new ObjectId * @returns {string} 16 random chars, base 16 @@ -47,6 +36,32 @@ export function clone(obj) { return JSON.parse(JSON.stringify(obj)); } +// Map storing timers per key +const simpleDebouncerTimers = new Map(); + +/** + * Produces a debounced function that uses a key to manage timers. + * Each key has its own debounce timer, so calls with different keys + * are debounced independently. + * @template PrimitiveKey extends unknown + * @param {PrimitiveKey} key - The key for this call. + * @param {(key: PrimitiveKey) => void} fn - Function to debounce. + * @param {number} time - Debounce delay in milliseconds. + * @returns {undefined} + */ +export function simpleDebouncer(key, fn, time) { + if (simpleDebouncerTimers.has(key)) { + clearTimeout(simpleDebouncerTimers.get(key)); + } + + const timerId = setTimeout(() => { + fn(key); + simpleDebouncerTimers.delete(key); + }, time); + + simpleDebouncerTimers.set(key, timerId); +} + /** * Produces a lambda function waiting `time` ms before calling fn. * No matter how many calls are done to lambda, the last call is the waiting starting point. @@ -178,14 +193,6 @@ export const camelToTitleCase = (text) => { return titleCase; }; -/** - * Get the file extension from a filename - * @param {string} filename - The file name including the file extension - * @returns {string} - the file extension - */ -export const getFileExtensionFromName = (filename) => - filename.substring(filename.lastIndexOf('.') + 1).toLowerCase().trim(); - /** * Helper to trigger a download for a file * @param {string} url - The URL to the file source @@ -216,14 +223,14 @@ export const downloadFile = (file, filename) => { /** * Generates a rasterized image of a JSROOT RootObject and triggers download. - * @param {string} filename - The name of the downloaded file including its extension. + * @param {string} filename - The name of the downloaded file excluding the file extension. + * @param {string} filetype - The file extension of the downloaded file. * @param {RootObject} root - The JSROOT RootObject to render. * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. * @returns {undefined} */ -export const downloadRoot = async (filename, root, drawingOptions = []) => { - const filetype = getFileExtensionFromName(filename); - const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES.get(filetype); +export const downloadRoot = async (filename, filetype, root, drawingOptions = []) => { + const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES[filetype]; if (!mime) { throw new Error(`The file extension (${filetype}) is not supported`); } @@ -235,7 +242,7 @@ export const downloadRoot = async (filename, root, drawingOptions = []) => { as_buffer: true, }); const blob = new Blob([image], { type: mime }); - downloadFile(blob, filename); + downloadFile(blob, `${filename}.${filetype}`); }; /** diff --git a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js index c4db81c7e..2707d866c 100644 --- a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js +++ b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js @@ -16,7 +16,7 @@ import { downloadButton } from '../../../common/downloadButton.js'; import { isOnLeftSideOfViewport } from '../../../common/utils.js'; import { defaultRowAttributes, qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js'; import { h, iconResizeBoth, info } from '/js/src/index.js'; -import { downloadRootImageButton } from '../../../common/downloadRootImageButton.js'; +import { downloadRootImageDropdown } from '../../../common/downloadRootImageDropdown.js'; /** * Builds 2 actionable buttons which are to be placed on top of a JSROOT plot @@ -40,8 +40,9 @@ export const objectInfoResizePanel = (model, tabObject) => { const toUseDrawingOptions = Array.from(new Set(ignoreDefaults ? drawingOptions : [...drawingOptions, ...displayHints, ...drawOptions])); + const visibility = object.getExtraObjectData(tabObject.id)?.saveImageDropdownOpen ? 'visible' : 'hidden'; return h('.text-right.resize-element.item-action-row.flex-row.g1', { - style: 'visibility: hidden; padding: .25rem .25rem 0rem .25rem;', + style: `visibility: ${visibility}; padding: .25rem .25rem 0rem .25rem;`, }, [ h('.dropdown', { class: isSelectedOpen ? 'dropdown-open' : '', @@ -69,10 +70,14 @@ export const objectInfoResizePanel = (model, tabObject) => { ), ]), objectRemoteData.isSuccess() && [ - downloadRootImageButton( - `${objectRemoteData.payload.name}.png`, + downloadRootImageDropdown( + objectRemoteData.payload.name, objectRemoteData.payload.qcObject.root, toUseDrawingOptions, + (isDropdownOpen) => { + object.appendExtraObjectData(tabObject.id, { saveImageDropdownOpen: isDropdownOpen }); + }, + tabObject.id, ), downloadButton({ href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id), diff --git a/QualityControl/public/object/QCObject.js b/QualityControl/public/object/QCObject.js index 840215994..4d9d8c4c3 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -14,7 +14,7 @@ import { RemoteData, iconArrowTop, BrowserStorage } from '/js/src/index.js'; import ObjectTree from './ObjectTree.class.js'; -import { prettyFormatDate, setBrowserTabTitle } from './../common/utils.js'; +import { simpleDebouncer, prettyFormatDate, setBrowserTabTitle } from './../common/utils.js'; import { isObjectOfTypeChecker } from './../library/qcObject/utils.js'; import { BaseViewModel } from '../common/abstracts/BaseViewModel.js'; import { StorageKeysEnum } from '../common/enums/storageKeys.enum.js'; @@ -39,6 +39,7 @@ export default class QCObject extends BaseViewModel { this.selected = null; // Object - { name; createTime; lastModified; } this.selectedOpen = false; this.objects = {}; // ObjectName -> RemoteData.payload -> plot + this._extraObjectData = {}; this.searchInput = ''; // String - content of input search this.searchResult = []; // Array - result list of search @@ -314,6 +315,7 @@ export default class QCObject extends BaseViewModel { async loadObjects(objectsName) { this.objectsRemote = RemoteData.loading(); this.objects = {}; // Remove any in-memory loaded objects + this._extraObjectData = {}; // Remove any in-memory extra object data this.model.services.object.objectsLoadedMap = {}; // TODO not here this.notify(); if (!objectsName || !objectsName.length) { @@ -653,4 +655,49 @@ export default class QCObject extends BaseViewModel { } this.loadList(); } + + /** + * Returns the extra data associated with a given object name. + * @param {string} objectName The name of the object whose extra data should be retrieved. + * @returns {object | undefined} The extra data associated with the given object name, or undefined if none exists. + */ + getExtraObjectData(objectName) { + return this._extraObjectData[objectName]; + } + + /** + * Appends extra data to an existing object entry. + * Existing keys are preserved unless overwritten by the provided data. If no data exists, a new entry is created. + * @param {string} objectName The name of the object to which extra data should be appended. + * @param {object} data The extra data to merge into the existing object data. + * @returns {undefined} + */ + appendExtraObjectData(objectName, data) { + this._extraObjectData[objectName] = { ...this._extraObjectData[objectName] ?? {}, ...data }; + // debounce notify by 1ms + simpleDebouncer('QCObject.appendExtraObjectData', () => this.notify(), 1); + } + + /** + * Sets (overwrites) the extra data for a given object name. + * Any previously stored data for the object is replaced entirely. + * @param {string} objectName The name of the object whose extra data should be set. + * @param {object | undefined} data The extra data to associate with the object. + * @returns {undefined} + */ + setExtraObjectData(objectName, data) { + this._extraObjectData[objectName] = data; + // debounce notify by 1ms + simpleDebouncer('QCObject.setExtraObjectData', () => this.notify(), 1); + } + + /** + * Clears all stored extra object data. + * After calling this method, no extra data will be associated with any object name. + * @returns {undefined} + */ + clearAllExtraObjectData() { + this._extraObjectData = {}; + this.notify(); + } } diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 1dd7f1491..1d1fa02a3 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -20,7 +20,7 @@ import virtualTable from './virtualTable.js'; import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js'; import { downloadButton } from '../common/downloadButton.js'; import { resizableDivider } from '../common/resizableDivider.js'; -import { downloadRootImageButton } from '../common/downloadRootImageButton.js'; +import { downloadRootImageDropdown } from '../common/downloadRootImageDropdown.js'; /** * Shows a page to explore though a tree of objects with a preview on the right if clicked @@ -104,7 +104,7 @@ const drawPlot = (model, object) => { : `?page=objectView&objectName=${name}`; return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ h('.item-action-row.flex-row.g1.p1', [ - downloadRootImageButton(`${name}.png`, root, ['stat']), + downloadRootImageDropdown(name, root, ['stat']), downloadButton({ href: model.objectViewModel.getDownloadQcdbObjectUrl(id), title: 'Download root object', diff --git a/QualityControl/public/pages/objectView/ObjectViewPage.js b/QualityControl/public/pages/objectView/ObjectViewPage.js index a7576a438..d7e076c9f 100644 --- a/QualityControl/public/pages/objectView/ObjectViewPage.js +++ b/QualityControl/public/pages/objectView/ObjectViewPage.js @@ -20,7 +20,7 @@ import { dateSelector } from '../../common/object/dateSelector.js'; import { defaultRowAttributes, qcObjectInfoPanel } from '../../common/object/objectInfoCard.js'; import { downloadButton } from '../../common/downloadButton.js'; import { visibilityToggleButton } from '../../common/visibilityButton.js'; -import { downloadRootImageButton } from '../../common/downloadRootImageButton.js'; +import { downloadRootImageDropdown } from '../../common/downloadRootImageDropdown.js'; /** * Shows a page to view an object on the whole page @@ -66,7 +66,7 @@ const objectPlotAndInfo = (objectViewModel) => ), ), h('.item-action-row.flex-row.g1.p2', [ - downloadRootImageButton(`${qcObject.name}.png`, qcObject.qcObject.root, drawingOptions), + downloadRootImageDropdown(qcObject.name, qcObject.qcObject.root, drawingOptions), downloadButton({ href: objectViewModel.getDownloadQcdbObjectUrl(qcObject.id), title: 'Download root object', diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 33961c003..355f344de 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -47,12 +47,12 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => ); await testParent.test( - 'should have a correctly made download root as image button', + 'should have a correctly made save root as image button', { timeout }, async () => { - const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + const exists = await page.evaluate(() => document.querySelector('.save-root-as-image-button') !== null); - ok(exists, 'Expected ROOT image download button to exist'); + ok(exists, 'Expected ROOT image save button to exist'); }, ); diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 31284c3e5..e8dcfae94 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -89,12 +89,12 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) ); await testParent.test( - 'should have a correctly made download root as image button', + 'should have a correctly made save root as image button', { timeout }, async () => { - const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + const exists = await page.evaluate(() => document.querySelector('.save-root-as-image-button') !== null); - ok(exists, 'Expected ROOT image download button to exist'); + ok(exists, 'Expected ROOT image save button to exist'); }, ); 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 d0e0d54f3..bed3c5973 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 @@ -11,7 +11,7 @@ * or submit itself to any jurisdiction. */ -import {strictEqual, deepStrictEqual, match, ok} from 'node:assert'; +import { strictEqual, deepStrictEqual, match, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; import { @@ -20,6 +20,7 @@ import { removeLocalStorage, setLocalStorageAsJson, } from '../../testUtils/localStorage.js'; +import { SUPPORTED_ROOT_IMAGE_FILE_TYPES } from '../../../public/common/enums/rootImageMimes.enum.js'; const OBJECT_VIEW_PAGE_PARAM = '?page=objectView&objectId=123456'; @@ -103,12 +104,46 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t ); await testParent.test( - 'should have a correctly made download root as image button', + 'should have a correctly made save root as image button', { timeout }, async () => { - const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + const exists = await page.evaluate(() => document.querySelector('.save-root-as-image-button') !== null); - ok(exists, 'Expected ROOT image download button to exist'); + ok(exists, 'Expected ROOT image save button to exist'); + }, + ); + + await testParent.test( + 'save root as image dropdown should have the correct filetype options', + { timeout }, + async () => { + const FILENAME = 'qc/test/object/1'; + + await page.locator('.save-root-as-image-button').click(); + await page.waitForSelector('.popover', { + visible: true, + timeout: 1000, + }); + + const expectedExtensionTypes = Object.keys(Object.entries(SUPPORTED_ROOT_IMAGE_FILE_TYPES).reduce( + (acc, [key, value]) => { + if (!acc.seen.has(value)) { + acc.seen.add(value); + acc.result[key] = value; + } + return acc; + }, + { seen: new Set(), result: {} }, + ).result); + + const testedOptions = await page.evaluate(() => + Array.from(document.querySelectorAll('.popover .dropdown > button')).map((buttonElement) => buttonElement.id)); + const expectedOptions = expectedExtensionTypes.map((filetype) => `${FILENAME}.${filetype}`); + deepStrictEqual( + testedOptions, + expectedOptions, + `Save options ${JSON.stringify(testedOptions)} should be ${JSON.stringify(expectedOptions)}`, + ); }, ); diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 75514eb4b..30c61ed03 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -107,7 +107,7 @@ const QC_DRAWING_OPTIONS_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 13; const LAYOUT_LIST_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 17; const OBJECT_TREE_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 20; const OBJECT_VIEW_FROM_OBJECT_TREE_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 5; -const OBJECT_VIEW_FROM_LAYOUT_SHOW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 17; +const OBJECT_VIEW_FROM_LAYOUT_SHOW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 18; const LAYOUT_SHOW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 23; const ABOUT_VIEW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 4; const FILTER_TEST_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 26;