diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index 203a6e58b..e9250ddd7 100644 --- a/QualityControl/public/Model.js +++ b/QualityControl/public/Model.js @@ -28,6 +28,8 @@ import { buildQueryParametersString } from './common/buildQueryParametersString. import AboutViewModel from './pages/aboutView/AboutViewModel.js'; import LayoutListModel from './pages/layoutListView/model/LayoutListModel.js'; import { RequestFields } from './common/RequestFields.enum.js'; +import { KeyCodesEnum } from '../common/enums/keyCodes.enum.js'; + import FilterModel from './common/filters/model/FilterModel.js'; /** @@ -131,14 +133,67 @@ export default class Model extends Observable { const code = e.keyCode; // Delete key + layout page + object select => delete this object - if (code === 8 && + if (code === KeyCodesEnum.DELETE && this.router.params.page === 'layoutShow' && this.layout.editEnabled && this.layout.editingTabObject) { this.layout.deleteTabObject(this.layout.editingTabObject); - } else if (code === 27 && this.isImportVisible) { + } else if (code === KeyCodesEnum.ESC && this.isImportVisible) { this.layout.resetImport(); } + + if (this.router.params.page === 'objectTree') { + const searchActive = Boolean(this.object.searchInput?.trim()); + // Search navigation + if (searchActive) { + const results = this.object.searchResult || []; + if (!results.length) { + return; + } + if (code === KeyCodesEnum.UP) { + e.preventDefault(); + this.object.setFocusedSearchResultByOffset(-1); + return; + } + if (code === KeyCodesEnum.DOWN) { + e.preventDefault(); + this.object.setFocusedSearchResultByOffset(1); + return; + } + if (code === KeyCodesEnum.RIGHT || code === KeyCodesEnum.ENTER) { + e.preventDefault(); + this.object.select(this.object.focusedSearchResult); + return; + } + return; + } + // Tree navigation + if (code === KeyCodesEnum.UP) { + e.preventDefault(); + this.object.tree.focusPreviousNode(); + return; + } + if (code === KeyCodesEnum.DOWN) { + e.preventDefault(); + this.object.tree.focusNextNode(); + return; + } + if (code === KeyCodesEnum.LEFT) { + e.preventDefault(); + this.object.tree.collapseFocusedNode(); + return; + } + if (code === KeyCodesEnum.RIGHT || code === KeyCodesEnum.ENTER) { + e.preventDefault(); + const focusedObject = this.object.tree.focusedNode?.object; + if (focusedObject) { + this.object.select(focusedObject); + } else { + this.object.tree.expandFocusedNode(); + } + return; + } + } } /** @@ -276,7 +331,7 @@ export default class Model extends Observable { /** * Clear URL parameters and redirect to a certain page - * @param {*} pageName - name of the page to be redirected to + * @param {string} pageName - name of the page to be redirected to * @returns {undefined} */ clearURL(pageName) { diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 9cd733a09..ddc877584 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -26,6 +26,8 @@ .object-selectable { cursor: pointer; text-decoration: none; } .object-selectable:hover { cursor: pointer; background-color: var(--color-gray-dark) !important; color: var(--color-gray-lighter); } +.focused-node>th, .focused-node>td { background-color: var(--color-gray-dark); color: var(--color-white); } + .layout-selectable { border: 0.0em solid var(--color-primary); transition: border 0.1s; } .layout-selected { border: 0.3em solid var(--color-primary); } .layout-edit-layer { cursor: move; opacity: 0; } diff --git a/QualityControl/public/common/constants/ui.js b/QualityControl/public/common/constants/ui.js new file mode 100644 index 000000000..c5445a3e7 --- /dev/null +++ b/QualityControl/public/common/constants/ui.js @@ -0,0 +1,16 @@ +/** + * @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. + */ + +export const OBJECT_LIST_ROW_HEIGHT = 33.6; +export const OBJECT_LIST_SIDE_ROW_HEIGHT = 29.4; diff --git a/QualityControl/public/common/enums/keyCodes.enum.js b/QualityControl/public/common/enums/keyCodes.enum.js new file mode 100644 index 000000000..af685ea93 --- /dev/null +++ b/QualityControl/public/common/enums/keyCodes.enum.js @@ -0,0 +1,28 @@ +/** + * @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. + */ + +/** + * Key codes enumeration for keyboard events + * @enum {number} + * @readonly + */ +export const KeyCodesEnum = Object.freeze({ + UP: 38, + DOWN: 40, + LEFT: 37, + RIGHT: 39, + ENTER: 13, + DELETE: 8, + ESC: 27, +}); diff --git a/QualityControl/public/common/enums/storageKeys.enum.js b/QualityControl/public/common/enums/storageKeys.enum.js index 2c6f0c10f..3657ac4cd 100644 --- a/QualityControl/public/common/enums/storageKeys.enum.js +++ b/QualityControl/public/common/enums/storageKeys.enum.js @@ -20,5 +20,5 @@ export const StorageKeysEnum = Object.freeze({ OBJECT_VIEW_LEFT_PANEL_WIDTH: 'object-view-left-panel-width', OBJECT_VIEW_INFO_VISIBILITY_SETTING: 'object-view-info-visibility-setting', - OBJECT_TREE_OPEN_NODES: 'object-tree-open-nodes', + OBJECT_TREE_OPEN_BRANCH_STATE: 'object-tree-open-branch-state', }); diff --git a/QualityControl/public/object/ObjectTree.class.js b/QualityControl/public/object/ObjectTree.class.js index 861f50ff0..d7002a6de 100644 --- a/QualityControl/public/object/ObjectTree.class.js +++ b/QualityControl/public/object/ObjectTree.class.js @@ -21,6 +21,8 @@ import { StorageKeysEnum } from '../common/enums/storageKeys.enum.js'; * a new tree. */ export default class ObjectTree extends Observable { + static _indexIncrementCount = 0; + /** * Instantiate tree with a root node called `name`, empty by default * @param {string} name - root name @@ -28,10 +30,24 @@ export default class ObjectTree extends Observable { */ constructor(name, parent) { super(); - this.storage = new BrowserStorage(StorageKeysEnum.OBJECT_TREE_OPEN_NODES); + this._index = ObjectTree._indexIncrementCount++; + this.focusedNode = null; + this.openBranchStateStorage = new BrowserStorage(StorageKeysEnum.OBJECT_TREE_OPEN_BRANCH_STATE); this.initTree(name, parent); } + get index() { + return this._index; + } + + get isBranch() { + return this.children.length > 0; + } + + get isLeaf() { + return this.object !== null; + } + /** * Method to instantiate/reset the tree * @param {string} name - name of the tree to be initialized @@ -48,31 +64,182 @@ export default class ObjectTree extends Observable { this.pathString = ''; // 'A/B' } + /** + * Set the focused node by index + * @param {number} index - Index of the node to focus + */ + setFocusedNodeByIndex(index) { + const nodeToFocus = this.getVisibleNodes().find((node) => node.index === index); + if (nodeToFocus) { + this._setFocusedNode(nodeToFocus); + } + } + + /** + * Set the currently focused node + * @param {ObjectTree} node - node to be focused + * @returns {undefined} + */ + _setFocusedNode(node) { + this.focusedNode = node; + this.notify(); + requestAnimationFrame(() => { + const container = document.getElementById('object-tree-scroll-container'); + const focusedRow = document.getElementById(`${node.index}`) || document.querySelector('.focused-node'); + if (!container || !focusedRow) { + return; + } + const containerRect = container.getBoundingClientRect(); + const rowRect = focusedRow.getBoundingClientRect(); + if (rowRect.top < containerRect.top) { + // Row is above view — scroll up + container.scrollTop += rowRect.top - containerRect.top; + } else if (rowRect.bottom > containerRect.bottom) { + // Row is below view — scroll down + container.scrollTop += rowRect.bottom - containerRect.bottom; + } + }); + } + + /** + * Collapse the currently focused node or its parent branch. + * @returns {undefined} + */ + collapseFocusedNode() { + if (!this.focusedNode) { + return; // No focused node + } + // focus is on a leaf node -> collapse and focus parent + const { isLeaf } = this.focusedNode; + if (isLeaf) { + const { parent } = this.focusedNode; + if (!parent) { + return; // No parent to collapse + } + parent.open = false; + this._setFocusedNode(parent); + return; + } + // focus is on a branch node -> collapse or focus parent + const { isBranch, open } = this.focusedNode; + if (isBranch) { + if (open) { + this.focusedNode.toggle(); + return; + } + const isNotRoot = Boolean(this.focusedNode.parent?.parent); + if (isNotRoot) { + this._setFocusedNode(this.focusedNode.parent); + return; + } + } + } + + /** + * Expand the currently focused branch if closed or move focus to its first child. + * @returns {undefined} + */ + expandFocusedNode() { + if (!this.focusedNode) { + return; // No focused node + } + if (!this.focusedNode.isBranch) { + return; // Cannot expand a leaf + } + if (this.focusedNode.open) { + this._setFocusedNode(this.focusedNode.children[0]); // Move focus to first child + } else { + this.focusedNode.toggle(); // Expand the branch + } + } + + /** + * Get all visible nodes in the tree (for navigation) + * @returns {Array.} - list of visible nodes + */ + getVisibleNodes() { + const nodes = []; + const traverse = (n) => { + nodes.push(n); + if (n.open) { + n.children.forEach(traverse); + } + }; + this.children.forEach(traverse); + return nodes; + } + + /** + * Focus the next visible node in the tree + */ + focusNextNode() { + const visible = this.getVisibleNodes(); + // No visible nodes + if (!visible.length) { + return; + } + const idx = visible.indexOf(this.focusedNode); + // Nothing focused yet -> focus first visible node + if (!this.focusedNode || idx === -1) { + const [first] = visible; + this._setFocusedNode(first); + return; + } + // At the last visible node, do nothing + if (idx >= visible.length - 1) { + return; + } + // Select next node + const next = visible[idx + 1] ?? visible[idx]; + this._setFocusedNode(next); + } + + /** + * Focus the previous visible node in the tree. + */ + focusPreviousNode() { + const visible = this.getVisibleNodes(); + // No visible nodes + if (!visible.length) { + return; + } + const idx = visible.indexOf(this.focusedNode); + // At the first visible node, do nothing + if (idx === 0) { + return; + } + // Nothing focused yet -> focus first visible node + if (!this.focusedNode || idx === -1) { + const [first] = visible; + this._setFocusedNode(first); + return; + } + // Select previous node + const prev = idx > 0 ? visible[idx - 1] : visible[0]; + this._setFocusedNode(prev); + } + /** * Load the expanded/collapsed state for this node and its children from localStorage. * Updates the `open` property for the current node and recursively for all children. */ - loadExpandedNodes() { + loadExpandedBranches() { if (!this.parent) { // The main node may not be collapsable or expandable. // Because of this we also have to load the expanded state of their direct children. - this.children.forEach((child) => child.loadExpandedNodes()); + this.children.forEach((child) => child.loadExpandedBranches()); } - const session = sessionService.get(); const key = session.personid.toString(); - - // We traverse the path to reach the parent object of this node - let parentNode = this.storage.getLocalItem(key) ?? {}; + // We traverse the path to reach the parent branch of this node + let branchState = this.openBranchStateStorage.getLocalItem(key) ?? {}; for (let i = 0; i < this.path.length - 1; i++) { - parentNode = parentNode[this.path[i]]; - if (!parentNode) { - // Cannot expand marked node because parent path does not exist - return; + branchState = branchState[this.path[i]]; + if (!branchState) { + return; // Cannot expand marked node because parent path does not exist } } - - this._applyExpandedNodesRecursive(parentNode, this); + this._applyExpandedBranchesRecursive(branchState, this); } /** @@ -80,57 +247,52 @@ export default class ObjectTree extends Observable { * @param {object} data - The current level of the hierarchical expanded nodes object * @param {ObjectTree} treeNode - The tree node to update */ - _applyExpandedNodesRecursive(data, treeNode) { + _applyExpandedBranchesRecursive(data, treeNode) { if (data[treeNode.name]) { treeNode.open = true; Object.keys(data[treeNode.name]).forEach((childName) => { const child = treeNode.children.find((child) => child.name === childName); if (child) { - this._applyExpandedNodesRecursive(data[treeNode.name], child); + this._applyExpandedBranchesRecursive(data[treeNode.name], child); } }); } }; /** - * Persist the current node's expanded/collapsed state in localStorage. + * Persist the current branch's expanded/collapsed state in localStorage. */ - storeExpandedNodes() { + storeExpandedBranches() { if (!this.parent) { // The main node may not be collapsable or expandable. // Because of this we have to store the expanded state of their direct children. - this.children.forEach((child) => child.storeExpandedNodes()); + this.children.forEach((child) => child.storeExpandedBranches()); } - const session = sessionService.get(); const key = session.personid.toString(); - const data = this.storage.getLocalItem(key) ?? {}; - - // We traverse the path to reach the parent object of this node - let parentNode = data; + const data = this.openBranchStateStorage.getLocalItem(key) ?? {}; + // We traverse the path to reach the parent branch of this node + let branchState = data; for (let i = 0; i < this.path.length - 1; i++) { const pathKey = this.path[i]; - if (!parentNode[pathKey]) { + if (!branchState[pathKey]) { if (!this.open) { - // Cannot remove marked node because parent path does not exist - // Due to this the marked node also does not exist (so there is nothing to remove) + // Cannot remove marked branch because parent path does not exist + // Due to this the marked branch also does not exist (so there is nothing to remove) return; } - - // Parent path does not exist, we create it here so we can mark a deeper node - parentNode[pathKey] = {}; + // Parent path does not exist, we create it here so we can mark a deeper branch + branchState[pathKey] = {}; } - - parentNode = parentNode[pathKey]; + branchState = branchState[pathKey]; } - if (this.open) { - this._markExpandedNodesRecursive(parentNode, this); - this.storage.setLocalItem(key, data); - } else if (parentNode[this.name]) { - // Deleting from `parentNode` directly updates the `data` object - delete parentNode[this.name]; - this.storage.setLocalItem(key, data); + this._markExpandedBranchesRecursive(branchState, this); + this.openBranchStateStorage.setLocalItem(key, data); + } else if (branchState[this.name]) { + // Deleting from `branchState` directly updates the `data` object + delete branchState[this.name]; + this.openBranchStateStorage.setLocalItem(key, data); } } @@ -142,13 +304,13 @@ export default class ObjectTree extends Observable { * @param {object} data - The current level in the hierarchical data object where nodes are stored. * @param {ObjectTree} treeNode - The tree node whose expanded state should be stored. */ - _markExpandedNodesRecursive(data, treeNode) { + _markExpandedBranchesRecursive(data, treeNode) { if (!data[treeNode.name]) { data[treeNode.name] = {}; } treeNode.children .filter((child) => child.open) - .forEach((child) => this._markExpandedNodesRecursive(data[treeNode.name], child)); + .forEach((child) => this._markExpandedBranchesRecursive(data[treeNode.name], child)); }; /** @@ -157,7 +319,7 @@ export default class ObjectTree extends Observable { */ toggle() { this.open = !this.open; - this.storeExpandedNodes(); + this.storeExpandedBranches(); this.notify(); } @@ -166,7 +328,7 @@ export default class ObjectTree extends Observable { */ closeAll() { this._closeAllRecursive(); - this.storeExpandedNodes(); + this.storeExpandedBranches(); this.notify(); } @@ -231,23 +393,13 @@ export default class ObjectTree extends Observable { subtree._addChild(object, path, fullPath); } - /** - * Add a single object as a child node - * @param {object} object - child to be added - */ - addOneChild(object) { - this._addChild(object); - this.loadExpandedNodes(); - this.notify(); - } - /** * Add a list of objects as child nodes * @param {Array} objects - children to be added */ addChildren(objects) { objects.forEach((object) => this._addChild(object)); - this.loadExpandedNodes(); + this.loadExpandedBranches(); this.notify(); } } diff --git a/QualityControl/public/object/QCObject.js b/QualityControl/public/object/QCObject.js index 840215994..e6e048e1f 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -42,6 +42,8 @@ export default class QCObject extends BaseViewModel { this.searchInput = ''; // String - content of input search this.searchResult = []; // Array - result list of search + this.focusedSearchResult = null; // Object - focused item in search results for keyboard navigation + this.sortBy = { field: 'name', title: 'Name', @@ -82,6 +84,68 @@ export default class QCObject extends BaseViewModel { this.notify(); } + /** + * Set focused search result by its object name/pathString + * @param {string} path - object path/name to be focused + */ + setFocusedSearchResultByPath(path) { + const result = this.searchResult.find((item) => item.name === path); + if (!result) { + return; + } + this.focusedSearchResult = result; + this.notify(); + } + + /** + * Set the focused search result to the next or previous item based on offset + * @param {number} offset - The offset to move the focus by (positive or negative) + */ + setFocusedSearchResultByOffset(offset) { + if (!Number.isInteger(offset) || offset === 0 || !this.searchResult?.length) { + return; // Invalid offset or empty search result + } + if (this.focusedSearchResult) { + const clampIndex = (index) => Math.min(Math.max(index, 0), this.searchResult.length - 1); + const currentIndex = this.searchResult.findIndex(({ name }) => name === this.focusedSearchResult?.name); + // Move focus by offset if found, else focus to first result + const nextIndex = currentIndex === -1 ? 0 : currentIndex + offset; + this.focusedSearchResult = this.searchResult[clampIndex(nextIndex)]; + this.notify(); + this._scrollFocusedSearchResultIntoView(); + } else { + // If no focused result, focus the first result + [this.focusedSearchResult] = this.searchResult; + this.notify(); + this._scrollFocusedSearchResultIntoView(); + } + } + + /** + * Scroll the focused search result into view within the scrollable container + * @returns {undefined} + */ + _scrollFocusedSearchResultIntoView() { + const container = document.getElementById('object-list-scroll'); + if (!container || !this.focusedSearchResult) { + return; + } + const focusedIndex = this.searchResult.findIndex(({ name }) => name === this.focusedSearchResult.name); + if (focusedIndex === -1) { + return; + } + const rowHeight = 33.6; + const rowTop = focusedIndex * rowHeight; + const rowBottom = rowTop + rowHeight; + const viewTop = container.scrollTop; + const viewBottom = viewTop + container.clientHeight; + if (rowTop < viewTop) { + container.scrollTop = rowTop; + } else if (rowBottom > viewBottom) { + container.scrollTop = rowBottom - container.clientHeight; + } + } + /** * Set searched items table UI sizes to allow virtual scrolling * @param {number} scrollTop - position of the user's scroll cursor @@ -391,6 +455,7 @@ export default class QCObject extends BaseViewModel { } else { await this.loadObjectByName(this.selected.name); } + this.notify(); } @@ -404,6 +469,8 @@ export default class QCObject extends BaseViewModel { this._computeFilters(); this.sortListByField(this.searchResult, this.sortBy.field, this.sortBy.order); + this.focusedSearchResult = null; + this.notify(); } diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 1dd7f1491..54c57a332 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -37,6 +37,7 @@ export default (model) => { h('.flex-row', { style: 'flex-grow: 1; height: 0;' }, [ h('.flex-column.scroll-y', { key: 'object-tree-scroll-container', + id: 'object-tree-scroll-container', style: { width: object.selected ? `${leftPanelWidthPercent}%` : '100%', }, @@ -45,14 +46,13 @@ export default (model) => { Loading: () => h('.absolute-fill.flex-column.items-center.justify-center.f5', [spinner(5), h('', 'Loading Objects')]), Success: () => { - const searchInput = object?.searchInput?.trim() ?? ''; - if (searchInput !== '') { - const objectsLoaded = object.list; - const objectsToDisplay = objectsLoaded.filter((qcObject) => - qcObject.name.toLowerCase().includes(searchInput.toLowerCase())); + if (object.searchInput === '') { + return tableShow(model); + } else { + const objectsToDisplay = object.list.filter((qcObject) => + qcObject.name.toLowerCase().includes(object.searchInput.toLowerCase().trim())); return virtualTable(model, 'main', objectsToDisplay); } - return tableShow(model); }, Failure: () => null, // Notification is displayed })), @@ -77,7 +77,7 @@ export default (model) => { */ function objectPanel(model) { const selectedObjectName = model.object.selected.name; - if (model.object.objects && model.object.objects[selectedObjectName]) { + if (model.object.objects?.[selectedObjectName]) { return model.object.objects[selectedObjectName].match({ NotAsked: () => null, Loading: () => @@ -199,7 +199,7 @@ const treeRows = (model) => !model.object.tree ? * @returns {vnode[]} - virtual node element */ function treeRow(model, tree, level = 0) { - const { pathString, open, children, object, name } = tree; + const { index, open, children, object, name } = tree; const childRow = open ? children.flatMap((children) => treeRow(model, children, level + 1)) @@ -207,13 +207,22 @@ function treeRow(model, tree, level = 0) { const rows = []; + let className = ''; + if (model.object.selected && object === model.object.selected) { + className = 'table-primary'; // Selected object + } else if (index === model.object.tree.focusedNode?.index) { + className = 'focused-node'; // Focused node + } + if (object) { // Add a leaf row (final element; cannot be expanded further) - const className = object === model.object.selected ? 'table-primary' : ''; const leaf = treeRowElement( - pathString, + index, name, - () => model.object.select(object), + () => { + model.object.tree.setFocusedNodeByIndex(index); + model.object.select(object); + }, iconBarChart, className, { @@ -225,11 +234,14 @@ function treeRow(model, tree, level = 0) { if (children.length > 0) { // Add a branch row (expandable / collapsible element) const branch = treeRowElement( - pathString, + index, name, - () => tree.toggle(), + () => { + model.object.tree.setFocusedNodeByIndex(index); + tree.toggle(); + }, open ? iconCaretBottom : iconCaretRight, - '', + className, { paddingLeft: `${level + 0.3}em`, }, @@ -254,7 +266,7 @@ function treeRow(model, tree, level = 0) { const treeRowElement = (key, title, onclick, icon, className = '', style = {}) => h('tr.object-selectable', { key, - id: key, + id: `tree-node-${key}`, title, onclick, class: className, diff --git a/QualityControl/public/object/virtualTable.js b/QualityControl/public/object/virtualTable.js index 29ebda4d4..a78529c0c 100644 --- a/QualityControl/public/object/virtualTable.js +++ b/QualityControl/public/object/virtualTable.js @@ -13,8 +13,9 @@ */ import { h, iconBarChart } from '/js/src/index.js'; +import { OBJECT_LIST_ROW_HEIGHT, OBJECT_LIST_SIDE_ROW_HEIGHT } from '../common/constants/ui.js'; -let ROW_HEIGHT = 33.6; +let ROW_HEIGHT = OBJECT_LIST_ROW_HEIGHT; let FONT = ''; /** @@ -25,13 +26,14 @@ let FONT = ''; * @returns {vnode} - virtual node element */ export default function virtualTable(model, location = 'main', objects = []) { - ROW_HEIGHT = location === 'side' ? 29.4 : 33.6; - FONT = location === 'side' ? '.f6' : ''; + const isLocationSide = location === 'side'; + ROW_HEIGHT = isLocationSide ? OBJECT_LIST_SIDE_ROW_HEIGHT : OBJECT_LIST_ROW_HEIGHT; + FONT = isLocationSide ? '.f6' : ''; return h('.flex-grow.flex-column', { }, [ location !== 'side' && tableHeader(), h( - '.scroll-y.animate-width', + '#object-list-scroll.scroll-y.animate-width', tableContainerHooks(model), h( '', @@ -63,11 +65,25 @@ export default function virtualTable(model, location = 'main', objects = []) { * @param {string} location - location of the object * @returns {vnode} - virtual node element */ -const objectFullRow = (model, item, location) => - h('tr.object-selectable', { +const objectFullRow = (model, item, location) => { + const isSelected = item && item === model.object.selected; + const isFocused = item && item === model.object.focusedSearchResult; + + let className = ''; + if (isSelected) { + className = 'table-primary'; // Selected object + } else if (isFocused) { + className = 'focused-node'; // Focused node + } + + return h('tr.object-selectable', { + id: `object-row-${item.name}`, key: item.name, title: item.name, - onclick: () => model.object.select(item), + onclick: () => { + model.object.select(item); + model.object.setFocusedSearchResultByPath(item.name); + }, ondblclick: () => { if (location === 'side') { model.layout.addItem(item.name); @@ -84,7 +100,7 @@ const objectFullRow = (model, item, location) => model.layout.moveTabObjectStop(); } }, - class: item && item === model.object.selected ? 'table-primary' : '', + class: className, draggable: location === 'side', }, [ h('td.highlight.text-ellipsis', [ @@ -93,6 +109,7 @@ const objectFullRow = (model, item, location) => item.name, ]), ]); +}; /** * Create a table header separately so that it does not get included diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 31284c3e5..614bdb23d 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -244,7 +244,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 personid = await page.evaluate(() => window.model.session.personid); - const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`; + const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_BRANCHES}-${personid}`; await page.locator(selector).click(); const localStorageBefore = await getLocalStorageAsJson(page, storageKey); @@ -294,7 +294,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) async () => { const selector = '#collapse-tree-button'; const personid = await page.evaluate(() => window.model.session.personid); - const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_NODES}-${personid}`; + const storageKey = `${StorageKeysEnum.OBJECT_TREE_OPEN_BRANCHES}-${personid}`; await page.locator(selector).click(); await delay(100); @@ -337,4 +337,114 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) deepStrictEqual(options, ['', 'runType1', 'runType2']); }, ); + + await testParent.test( + 'should navigate object tree and search results with arrow keys and enter key', + { timeout }, + async () => { + await page.goto(`${url}${OBJECT_TREE_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); + + // Focus first node + await page.keyboard.press('ArrowDown'); + await delay(200); + const isFirstObjectFocused = await page.evaluate(() => { + const selectedNode = document.querySelector('tr.object-selectable'); + return selectedNode?.classList.contains('focused-node'); + }); + strictEqual(isFirstObjectFocused, true, 'The first object is not focused.'); + + // Focus second node and expand + await page.keyboard.press('ArrowDown'); // Focus second node + await page.keyboard.press('ArrowRight'); // Expand focused node + await delay(200); + const isNodeExpanded = await page.evaluate(() => { + const [, selectedNode] = document.querySelectorAll('tr.object-selectable'); + return selectedNode?.classList.contains('focused-node'); + }); + strictEqual(isNodeExpanded, true, 'The focused node was not expanded on pressing ArrowRight key.'); + + // Focus third node, expand and select a leaf node + await page.keyboard.press('ArrowDown'); // Focus third node + await page.keyboard.press('ArrowRight'); // Expand focused node + await page.keyboard.press('ArrowDown'); // Focus fourth node + await page.keyboard.press('Enter'); // Select focused leaf node + await delay(500); + const isObjectSelected = await page.evaluate(() => model.object.selected !== undefined); + const isObjectPlotOpened = await page.evaluate(() => { + const objectPanel = document.querySelector('#qcObjectInfoPanel'); + return objectPanel !== null && objectPanel !== undefined; + }); + strictEqual(isObjectSelected, true, 'Focused leaf node was not selected on pressing ArrowRight key.'); + strictEqual(isObjectPlotOpened, true, 'Object plot panel is not opened after selecting the focused leaf node.'); + + // Collapse parent node of the focused leaf node + await page.keyboard.press('ArrowLeft'); + await delay(200); + const nodeCountAfterCollapse = await page.evaluate(() => { + const nodes = document.querySelectorAll('tr.object-selectable'); + return nodes.length; + }); + strictEqual( + nodeCountAfterCollapse, + 3, + 'The object tree navigation does not have exactly 3 nodes after collapsing the parent.', + ); + + // Focus previous node + await page.keyboard.press('ArrowUp'); + await delay(200); + const isSecondNodeHighlightedAgain = await page.evaluate(() => { + const [, selectedNode] = document.querySelectorAll('tr.object-selectable'); + return selectedNode?.classList.contains('focused-node'); + }); + strictEqual(isSecondNodeHighlightedAgain, true, 'The second node is not highlighted after pressing ArrowUp key.'); + + // Collapse tree + await page.keyboard.press('ArrowLeft'); + await delay(200); + const isNodeCollapsed = await page.evaluate(() => { + const nodes = document.querySelectorAll('tr.object-selectable'); + return nodes.length < 3; // Check if there are less than 3 nodes + }); + strictEqual(isNodeCollapsed, true, 'The third node is still present after collapsing the second node.'); + }, + ); + + await testParent.test( + 'should navigate object tree and search results with arrow keys and enter key when search active', + { timeout }, + async () => { + await page.goto(`${url}${OBJECT_TREE_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); + await page.focus('#searchObjectTree'); + await page.type('#searchObjectTree', 'qc/test/object'); + + // Focus first object in search results + await page.keyboard.press('ArrowDown'); + await delay(200); + const isFirstObjectHighlighted = await page.evaluate(() => { + const selectedNode = document.querySelector('tr.object-selectable'); + return selectedNode?.classList.contains('focused-node'); + }); + strictEqual(isFirstObjectHighlighted, true, 'The first object in search results is not highlighted.'); + + // Select focused object + await page.keyboard.press('Enter'); + await delay(500); + const isObjectSelected = await page.evaluate(() => model.object.selected !== undefined); + const isObjectPlotOpened = await page.evaluate(() => { + const objectPanel = document.querySelector('#qcObjectInfoPanel'); + return objectPanel !== null && objectPanel !== undefined; + }); + strictEqual( + isObjectSelected, + true, + 'The focused object in search results was not selected on pressing ArrowRight key.', + ); + strictEqual( + isObjectPlotOpened, + true, + 'The object plot panel is not opened after selecting the focused object in search results.', + ); + }, + ); };