diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 9cd733a09..755aa8595 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -193,6 +193,23 @@ white-space: nowrap; } +.sort-button { + .hover-icon { + display: none; + opacity: 0.6; + } + + &:hover { + .current-icon { + display: none; + } + + .hover-icon { + display: inline-block; + } + } +} + .drop-zone { position: absolute; height: 100%; diff --git a/QualityControl/public/common/enums/columnSort.enum.js b/QualityControl/public/common/enums/columnSort.enum.js new file mode 100644 index 000000000..ba58e6619 --- /dev/null +++ b/QualityControl/public/common/enums/columnSort.enum.js @@ -0,0 +1,24 @@ +/** + * @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 sort directions + * @enum {number} + * @readonly + */ +export const SortDirectionsEnum = Object.freeze({ + NONE: 0, + ASC: 1, + DESC: -1, +}); diff --git a/QualityControl/public/common/sortButton.js b/QualityControl/public/common/sortButton.js new file mode 100644 index 000000000..97e3197d4 --- /dev/null +++ b/QualityControl/public/common/sortButton.js @@ -0,0 +1,81 @@ +/** + * @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 { SortDirectionsEnum } from '../common/enums/columnSort.enum.js'; +import { h, iconCircleX, iconArrowBottom, iconArrowTop } from '/js/src/index.js'; + +/** + * Get the icon for the sort direction. + * @param {SortDirectionsEnum} direction - direction of the sort. + * @returns {vnode} the correct icon related to the direction. + */ +const getSortIcon = (direction) => { + if (direction === SortDirectionsEnum.ASC) { + return iconArrowTop(); + } + if (direction === SortDirectionsEnum.DESC) { + return iconArrowBottom(); + } + return iconCircleX(); +}; + +/** + * @callback SortClickCallback + * @param {string} label - The label of the column being sorted. + * @param {number} order - The next sort direction in the cycle. + * @param {vnode} icon - The VNode for the icon representing the next sort state. + * @returns {void} + */ + +/** + * Renders a sortable table header button that cycles through sort states. + * Displays the current sort icon and a preview icon of the next state on hover. + * @param {object} props - The component properties. + * @param {number} props.order - The current sort direction value from SortDirectionsEnum. + * @param {object|undefined} props.icon - The VNode/element for the current active sort icon. + * @param {string} props.label - The display text for the column header. + * @param {SortClickCallback} props.onclick - Callback triggered on click. + * @param {Array} [props.sortOptions] - Array of SortDirectionsEnum values defining the + * order of the sort cycle. Defaults to all enum values. + * @returns {object} A HyperScript VNode representing the sortable button. + */ +export const sortableTableHead = ({ + order, + icon, + label, + onclick, + sortOptions = [...Object.values(SortDirectionsEnum)], +}) => { + const currentIndex = sortOptions.indexOf(order); + const nextIndex = (currentIndex + 1) % sortOptions.length; + const nextSortOrder = sortOptions[nextIndex]; + const hoverIcon = getSortIcon(nextSortOrder); + + const directionLabel = Object.keys(SortDirectionsEnum).find((key) => SortDirectionsEnum[key] === nextSortOrder); + + return h( + 'button.btn.sort-button', + { + onclick: () => onclick(label, nextSortOrder, hoverIcon), + title: `Sort by ${directionLabel}`, + }, + [ + label, + h('span.icon-container.mh1', [ + h('span.current-icon', [order != SortDirectionsEnum.NONE ? icon : undefined]), + h('span.hover-icon', [getSortIcon(nextSortOrder)]), + ]), + ], + ); +}; diff --git a/QualityControl/public/object/QCObject.js b/QualityControl/public/object/QCObject.js index 840215994..83f69e2a9 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -47,7 +47,6 @@ export default class QCObject extends BaseViewModel { title: 'Name', order: 1, icon: iconArrowTop(), - open: false, }; this.tree = new ObjectTree('database'); @@ -115,15 +114,6 @@ export default class QCObject extends BaseViewModel { this.notify(); } - /** - * Toggle the display of the sort by dropdown - * @returns {undefined} - */ - toggleSortDropdown() { - this.sortBy.open = !this.sortBy.open; - this.notify(); - } - /** * Computes the final list of objects to be seen by user depending on search input from user * If any of those changes, this method should be called to update the outputs. @@ -189,7 +179,7 @@ export default class QCObject extends BaseViewModel { this._computeFilters(); - this.sortBy = { field, title, order, icon, open: false }; + this.sortBy = { field, title, order, icon }; this.notify(); } @@ -253,7 +243,6 @@ export default class QCObject extends BaseViewModel { title: 'Name', order: 1, icon: iconArrowTop(), - open: false, }; this._computeFilters(); diff --git a/QualityControl/public/object/objectTreeHeader.js b/QualityControl/public/object/objectTreeHeader.js index e7af35c13..00b8b0447 100644 --- a/QualityControl/public/object/objectTreeHeader.js +++ b/QualityControl/public/object/objectTreeHeader.js @@ -13,7 +13,6 @@ */ import { h } from '/js/src/index.js'; -import { iconCollapseUp, iconArrowBottom, iconArrowTop } from '/js/src/icons.js'; import { filterPanelToggleButton } from '../common/filters/filterViews.js'; /** @@ -39,53 +38,9 @@ export default function objectTreeHeader(qcObject, filterModel) { qcObject.objectsRemote.isSuccess() && h('span', `(${howMany})`), ]), - rightCol: h('.w-25.flex-row.items-center.g2.justify-end', [ - filterModel.isRunModeActivated ? null : filterPanelToggleButton(filterModel), - ' ', - h('.dropdown', { - id: 'sortTreeButton', title: 'Sort by', class: qcObject.sortBy.open ? 'dropdown-open' : '', - }, [ - h('button.btn', { - title: 'Sort by', - onclick: () => qcObject.toggleSortDropdown(), - }, [qcObject.sortBy.title, ' ', qcObject.sortBy.icon]), - h('.dropdown-menu.text-left', [ - sortMenuItem(qcObject, 'Name', 'Sort by name ASC', iconArrowTop(), 'name', 1), - sortMenuItem(qcObject, 'Name', 'Sort by name DESC', iconArrowBottom(), 'name', -1), - - ]), - ]), - ' ', - h('button.btn', { - title: 'Close whole tree', - id: 'collapse-tree-button', - onclick: () => qcObject.tree.closeAll(), - disabled: Boolean(qcObject.searchInput), - }, iconCollapseUp()), - ' ', - h('input.form-control.form-inline.mh1.w-33', { - id: 'searchObjectTree', - placeholder: 'Search', - type: 'text', - value: qcObject.searchInput, - disabled: qcObject.queryingObjects ? true : false, - oninput: (e) => qcObject.search(e.target.value), - }), - ' ', - ]), + rightCol: h( + '.w-25.flex-row.items-center.g2.justify-end', + [filterModel.isRunModeActivated ? null : filterPanelToggleButton(filterModel)], + ), }; } - -/** - * Create a menu-item for sort-by dropdown - * @param {QcObject} qcObject - Model that manages the QCObject state. - * @param {string} shortTitle - title that gets displayed to the user - * @param {string} title - title that gets displayed to the user on hover - * @param {Icon} icon - svg icon to be used - * @param {string} field - field by which sorting should happen - * @param {number} order - {-1/1}/{DESC/ASC} - * @returns {vnode} - virtual node element - */ -const sortMenuItem = (qcObject, shortTitle, title, icon, field, order) => h('a.menu-item', { - title: title, style: 'white-space: nowrap;', onclick: () => qcObject.sortTree(shortTitle, field, order, icon), -}, [shortTitle, ' ', icon]); diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 1dd7f1491..117a69a6e 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -12,7 +12,15 @@ * or submit itself to any jurisdiction. */ -import { h, iconBarChart, iconCaretRight, iconResizeBoth, iconCaretBottom, iconCircleX } from '/js/src/index.js'; +import { + h, + iconCollapseUp, + iconBarChart, + iconCaretRight, + iconResizeBoth, + iconCaretBottom, + iconCircleX, +} from '/js/src/index.js'; import { spinner } from '../common/spinner.js'; import { draw } from '../common/object/draw.js'; import timestampSelectForm from './../common/timestampSelectForm.js'; @@ -20,6 +28,8 @@ 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 { SortDirectionsEnum } from '../common/enums/columnSort.enum.js'; +import { sortableTableHead } from '../common/sortButton.js'; import { downloadRootImageButton } from '../common/downloadRootImageButton.js'; /** @@ -50,9 +60,15 @@ export default (model) => { const objectsLoaded = object.list; const objectsToDisplay = objectsLoaded.filter((qcObject) => qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); - return virtualTable(model, 'main', objectsToDisplay); + return h('.scroll-y.flex-column.flex-grow', [ + tableHeaderRow(model), + virtualTable(model, 'side', objectsToDisplay), + ]); } - return tableShow(model); + return h('', [ + tableHeaderRow(model), + tableShow(model), + ]); }, Failure: () => null, // Notification is displayed })), @@ -170,11 +186,45 @@ const statusBarRight = (model) => model.object.selected * @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)]), + h('table.table.table-sm.text-no-select', h('tbody', [treeRows(model)])); + +const tableHeaderRow = (model) => h('.bg-gray-light.pv2', [ + sortableTableHead({ + order: model.object.sortBy.order, + icon: model.object.sortBy.icon, + label: 'Name', + sortOptions: [SortDirectionsEnum.ASC, SortDirectionsEnum.DESC], + onclick: (label, order, icon) => { + model.object.sortTree(label, 'name', order, icon); + }, + }), + tableHeader(model.object), +]); + +const tableHeader = (qcObject) => + h('.flex-row.w-100', [ + tableSearchInput(qcObject), + tableCollapseAll(qcObject), ]); +const tableCollapseAll = (qcObject) => + h('button.btn.m2', { + title: 'Close whole tree', + onclick: () => qcObject.tree.closeAll(), + disabled: Boolean(qcObject.searchInput), + id: 'collapse-tree-button', + }, iconCollapseUp()); + +const tableSearchInput = (qcObject) => + h('input.form-control.form-inline.mv2.mh3.flex-grow', { + id: 'searchObjectTree', + placeholder: 'Search', + type: 'text', + value: qcObject.searchInput, + disabled: qcObject.queryingObjects ? true : false, + oninput: (e) => qcObject.search(e.target.value), + }); + /** * Shows a list of lines of objects * @param {Model} model - root model of the application diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index 625a1bec7..129c98391 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -241,16 +241,15 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { ); let rowCount = await page.evaluate(() => document.querySelectorAll('tr').length); - strictEqual(rowCount, 7); + strictEqual(rowCount, 6); const runNumber = '0'; await page.locator('#runNumberFilter').fill(runNumber); await page.locator('#filterElement #triggerFilterButton').click(); - - await extendTree(3, 5); + await delay(100); rowCount = await page.evaluate(() => document.querySelectorAll('tr').length); - strictEqual(rowCount, 5); // Due to the filter there are two objects fewer. + strictEqual(rowCount, 4); // Due to the filter there are two objects fewer. }); await testParent.test('ObjectTree infoPanel should show filtered object versions', { timeout }, async () => { diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 31284c3e5..b93a9affa 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -34,7 +34,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should have a tree as a table', { timeout }, async () => { - const tableRowPath = 'section > div > div > div > table > tbody > tr'; + const tableRowPath = 'section > div > div > div > div > table > tbody > tr'; await page.waitForSelector(tableRowPath, { timeout: 1000 }); const rowsCount = await page.evaluate( (tableRowPath) => document.querySelectorAll(tableRowPath).length, @@ -44,26 +44,26 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should preserve state if refreshed', { timeout }, async () => { - const selector = 'section > div > div > div > table > tbody > tr:nth-child(2)'; + const selector = 'section > div > div > div > div > table > tbody > tr:nth-child(2)'; await page.locator(selector).click(); await page.reload({ waitUntil: 'networkidle0' }); const rowCountExpanded = await page.evaluate(() => - document.querySelectorAll('section > div > div > div > table > tbody > tr').length); + document.querySelectorAll('section > div > div > div > div > table > tbody > tr').length); await page.locator(selector).click(); await page.reload({ waitUntil: 'networkidle0' }); const rowCountCollapsed = await page.evaluate(() => - document.querySelectorAll('section > div > div > div > table > tbody > tr').length); + document.querySelectorAll('section > div > div > div > div > table > tbody > tr').length); strictEqual(rowCountExpanded, 3); strictEqual(rowCountCollapsed, 2); }); await testParent.test('should have a button to sort by (default "Name" ASC)', async () => { - const sortByButtonTitle = await page.evaluate((path) => document.querySelector(path).title, '#sortTreeButton'); - strictEqual(sortByButtonTitle, 'Sort by'); + const sortByButtonTitle = await page.evaluate((path) => document.querySelector(path).title, '.btn.sort-button'); + strictEqual(sortByButtonTitle, 'Sort by DESC'); }); await testParent.test('should have first element in tree as "qc/test/object/1"', async () => { @@ -242,7 +242,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) ); await testParent.test('should update local storage when tree node is clicked', { timeout }, async () => { - const selector = 'section > div > div > div > table > tbody > tr:nth-child(2)'; + const selector = 'section > div > div > div > div > table > tbody > tr:nth-child(2)'; const personid = await page.evaluate(() => window.model.session.personid); const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`; @@ -260,9 +260,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should sort list of histograms by name in descending order', async () => { - await page.locator('#sortTreeButton').click(); - const sortingByNameOptionPath = '#sortTreeButton > div > a:nth-child(2)'; - await page.locator(sortingByNameOptionPath).click(); + await page.locator('.btn.sort-button').click(); const sorted = await page.evaluate(() => ({ list: window.model.object.currentList, @@ -275,9 +273,8 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should sort list of histograms by name in ascending order', async () => { - await page.locator('#sortTreeButton').click(); - const sortingByNameOptionPath = '#sortTreeButton > div > a:nth-child(1)'; - await page.locator(sortingByNameOptionPath).click(); + await page.locator('.btn.sort-button').click(); + const sorted = await page.evaluate(() => ({ list: window.model.object.currentList, sort: window.model.object.sortBy, @@ -300,7 +297,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) await delay(100); const storedNodes = await getLocalStorageAsJson(page, storageKey); const tableRowCount = await page.evaluate(() => - document.querySelectorAll('section > div > div > div > table > tbody > tr').length); + document.querySelectorAll('section > div > div > div > div > table > tbody > tr').length); deepStrictEqual(storedNodes, {}, 'Stores nodes should be empty'); strictEqual(tableRowCount, 1, 'Tree should be fully collapsed'); @@ -311,7 +308,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) await page.type('#searchObjectTree', 'qc/test/object/1'); const rowsDisplayed = await page.evaluate(() => { const rows = []; - document.querySelectorAll('section > div > div > div > table > tbody > tr') + document.querySelectorAll('section > div > div > div > div > table > tbody > tr') .forEach((item) => rows.push(item.innerText)); return rows; }, { timeout: 5000 });