Skip to content

Commit ea83779

Browse files
authored
[OGUI-1816] Object Details Panel Improvements and Copy of values (#3181)
The panel displaying the details of the object such as: - each value should on hover: - display the cursor mouse pointer to inform the user that clicking will trigger an action. A "title" property should be added which will be displayed on hover with text Copy! - onclick - the value should be copied - on double click - native browser behaviour should be left - each key should have a label displayed and not the key - id and tag to be combined in something like `ID (etag)` - the following values and or keys should popout - run number - run type - path - qc version
1 parent 507d47e commit ea83779

File tree

10 files changed

+237
-39
lines changed

10 files changed

+237
-39
lines changed

QualityControl/public/Model.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ export default class Model extends Observable {
319319
* Method to check if connection is secure to enable certain improvements
320320
* e.g navigator.clipboard, notifications, service workers
321321
* @returns {boolean} - whether window is in secure context
322+
* @deprecated use `isContextSecure` from `public/common/utils.js`
322323
*/
323324
isContextSecure() {
324325
return window.isSecureContext;

QualityControl/public/app.css

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
44
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
55
* All rights not expressly granted are reserved.
6-
*
6+
*
77
* This software is distributed under the terms of the GNU General Public
88
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
9-
*
9+
*
1010
* In applying this license CERN does not waive the privileges and immunities
1111
* granted to it by virtue of its status as an Intergovernmental Organization
1212
* or submit itself to any jurisdiction.
@@ -47,20 +47,20 @@
4747
flex-direction: column;
4848
}
4949

50-
.cardHeader {
50+
.cardHeader {
5151
padding-left: var(--space-s);
5252
}
5353

54-
.cardHeader > h5 {
54+
.cardHeader > h5 {
5555
align-content: center;
5656
}
5757

5858
.cardHeaderButton {
59-
min-width: 145px;
59+
min-width: 145px;
6060
}
6161

62-
.cardBody {
63-
border: solid 1px var(--color-primary);
62+
.cardBody {
63+
border: solid 1px var(--color-primary);
6464
border-top: white;
6565
border-radius: 0 0 .5rem .5rem;
6666
height: 100%;
@@ -136,3 +136,18 @@
136136
color: var(--color-success);
137137
}
138138
}
139+
140+
.info-row {
141+
&.highlighted {
142+
background-color: var(--color-light-blue);
143+
font-weight: 500;
144+
}
145+
146+
&>div:hover {
147+
font-weight: 700;
148+
}
149+
}
150+
151+
.cursor-pointer {
152+
cursor: pointer;
153+
}

QualityControl/public/common/object/objectInfoCard.js

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,48 +13,130 @@
1313
*/
1414

1515
import { h } from '/js/src/index.js';
16-
import { prettyFormatDate } from './../utils.js';
16+
import { camelToTitleCase, copyToClipboard, isContextSecure, prettyFormatDate } from './../utils.js';
17+
18+
const SPECIFIC_KEY_LABELS = {
19+
id: 'ID (etag)',
20+
};
1721

1822
const DATE_FIELDS = ['validFrom', 'validUntil', 'createdAt', 'lastModified'];
19-
const TO_REMOVE_FIELDS = ['qcObject', 'versions', 'name', 'location'];
23+
const TO_REMOVE_FIELDS = ['etag', 'qcObject', 'versions', 'name', 'location'];
24+
const HIGHLIGHTED_FIELDS = ['runNumber', 'runType', 'path', 'qcVersion'];
25+
26+
const KEY_TO_RENDER_FIRST = 'path';
2027

2128
/**
2229
* Builds a panel with information of the object; Fields are parsed according to their category
2330
* @param {QCObjectDTO} qcObject - QC object with its associated details
2431
* @param {object} style - properties of the vnode
32+
* @param {function(Notification): function(string, string): object} rowAttributes -
33+
* An optional curried function that returns the VNode attribute builder.
34+
* Use {@link defaultRowAttributes} exported from this module, supplying the Notification API.
2535
* @returns {vnode} - panel with information about the object
36+
* @example
37+
* ```
38+
* qcObjectInfoPanel(qcObject, {}, defaultRowAttributes(model.notification))
39+
* ```
2640
*/
27-
export const qcObjectInfoPanel = (qcObject, style = {}) =>
28-
h('.flex-column.scroll-y', { style }, [
29-
Object.keys(qcObject)
30-
.filter((key) => !TO_REMOVE_FIELDS.includes(key))
31-
.map((key) => infoRow(key, qcObject[key])),
41+
export const qcObjectInfoPanel = (qcObject, style = {}, rowAttributes = () => undefined) =>
42+
h('.flex-column.scroll-y#qcObjectInfoPanel', { style }, [
43+
[
44+
KEY_TO_RENDER_FIRST,
45+
...Object.keys(qcObject)
46+
.filter((key) =>
47+
key !== KEY_TO_RENDER_FIRST && !TO_REMOVE_FIELDS.includes(key)),
48+
]
49+
.map((key) => infoRow(key, qcObject[key], rowAttributes)),
3250
]);
3351

3452
/**
3553
* Builds a raw with the key and value information parsed based on their type
3654
* @param {string} key - key of the object info
3755
* @param {string|number|object|undefined} value - value of the object info
56+
* @param {function(key, value)} infoRowAttributes - function that return given attributes for the row
3857
* @returns {vnode} - row with object information key and value
3958
*/
40-
const infoRow = (key, value) => h('.flex-row.g2', [
41-
h('b.w-25.w-wrapped', key),
42-
h('.w-75', infoPretty(key, value)),
43-
]);
59+
const infoRow = (key, value, infoRowAttributes) => {
60+
const highlightedClasses = HIGHLIGHTED_FIELDS.includes(key) ? '.highlighted' : '';
61+
const formattedValue = infoPretty(key, value);
62+
const formattedKey = getUILabel(key);
63+
64+
const hasValue = value != null && value !== '' && (!Array.isArray(value) || value.length !== 0);
65+
66+
return h(`.flex-row.g2.info-row${highlightedClasses}`, [
67+
h('b.w-25.w-wrapped', formattedKey),
68+
h('.w-75.cursor-pointer', hasValue && infoRowAttributes(formattedKey, formattedValue), formattedValue),
69+
]);
70+
};
71+
72+
/**
73+
* Retrieves the final UI-friendly label for given data key
74+
* * Priority:
75+
* 1. Manual override using `SPECIFIC_KEY_LABELS`
76+
* 2. Use `defaultKeyTransform` to generate a label
77+
* @param {string} key - key of the object info
78+
* @returns {string} - formatted label for the given key
79+
*/
80+
const getUILabel = (key) => {
81+
if (Object.hasOwn(SPECIFIC_KEY_LABELS, key)) {
82+
return SPECIFIC_KEY_LABELS[key];
83+
}
84+
85+
return camelToTitleCase(key);
86+
};
4487

4588
/**
4689
* Parses the value and returns it in a specific format based on type
90+
* safely handeling nulls and objects.
4791
* @param {string} key - key of the object info
4892
* @param {string|number|object|undefined} value - value of the object info
49-
* @returns {vnode} - value of object based on its type
93+
* @returns {string} - string representation of the value passed
5094
*/
5195
const infoPretty = (key, value) => {
96+
if (value == null) {
97+
return '-';
98+
}
99+
52100
if (DATE_FIELDS.includes(key)) {
53101
return prettyFormatDate(value);
54-
} else if (Array.isArray(value)) {
102+
}
103+
104+
if (Array.isArray(value)) {
55105
return value.length > 0
56106
? value.join(', ')
57107
: '-';
58108
}
59-
return h('', value);
109+
110+
if (typeof value === 'object') {
111+
return JSON.stringify(value);
112+
}
113+
114+
return String(value);
60115
};
116+
117+
/**
118+
* Default function to configure the info row vnode attributes
119+
* @typedef {import('/js/src/index.js').Notification} Notification
120+
* @param {Notification} notification - Notification API from WebUI framework
121+
* @returns {function(string, string): object} object containing the constructed vnode attributes
122+
*/
123+
export const defaultRowAttributes = (notification) =>
124+
(key, value) => ({
125+
onclick: async (e) => {
126+
// to allowing the default behaviour for clicking multiple times
127+
const clickCount = e.detail;
128+
if (clickCount === 1) {
129+
if (!isContextSecure()) {
130+
return;
131+
}
132+
133+
try {
134+
await copyToClipboard(value);
135+
notification.show('Value has been successfully copied to clipboard', 'success', 1500);
136+
} catch (error) {
137+
notification.show(`Failed to copy to clipboard: ${error.message}`, 'danger', 1500);
138+
}
139+
}
140+
},
141+
title: `Copy ${key}`,
142+
});

QualityControl/public/common/utils.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,36 @@ export function setBrowserTabTitle(title = undefined) {
138138
export function hasMinimumRoleAccess(userRoles, requiredRole) {
139139
return userRoles.some((role) => isUserRoleSufficient(role, requiredRole));
140140
}
141+
142+
/**
143+
* Method to check if connection is secure to enable certain improvements
144+
* e.g navigator.clipboard, notifications, service workers
145+
* @returns {boolean} - whether window is in secure context
146+
*/
147+
export function isContextSecure() {
148+
return window.isSecureContext;
149+
}
150+
151+
/**
152+
* Asynchronously writes the given text value to the system clipboard
153+
* @param {string} value - The text string to be copied to the clipboard
154+
* @returns {Promise<void>} - A Promise that resolves with no value when the text has been successfully copied.
155+
* The promise is rejected if the operation fails (e.g., due to lack of user permission
156+
* or an insecure context)
157+
*/
158+
export function copyToClipboard(value) {
159+
return navigator.clipboard.writeText(value);
160+
}
161+
162+
/**
163+
* Converts a camelCase string to a human-readable Title Case string.
164+
* It inserts a space before every uppercase letter and uppercase the
165+
* first character of the resulting string.
166+
* @param {string} text - the camelCase string to tranform (e.g. 'lastModified')
167+
* @returns {string} - the formatted Title Case string (e.g. `Last Modified')
168+
*/
169+
export const camelToTitleCase = (text) => {
170+
const spaced = text.replace(/([A-Z])/g, ' $1');
171+
const titleCase = spaced.charAt(0).toUpperCase() + spaced.slice(1);
172+
return titleCase;
173+
};

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
*/
1414

1515
import { downloadButton } from '../../../common/downloadButton.js';
16-
import { qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js';
16+
import { defaultRowAttributes, qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js';
1717
import { h, iconResizeBoth, info } from '/js/src/index.js';
1818

1919
/**
@@ -47,7 +47,8 @@ export const objectInfoResizePanel = (model, tabObject) => {
4747
h(
4848
'.dropdown-menu',
4949
{ style: 'right:0.1em; width: 35em;left: auto;' },
50-
objectRemoteData.isSuccess() && h('.p1', qcObjectInfoPanel(objectRemoteData.payload)),
50+
objectRemoteData.isSuccess() &&
51+
h('.p1', qcObjectInfoPanel(objectRemoteData.payload, {}, defaultRowAttributes(model.notification))),
5152
),
5253
]),
5354
objectRemoteData.isSuccess() &&

QualityControl/public/object/objectTreePage.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { spinner } from '../common/spinner.js';
1717
import { draw } from '../common/object/draw.js';
1818
import timestampSelectForm from './../common/timestampSelectForm.js';
1919
import virtualTable from './virtualTable.js';
20-
import { qcObjectInfoPanel } from '../common/object/objectInfoCard.js';
20+
import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js';
2121
import { downloadButton } from '../common/downloadButton.js';
2222

2323
/**
@@ -121,7 +121,7 @@ const drawPlot = (model, object) => {
121121
h('', { style: 'height:77%;' }, draw(model.object, name, { stat: true })),
122122
h('.scroll-y', {}, [
123123
h('.w-100.flex-row', { style: 'justify-content: center' }, h('.w-80', timestampSelectForm(model))),
124-
qcObjectInfoPanel(object, { 'font-size': '.875rem;' }),
124+
qcObjectInfoPanel(object, { 'font-size': '.875rem;' }, defaultRowAttributes(model.notification)),
125125
]),
126126
]);
127127
};

QualityControl/public/pages/objectView/ObjectViewPage.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { drawObject } from './../../common/object/draw.js';
1717
import { spinner } from './../../common/spinner.js';
1818
import { errorDiv } from '../../common/errorDiv.js';
1919
import { dateSelector } from '../../common/object/dateSelector.js';
20-
import { qcObjectInfoPanel } from '../../common/object/objectInfoCard.js';
20+
import { defaultRowAttributes, qcObjectInfoPanel } from '../../common/object/objectInfoCard.js';
2121
import { downloadButton } from '../../common/downloadButton.js';
2222
import { visibilityToggleButton } from '../../common/visibilityButton.js';
2323

@@ -87,7 +87,7 @@ const objectPlotAndInfo = (objectViewModel) =>
8787
key: 'objectInfoPanel',
8888
}, [
8989
h('h3.text-center', 'Object information'),
90-
qcObjectInfoPanel(qcObject, { gap: '.5em' }),
90+
qcObjectInfoPanel(qcObject, { gap: '.5em' }, defaultRowAttributes(model.notification)),
9191
]),
9292
]),
9393
]);

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,15 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) =>
135135
const result = await page.evaluate((commonSelectorPath) => {
136136
const { title } = document.querySelector(`${commonSelectorPath} > div:nth-child(2) > div > button`);
137137
const infoCommonSelectorPath = `${commonSelectorPath} > div:nth-child(2) > div > div > div > div`;
138-
const objectPath = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > div > div`).innerText;
139-
const pathTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > b`).innerText;
138+
const objectPath = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(1) > div`).innerText;
139+
const pathTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(1) > b`).innerText;
140140
const lastModifiedTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(6) > b`).innerText;
141141
return { title, pathTitle, objectPath, lastModifiedTitle };
142142
}, commonSelectorPath);
143143
strictEqual(result.title, 'View details about histogram');
144-
strictEqual(result.pathTitle, 'path');
144+
strictEqual(result.pathTitle, 'Path');
145145
strictEqual(result.objectPath, 'qc/test/object/1');
146-
strictEqual(result.lastModifiedTitle, 'lastModified');
146+
strictEqual(result.lastModifiedTitle, 'Last Modified');
147147
});
148148

149149
await testParent.test(
@@ -156,15 +156,15 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) =>
156156
const result = await page.evaluate((commonSelectorPath) => {
157157
const { title } = document.querySelector(`${commonSelectorPath} > div:nth-child(2) > div > button`);
158158
const infoCommonSelectorPath = `${commonSelectorPath} > div:nth-child(2) > div > div > div > div`;
159-
const objectPath = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > div`).innerText;
160-
const pathTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > b`).innerText;
159+
const objectPath = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(1) > div`).innerText;
160+
const pathTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(1) > b`).innerText;
161161
const lastModifiedTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(6) > b`).innerText;
162162
return { title, pathTitle, objectPath, lastModifiedTitle };
163163
}, commonSelectorPath);
164164
strictEqual(result.title, 'View details about histogram');
165-
strictEqual(result.pathTitle, 'path');
165+
strictEqual(result.pathTitle, 'Path');
166166
strictEqual(result.objectPath, 'qc/test/object/1');
167-
strictEqual(result.lastModifiedTitle, 'lastModified');
167+
strictEqual(result.lastModifiedTitle, 'Last Modified');
168168
},
169169
);
170170

0 commit comments

Comments
 (0)