diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 956128933..fe8b491dd 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -187,3 +187,20 @@ .whitespace-nowrap { white-space: nowrap; } + +.sort-button { + .hover-icon { + display: none; + opacity: 0.6; + } + + &:hover { + .current-icon { + display: none; + } + + .hover-icon { + display: inline-block; + } + } +} 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 fe53637d8..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,52 +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', - 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 f53b09d38..132895603 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'; /** * Shows a page to explore though a tree of objects with a preview on the right if clicked @@ -46,9 +56,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('', [ + tableHeader(model.object), + virtualTable(model, 'main', objectsToDisplay), + ]); } - return tableShow(model); + return h('', [ + tableHeader(model.object), + tableShow(model), + ]); }, Failure: () => null, // Notification is displayed })), @@ -165,10 +181,45 @@ const statusBarRight = (model) => model.object.selected */ const tableShow = (model) => h('table.table.table-sm.text-no-select', [ - h('thead', [h('tr', [h('th', 'Name')])]), + h('thead', [ + h('tr', [ + h('th', 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); + }, + })), + ]), + ]), h('tbody', [treeRows(model)]), ]); +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), + }, iconCollapseUp()); + +const tableSearchInput = (qcObject) => + h('input.form-control.form-inline.m2.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/public/object/virtualTable.js b/QualityControl/public/object/virtualTable.js index 29ebda4d4..c5e472f95 100644 --- a/QualityControl/public/object/virtualTable.js +++ b/QualityControl/public/object/virtualTable.js @@ -12,6 +12,8 @@ * or submit itself to any jurisdiction. */ +import { SortDirectionsEnum } from '../common/enums/columnSort.enum.js'; +import { sortableTableHead } from '../common/sortButton.js'; import { h, iconBarChart } from '/js/src/index.js'; let ROW_HEIGHT = 33.6; @@ -102,7 +104,19 @@ const objectFullRow = (model, item, location) => const tableHeader = () => h('table.table.table-sm.text-no-select', { style: 'margin-bottom:0', - }, h('thead', [h('tr', [h('th', 'Name')])])); + }, h('thead', [ + h('tr', [ + h('th', 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); + }, + })), + ]), + ])); /** * Set styles of the floating table and its position inside the big div .tableLogsContentPlaceholder diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 45788051d..62b75bdee 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,19 +44,19 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should not preserve state if refreshed not in run mode', { timeout }, async () => { - const tbodyPath = 'section > div > div > div > table > tbody'; + const tbodyPath = 'section > div > div > div > div > table > tbody'; await page.locator(`${tbodyPath} > tr:nth-child(2)`).click(); await page.reload({ waitUntil: 'networkidle0' }); const rowCount = 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(rowCount, 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 () => { @@ -229,9 +229,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, @@ -244,9 +242,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,