Skip to content

Commit cb1f4da

Browse files
authored
[OGUI-1822] Download SVG/PNG/JPEG from QCG (#3238)
- Add a `downloadRoot` utility function to download root objects as a `SVG`, `PNG`, `JPEG` or `WEBP`. - Add a 'download root as image' button next to the normal root object download buttons
1 parent ab9f027 commit cb1f4da

File tree

9 files changed

+189
-21
lines changed

9 files changed

+189
-21
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
4+
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
5+
* All rights not expressly granted are reserved.
6+
*
7+
* This software is distributed under the terms of the GNU General Public
8+
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
9+
*
10+
* In applying this license CERN does not waive the privileges and immunities
11+
* granted to it by virtue of its status as an Intergovernmental Organization
12+
* or submit itself to any jurisdiction.
13+
*/
14+
15+
import { h, imagE } from '/js/src/index.js';
16+
import { downloadRoot, getFileExtensionFromName } from './utils.js';
17+
import { isObjectOfTypeChecker } from '../../library/qcObject/utils.js';
18+
19+
/**
20+
* Download root image button.
21+
* @param {string} filename - The name of the downloaded file including its extension.
22+
* @param {RootObject} root - The JSROOT RootObject to render.
23+
* @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options.
24+
* @returns {vnode} - Download root image button element.
25+
*/
26+
export function downloadRootImageButton(filename, root, drawingOptions = []) {
27+
const filetype = getFileExtensionFromName(filename);
28+
return !isObjectOfTypeChecker(root) && h(`button.btn.download-root-image-${filetype}-button`, {
29+
title: `Download as ${filetype.toUpperCase()}`,
30+
onclick: async (event) => {
31+
try {
32+
event.target.disabled = true;
33+
await downloadRoot(filename, root, drawingOptions);
34+
} finally {
35+
event.target.disabled = false;
36+
}
37+
},
38+
}, imagE());
39+
}

QualityControl/public/common/utils.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@
1313
*/
1414

1515
import { isUserRoleSufficient } from '../../../../library/userRole.enum.js';
16+
import { generateDrawingOptionString } from '../../library/qcObject/utils.js';
17+
18+
/* global JSROOT */
19+
20+
/**
21+
* Map of allowed `ROOT.makeImage` file extensions to MIME types
22+
* @type {Map<string, string>}
23+
*/
24+
const SUPPORTED_ROOT_IMAGE_FILE_TYPES = new Map([
25+
['svg', 'image/svg+xml'],
26+
['png', 'file/png'],
27+
['jpg', 'file/jpeg'],
28+
['jpeg', 'file/jpeg'],
29+
['webp', 'file/webp'],
30+
]);
1631

1732
/**
1833
* Generates a new ObjectId
@@ -163,6 +178,66 @@ export const camelToTitleCase = (text) => {
163178
return titleCase;
164179
};
165180

181+
/**
182+
* Get the file extension from a filename
183+
* @param {string} filename - The file name including the file extension
184+
* @returns {string} - the file extension
185+
*/
186+
export const getFileExtensionFromName = (filename) =>
187+
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase().trim();
188+
189+
/**
190+
* Helper to trigger a download for a file
191+
* @param {string} url - The URL to the file source
192+
* @param {string} filename - The name of the file including the file extension
193+
* @returns {undefined}
194+
*/
195+
export const triggerDownload = (url, filename) => {
196+
const link = document.createElement('a');
197+
link.href = url;
198+
link.download = filename;
199+
link.click();
200+
};
201+
202+
/**
203+
* Downloads a file
204+
* @param {Blob|MediaSource} file - The file to download
205+
* @param {string} filename - The name of the file including the file extension
206+
* @returns {undefined}
207+
*/
208+
export const downloadFile = (file, filename) => {
209+
const url = URL.createObjectURL(file);
210+
try {
211+
triggerDownload(url, filename);
212+
} finally {
213+
URL.revokeObjectURL(url);
214+
}
215+
};
216+
217+
/**
218+
* Generates a rasterized image of a JSROOT RootObject and triggers download.
219+
* @param {string} filename - The name of the downloaded file including its extension.
220+
* @param {RootObject} root - The JSROOT RootObject to render.
221+
* @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options.
222+
* @returns {undefined}
223+
*/
224+
export const downloadRoot = async (filename, root, drawingOptions = []) => {
225+
const filetype = getFileExtensionFromName(filename);
226+
const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES.get(filetype);
227+
if (!mime) {
228+
throw new Error(`The file extension (${filetype}) is not supported`);
229+
}
230+
231+
const image = await JSROOT.makeImage({
232+
object: root,
233+
option: generateDrawingOptionString(root, drawingOptions),
234+
format: filetype,
235+
as_buffer: true,
236+
});
237+
const blob = new Blob([image], { type: mime });
238+
downloadFile(blob, filename);
239+
};
240+
166241
/**
167242
* Determines whether the element is positioned on the left half of the viewport.
168243
* This is used to decide which way a dropdown should anchor to stay within view.

QualityControl/public/layout/view/panels/objectInfoResizePanel.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,17 @@ import { downloadButton } from '../../../common/downloadButton.js';
1616
import { isOnLeftSideOfViewport } from '../../../common/utils.js';
1717
import { defaultRowAttributes, qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js';
1818
import { h, iconResizeBoth, info } from '/js/src/index.js';
19+
import { downloadRootImageButton } from '../../../common/downloadRootImageButton.js';
1920

2021
/**
2122
* Builds 2 actionable buttons which are to be placed on top of a JSROOT plot
2223
* Buttons shall appear on hover of the plot
2324
* @param {Model} model - root model of the application
24-
* @param {object} tabObject - tab dto representation
25+
* @param {TabObject} tabObject - tab dto representation
2526
* @returns {vnode} - virtual node element
2627
*/
2728
export const objectInfoResizePanel = (model, tabObject) => {
28-
const { name } = tabObject;
29+
const { name, options: drawingOptions = [], ignoreDefaults } = tabObject;
2930
const { filterModel, router, object, services } = model;
3031
const isSelectedOpen = object.selectedOpen;
3132
const objectRemoteData = services.object.objectsLoadedMap[name];
@@ -35,6 +36,10 @@ export const objectInfoResizePanel = (model, tabObject) => {
3536
.forEach(([key, value]) => {
3637
uri += `&${key}=${encodeURI(value)}`;
3738
});
39+
const { displayHints = [], drawOptions = [] } = objectRemoteData?.payload ?? {};
40+
const toUseDrawingOptions = Array.from(new Set(ignoreDefaults
41+
? drawingOptions
42+
: [...drawingOptions, ...displayHints, ...drawOptions]));
3843
return h('.text-right.resize-element.item-action-row.flex-row.g1', {
3944
style: 'visibility: hidden; padding: .25rem .25rem 0rem .25rem;',
4045
}, [
@@ -63,12 +68,18 @@ export const objectInfoResizePanel = (model, tabObject) => {
6368
h('.p1', qcObjectInfoPanel(objectRemoteData.payload, {}, defaultRowAttributes(model.notification))),
6469
),
6570
]),
66-
objectRemoteData.isSuccess() &&
67-
downloadButton({
68-
href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id),
69-
title: 'Download object',
70-
id: `download-button-${objectRemoteData.payload.id}`,
71-
}),
71+
objectRemoteData.isSuccess() && [
72+
downloadRootImageButton(
73+
`${objectRemoteData.payload.name}.png`,
74+
objectRemoteData.payload.qcObject.root,
75+
toUseDrawingOptions,
76+
),
77+
downloadButton({
78+
href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id),
79+
title: 'Download root object',
80+
id: `download-button-${objectRemoteData.payload.id}`,
81+
}),
82+
],
7283
h('a.btn', {
7384
title: 'Open object plot in full screen',
7485
href: uri,

QualityControl/public/object/objectTreePage.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import virtualTable from './virtualTable.js';
2020
import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js';
2121
import { downloadButton } from '../common/downloadButton.js';
2222
import { resizableDivider } from '../common/resizableDivider.js';
23+
import { downloadRootImageButton } from '../common/downloadRootImageButton.js';
2324

2425
/**
2526
* 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) {
9394
* @returns {vnode} - virtual node element
9495
*/
9596
const drawPlot = (model, object) => {
96-
const { name, validFrom, id } = object;
97+
const { name, qcObject, validFrom, id } = object;
98+
const { root } = qcObject;
9799
const href = validFrom ?
98100
`?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}`
99101
: `?page=objectView&objectName=${name}`;
100102
return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [
101103
h('.item-action-row.flex-row.g1.p1', [
104+
downloadRootImageButton(`${name}.png`, root, ['stat']),
102105
downloadButton({
103-
href: model.objectViewModel.getDownloadQcdbObjectUrl(object.id),
104-
title: 'Download object',
106+
href: model.objectViewModel.getDownloadQcdbObjectUrl(id),
107+
title: 'Download root object',
105108
}),
106109
h(
107110
'a.btn#fullscreen-button',

QualityControl/public/pages/objectView/ObjectViewPage.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { dateSelector } from '../../common/object/dateSelector.js';
2020
import { defaultRowAttributes, qcObjectInfoPanel } from '../../common/object/objectInfoCard.js';
2121
import { downloadButton } from '../../common/downloadButton.js';
2222
import { visibilityToggleButton } from '../../common/visibilityButton.js';
23+
import { downloadRootImageButton } from '../../common/downloadRootImageButton.js';
2324

2425
/**
2526
* Shows a page to view an object on the whole page
@@ -65,9 +66,10 @@ const objectPlotAndInfo = (objectViewModel) =>
6566
),
6667
),
6768
h('.item-action-row.flex-row.g1.p2', [
69+
downloadRootImageButton(`${qcObject.name}.png`, qcObject.qcObject.root, drawingOptions),
6870
downloadButton({
6971
href: objectViewModel.getDownloadQcdbObjectUrl(qcObject.id),
70-
title: 'Download object',
72+
title: 'Download root object',
7173
}),
7274
visibilityToggleButton(
7375
{

QualityControl/test/public/pages/layout-list.test.js

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,6 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent)
3939

4040
const filterPath = 'section > div > div:nth-child(1) > input';
4141
const filterObjectPath = 'input.form-control:nth-child(1)';
42-
await testParent.test('should not show a download button when there is no data', async () => {
43-
await page.goto(`${url}?page=layoutShow&layoutId=671b8c22402408122e2f20dd&tab=main`, { waitUntil: 'networkidle0' });
44-
45-
const downloadCount = await page.evaluate(() => document.querySelectorAll('#download-button').length);
46-
47-
strictEqual(downloadCount, 0);
48-
});
4942

5043
await testParent.test('should successfully load layoutList page "/"', { timeout }, async () => {
5144
await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' });

QualityControl/test/public/pages/layout-show.test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) =>
4646
},
4747
);
4848

49+
await testParent.test(
50+
'should have a correctly made download root as image button',
51+
{ timeout },
52+
async () => {
53+
const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null);
54+
55+
ok(exists, 'Expected ROOT image download button to exist');
56+
},
57+
);
58+
4959
await testParent.test('should remove query param only if option is invalid for any filter', { timeout }, async () => {
5060
const baseParams = `?page=layoutShow&layoutId=${LAYOUT_ID}&tab=main`;
5161

@@ -513,6 +523,21 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) =>
513523
ok(originalSidebarName !== updatedSidebarName, 'Sidebar name should have changed from original');
514524
},
515525
);
526+
527+
await testParent.test('should not show a download button when there is no data', async () => {
528+
await page.goto(`${url}?page=layoutShow&layoutId=671b8c22402408122e2f20dd&tab=main`, { waitUntil: 'networkidle0' });
529+
530+
const downloadCount = await page.evaluate(() => document.querySelectorAll('#download-button').length);
531+
532+
strictEqual(downloadCount, 0);
533+
});
534+
535+
await testParent.test('should not show a download root as image button when there is no data', async () => {
536+
// layout id 671b8c22402408122e2f20dd has no data
537+
const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null);
538+
539+
strictEqual(exists, false);
540+
});
516541
};
517542

518543
const checkInvalidJSON = async (page, mockedJSON, errorMessage) => {

QualityControl/test/public/pages/object-tree.test.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent)
7272
});
7373

7474
await testParent.test(
75-
'should have a correctly made download button',
75+
'should have a correctly made download root as object button',
7676
{ timeout },
7777
async () => {
7878
const objectId = '016fa8ac-f3b6-11ec-b9a9-c0a80209250c';
@@ -88,6 +88,16 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent)
8888
},
8989
);
9090

91+
await testParent.test(
92+
'should have a correctly made download root as image button',
93+
{ timeout },
94+
async () => {
95+
const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null);
96+
97+
ok(exists, 'Expected ROOT image download button to exist');
98+
},
99+
);
100+
91101
await testParent.test(
92102
'should have default panel width of 50% when width is null in localStorage',
93103
{ timeout },

QualityControl/test/public/pages/object-view-from-layout-show.test.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* or submit itself to any jurisdiction.
1212
*/
1313

14-
import { strictEqual, deepStrictEqual, match } from 'node:assert';
14+
import {strictEqual, deepStrictEqual, match, ok} from 'node:assert';
1515
import { delay } from '../../testUtils/delay.js';
1616
import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js';
1717
import {
@@ -102,6 +102,16 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t
102102
},
103103
);
104104

105+
await testParent.test(
106+
'should have a correctly made download root as image button',
107+
{ timeout },
108+
async () => {
109+
const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null);
110+
111+
ok(exists, 'Expected ROOT image download button to exist');
112+
},
113+
);
114+
105115
await testParent.test(
106116
'should take back the user to page=layoutShow when clicking "Back to layout"',
107117
{ timeout },

0 commit comments

Comments
 (0)