diff --git a/QualityControl/public/common/downloadRootImageButton.js b/QualityControl/public/common/downloadRootImageButton.js new file mode 100644 index 000000000..76f69d313 --- /dev/null +++ b/QualityControl/public/common/downloadRootImageButton.js @@ -0,0 +1,39 @@ +/** + * @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/utils.js b/QualityControl/public/common/utils.js index 14891f933..0a50495d1 100644 --- a/QualityControl/public/common/utils.js +++ b/QualityControl/public/common/utils.js @@ -13,6 +13,21 @@ */ import { isUserRoleSufficient } from '../../../../library/userRole.enum.js'; +import { generateDrawingOptionString } from '../../library/qcObject/utils.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 @@ -171,3 +186,60 @@ export const camelToTitleCase = (text) => { const titleCase = spaced.charAt(0).toUpperCase() + spaced.slice(1); 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 + * @param {string} filename - The name of the file including the file extension + */ +export const triggerDownload = (url, filename) => { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); +}; + +/** + * Downloads a file + * @param {Blob|MediaSource} file - The file to download + * @param {string} filename - The name of the file including the file extension + */ +export const downloadFile = (file, filename) => { + const url = URL.createObjectURL(file); + try { + triggerDownload(url, filename); + } finally { + URL.revokeObjectURL(url); + } +}; + +/** + * Generates a rasterized image of a JSROOT RootObject and triggers download. + * @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. + */ +export const downloadRoot = async (filename, root, drawingOptions = []) => { + const filetype = getFileExtensionFromName(filename); + const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES.get(filetype); + if (!mime) { + throw new Error(`The file extension (${filetype}) is not supported`); + } + + const image = await JSROOT.makeImage({ + object: root, + option: generateDrawingOptionString(root, drawingOptions), + format: filetype, + as_buffer: true, + }); + const blob = new Blob([image], { type: mime }); + downloadFile(blob, filename); +}; diff --git a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js index 40e08d452..e321bc03c 100644 --- a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js +++ b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js @@ -15,16 +15,17 @@ import { downloadButton } from '../../../common/downloadButton.js'; import { defaultRowAttributes, qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js'; import { h, iconResizeBoth, info } from '/js/src/index.js'; +import { downloadRootImageButton } from '../../../common/downloadRootImageButton.js'; /** * Builds 2 actionable buttons which are to be placed on top of a JSROOT plot * Buttons shall appear on hover of the plot * @param {Model} model - root model of the application - * @param {object} tabObject - tab dto representation + * @param {TabObject} tabObject - tab dto representation * @returns {vnode} - virtual node element */ export const objectInfoResizePanel = (model, tabObject) => { - const { name } = tabObject; + const { name, options: drawingOptions = [], ignoreDefaults } = tabObject; const { filterModel, router, object, services } = model; const isSelectedOpen = object.selectedOpen; const objectRemoteData = services.object.objectsLoadedMap[name]; @@ -34,6 +35,10 @@ export const objectInfoResizePanel = (model, tabObject) => { .forEach(([key, value]) => { uri += `&${key}=${encodeURI(value)}`; }); + const { displayHints = [], drawOptions = [] } = objectRemoteData?.payload ?? {}; + const toUseDrawingOptions = Array.from(new Set(ignoreDefaults + ? drawingOptions + : [...drawingOptions, ...displayHints, ...drawOptions])); return h('.text-right.resize-element.item-action-row.flex-row.g1', { style: 'display: none; padding: .25rem .25rem 0rem .25rem;', }, [ @@ -51,12 +56,18 @@ export const objectInfoResizePanel = (model, tabObject) => { h('.p1', qcObjectInfoPanel(objectRemoteData.payload, {}, defaultRowAttributes(model.notification))), ), ]), - objectRemoteData.isSuccess() && - downloadButton({ - href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id), - title: 'Download object', - id: `download-button-${objectRemoteData.payload.id}`, - }), + objectRemoteData.isSuccess() && [ + downloadRootImageButton( + `${objectRemoteData.payload.name}.png`, + objectRemoteData.payload.qcObject.root, + toUseDrawingOptions, + ), + downloadButton({ + href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id), + title: 'Download root object', + id: `download-button-${objectRemoteData.payload.id}`, + }), + ], h('a.btn', { title: 'Open object plot in full screen', href: uri, diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index f53b09d38..9720f0e8a 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -20,6 +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'; /** * Shows a page to explore though a tree of objects with a preview on the right if clicked @@ -93,15 +94,17 @@ function objectPanel(model) { * @returns {vnode} - virtual node element */ const drawPlot = (model, object) => { - const { name, validFrom, id } = object; + const { name, qcObject, validFrom, id } = object; + const { root } = qcObject; const href = validFrom ? `?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}` : `?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']), downloadButton({ - href: model.objectViewModel.getDownloadQcdbObjectUrl(object.id), - title: 'Download object', + href: model.objectViewModel.getDownloadQcdbObjectUrl(id), + title: 'Download root object', }), h( 'a.btn#fullscreen-button', diff --git a/QualityControl/public/pages/objectView/ObjectViewPage.js b/QualityControl/public/pages/objectView/ObjectViewPage.js index b296c716c..a7576a438 100644 --- a/QualityControl/public/pages/objectView/ObjectViewPage.js +++ b/QualityControl/public/pages/objectView/ObjectViewPage.js @@ -20,6 +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'; /** * Shows a page to view an object on the whole page @@ -65,9 +66,10 @@ const objectPlotAndInfo = (objectViewModel) => ), ), h('.item-action-row.flex-row.g1.p2', [ + downloadRootImageButton(`${qcObject.name}.png`, qcObject.qcObject.root, drawingOptions), downloadButton({ href: objectViewModel.getDownloadQcdbObjectUrl(qcObject.id), - title: 'Download object', + title: 'Download root object', }), visibilityToggleButton( { diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index 8308a6013..3229e8659 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -47,6 +47,14 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) strictEqual(downloadCount, 0); }); + await testParent.test('should not show a download root as image button when there is no data', async () => { + await page.goto(`${url}?page=layoutShow&layoutId=671b8c22402408122e2f20dd&tab=main`, { waitUntil: 'networkidle0' }); + + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + + strictEqual(exists, false); + }); + await testParent.test('should successfully load layoutList page "/"', { timeout }, async () => { await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 1d96724a3..9f18e3580 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -45,6 +45,16 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => }, ); + await testParent.test( + 'should have a correctly made download root as image button', + { timeout }, + async () => { + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + + ok(exists, 'Expected ROOT image download button to exist'); + }, + ); + await testParent.test('should remove query param only if option is invalid for any filter', { timeout }, async () => { const baseParams = `?page=layoutShow&layoutId=${LAYOUT_ID}&tab=main`; diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 45788051d..ae1bafce0 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -65,7 +65,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test( - 'should have a correctly made download button', + 'should have a correctly made download root as object button', { timeout }, async () => { const objectId = '016fa8ac-f3b6-11ec-b9a9-c0a80209250c'; @@ -81,6 +81,16 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }, ); + await testParent.test( + 'should have a correctly made download root as image button', + { timeout }, + async () => { + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + + ok(exists, 'Expected ROOT image download button to exist'); + }, + ); + await testParent.test( 'should have default panel width of 50% when width is null in localStorage', { timeout }, 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 07b65f280..d0e0d54f3 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 } 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 { @@ -102,6 +102,16 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t }, ); + await testParent.test( + 'should have a correctly made download root as image button', + { timeout }, + async () => { + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + + ok(exists, 'Expected ROOT image download button to exist'); + }, + ); + await testParent.test( 'should take back the user to page=layoutShow when clicking "Back to layout"', { timeout },