diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index e30163478..1e034e96b 100644 --- a/QualityControl/public/Model.js +++ b/QualityControl/public/Model.js @@ -132,17 +132,36 @@ export default class Model extends Observable { */ handleKeyboardDown(e) { // Console.log(`e.keyCode=${e.keyCode}, e.metaKey=${e.metaKey}, e.ctrlKey=${e.ctrlKey}, e.altKey=${e.altKey}`); - const code = e.keyCode; - + const { key } = e; // Delete key + layout page + object select => delete this object - if (code === 8 && + if (key === '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 (key === 'Escape' && this.isImportVisible) { this.layout.resetImport(); } + if ( + this.router.params.page === 'objectTree' && + ( + key === 'ArrowUp' || + key === 'ArrowDown' || + key === 'Enter' || + key === 'ArrowLeft' || + key === 'ArrowRight' + ) + ) { + e.preventDefault(); // Prevent scrolling the page + const searchActive = Boolean(this.object.searchInput?.trim()); + if (searchActive) { + // Search navigation + this.object.handleKeyboardNavigationSearchResults(key); + } else { + // Tree navigation + this.object.tree.handleKeyboardNavigation(key, (selectedObject) => this.object.select(selectedObject)); + } + } } /** @@ -280,7 +299,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 05dcac3bd..38593708e 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..2dfc2e749 --- /dev/null +++ b/QualityControl/public/common/constants/ui.js @@ -0,0 +1,15 @@ +/** + * @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_SIDE_ROW_HEIGHT = 29.4; diff --git a/QualityControl/public/common/enums/storageKeys.enum.js b/QualityControl/public/common/enums/storageKeys.enum.js index b90f45425..9048f86ee 100644 --- a/QualityControl/public/common/enums/storageKeys.enum.js +++ b/QualityControl/public/common/enums/storageKeys.enum.js @@ -20,6 +20,6 @@ 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_BRANCH_STATE: 'object-tree-open-branch-state', NOTIFICATION_START_RUN_SETTING: 'notification-start-run-setting', - OBJECT_TREE_OPEN_NODES: 'object-tree-open-nodes', }); diff --git a/QualityControl/public/object/ObjectTree.class.js b/QualityControl/public/object/ObjectTree.class.js index 861f50ff0..5fa9b0869 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.children.length === 0 && this.object !== null; + } + /** * Method to instantiate/reset the tree * @param {string} name - name of the tree to be initialized @@ -48,31 +64,208 @@ export default class ObjectTree extends Observable { this.pathString = ''; // 'A/B' } + /** + * Handles keyboard navigation for the tree + * @param {string} key - The key value for navigation (e.g., "ArrowUp", "ArrowDown") + * @param {(object: object) => void} onSelectObject - Callback function to select an object + */ + handleKeyboardNavigation(key, onSelectObject) { + const selectOrExpand = () => { + if (this.focusedNode?.isLeaf && this.focusedNode.object) { + onSelectObject(this.focusedNode.object); + return; + } + this._expandFocusedNode(); + }; + const actions = { + ['ArrowLeft']: () => this._collapseFocusedNode(), + ['ArrowRight']: selectOrExpand, + ['Enter']: selectOrExpand, + ['ArrowUp']: () => this._focusPreviousNode(), + ['ArrowDown']: () => this._focusNextNode(), + }; + const action = actions[key]; + if (action) { + action(); + } + } + + /** + * 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); + // At the last visible node, do nothing + if (idx >= visible.length - 1) { + return; + } + // Nothing focused yet -> focus first visible node + if (!this.focusedNode || idx === -1) { + const [first] = visible; + this._setFocusedNode(first); + 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 +273,55 @@ 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 two children share the same name, expand the one that has children + const child = + treeNode.children.find((c) => c.name === childName && c.isBranch) || + treeNode.children.find((c) => c.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 +333,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 +348,7 @@ export default class ObjectTree extends Observable { */ toggle() { this.open = !this.open; - this.storeExpandedNodes(); + this._storeExpandedBranches(); this.notify(); } @@ -166,7 +357,7 @@ export default class ObjectTree extends Observable { */ closeAll() { this._closeAllRecursive(); - this.storeExpandedNodes(); + this._storeExpandedBranches(); this.notify(); } @@ -202,52 +393,39 @@ export default class ObjectTree extends Observable { this._addChild(object, path); return; } - // Case end of path, associate the object to 'this' node if (path.length === 0) { this.object = object; return; } - // Case we need to pass to subtree const name = path.shift(); const fullPath = [...pathParent, name]; - let subtree = this.children.find((children) => children.name === name); - - // Subtree does not exist yet + const requiresBranch = path.length > 0; + // Find if subtree already exists + const matchesNameAndType = requiresBranch + ? (c) => c.name === name && c.isBranch + : (c) => c.name === name && c.isLeaf; + let subtree = this.children.find(matchesNameAndType); + // Subtree does not exist yet - create it, then pass to child if (!subtree) { - /* - * Create it and push as child - * Listen also for changes to bubble it until root - */ subtree = new ObjectTree(name, this); subtree.path = fullPath; subtree.pathString = fullPath.join('/'); this.children.push(subtree); - subtree.observe(() => this.notify()); + subtree.observe(() => this.notify()); // listen for changes and bubble to root } - // Pass to child 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 72780449e..cbbeffc0b 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -18,6 +18,7 @@ import { prettyFormatDate, setBrowserTabTitle } from './../common/utils.js'; import { isObjectOfTypeChecker } from './../library/qcObject/utils.js'; import { BaseViewModel } from '../common/abstracts/BaseViewModel.js'; import { StorageKeysEnum } from '../common/enums/storageKeys.enum.js'; +import { OBJECT_LIST_SIDE_ROW_HEIGHT } from '../common/constants/ui.js'; /** * Model namespace for all about QC's objects (not javascript objects) @@ -42,6 +43,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', @@ -59,6 +62,25 @@ export default class QCObject extends BaseViewModel { this._initializeLeftPanelWidth(); } + handleKeyboardNavigationSearchResults(key) { + if (!this.searchResult.length) { + return; + } + const select = () => { + this.model.object.select(this.focusedSearchResult); + }; + const actions = { + ['ArrowRight']: () => select(), + ['Enter']: () => select(), + ['ArrowUp']: () => this._setFocusedSearchResultByOffset(-1), + ['ArrowDown']: () => this._setFocusedSearchResultByOffset(1), + }; + const action = actions[key]; + if (action) { + action(); + } + } + /** * Initialize left panel width from local storage or set to default * @returns {undefined} @@ -81,6 +103,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 = OBJECT_LIST_SIDE_ROW_HEIGHT; + 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 @@ -380,6 +464,7 @@ export default class QCObject extends BaseViewModel { } else { await this.loadObjectByName(this.selected.name); } + this.notify(); } @@ -393,6 +478,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 7a9b19716..be6c66163 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -47,6 +47,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%', }, @@ -93,7 +94,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: () => @@ -279,7 +280,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)) @@ -287,13 +288,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.select(object); + model.object.tree.setFocusedNodeByIndex(index); + }, iconBarChart, className, { @@ -305,11 +315,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`, }, @@ -334,7 +347,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..c9aebb8f1 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_SIDE_ROW_HEIGHT } from '../common/constants/ui.js'; -let ROW_HEIGHT = 33.6; +let ROW_HEIGHT = OBJECT_LIST_SIDE_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 = OBJECT_LIST_SIDE_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 e4382d645..2ad8f2fdb 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 > 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_BRANCH_STATE}-${personid}`; await page.locator(selector).click(); const localStorageBefore = await getLocalStorageAsJson(page, storageKey); @@ -291,7 +291,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_BRANCH_STATE}-${personid}`; await page.locator(selector).click(); await delay(100); @@ -334,4 +334,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.', + ); + }, + ); };