Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2e8b20c
Tree stores expanded state of nodes in local storage
hehoon Dec 13, 2025
f19578d
Add a quick (and hacky) workaround for local storage reset due to non…
hehoon Dec 13, 2025
3f38aaa
Add a test to make sure the local storage is updated upon clicking a …
hehoon Dec 13, 2025
8a0a5a8
Refactor tests
hehoon Dec 15, 2025
c10f0ed
Remove unused imports
hehoon Dec 15, 2025
d370eab
Add keyboard arrow navigation for object tree
crashdance Jan 5, 2026
21e4964
fix: keyboard navigation in object tree
crashdance Jan 5, 2026
ba3675d
chore: remove unused storage key
crashdance Jan 5, 2026
2081fe2
tests: add arrow key navigation tests for object tree
crashdance Jan 6, 2026
f53400e
Merge branch 'dev' into feature/QCG/OGUI-561/browse-object-tree-with-…
crashdance Jan 7, 2026
d087737
fix: merge conflicts objects tree class
crashdance Jan 7, 2026
00a8782
Merge branch 'dev' into feature/QCG/OGUI-561/browse-object-tree-with-…
crashdance Jan 8, 2026
b3b8a90
refactor object tree navigation and cleanup unused code
crashdance Jan 8, 2026
d6f788b
fix: ensure object selection only occurs if focused node is present
crashdance Jan 8, 2026
1fc2232
refactor: remove unused addOneChild method from ObjectTree class
crashdance Jan 8, 2026
4cd1979
tests: add arrow left key navigation test and refactor object tree tests
crashdance Jan 8, 2026
6c52974
refactor: simplify comments for object tree key navigation tests
crashdance Jan 8, 2026
897c29e
feat: keyboard navigation for virtual table when search input is active
crashdance Jan 9, 2026
06aeabf
tests: add tests key navigation object tree page when search is active
crashdance Jan 9, 2026
82302a9
feat: enhance keyboard navigation in search results and move focus to…
crashdance Jan 10, 2026
307137e
tests: refine object tree keyboard navigation tests and added tests f…
crashdance Jan 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions QualityControl/public/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,61 @@
} else if (code === 27 && this.isImportVisible) {
this.layout.resetImport();
}

if (this.router.params.page === 'objectTree') {
const isUp = code === 38;
const isDown = code === 40;
const isLeft = code === 37;
const isRightOrEnter = code === 39 || code === 13;
const searchActive = Boolean(this.object.searchInput?.trim());
// Use virtual table navigation when search is active
if (searchActive) {
const results = this.object.searchResult || [];
if (!results.length) {
return;
}
const focusedIndex = this.object.focusedSearchIndex ?? -1;
if (isUp) {
const nextIndex = focusedIndex > 0 ? focusedIndex - 1 : 0;
this.object.setFocusedSearchResultAt(nextIndex);
return;
}
if (isDown) {
const nextIndex = focusedIndex >= 0 ? Math.min(results.length - 1, focusedIndex + 1) : 0;
this.object.setFocusedSearchResultAt(nextIndex);
return;
}
if (isRightOrEnter) {
if (this.object.focusedSearchResult) {
this.object.select(this.object.focusedSearchResult);
}
return;
}
} else {
// No search, use tree navigation
if (isUp) {
this.object.tree.focusPreviousNode();
return;
}
if (isDown) {
this.object.tree.focusNextNode();
return;
}
if (isLeft) {
this.object.tree.collapseFocusedNode();
return;
}
if (isRightOrEnter) {
const focusedObject = this.object.tree.focusedNode?.object;
if (focusedObject) {
this.object.select(focusedObject);
} else {
this.object.tree.expandFocusedNode();
}
return;
}
}
}
}

/**
Expand Down Expand Up @@ -276,7 +331,7 @@

/**
* Clear URL parameters and redirect to a certain page
* @param {*} pageName - name of the page to be redirected to

Check warning on line 334 in QualityControl/public/Model.js

View workflow job for this annotation

GitHub Actions / Check eslint rules on ubuntu-latest

Prefer a more specific type to `*`
* @returns {undefined}
*/
clearURL(pageName) {
Expand Down
2 changes: 2 additions & 0 deletions QualityControl/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
155 changes: 145 additions & 10 deletions QualityControl/public/object/ObjectTree.class.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default class ObjectTree extends Observable {
constructor(name, parent) {
super();
this.storage = new BrowserStorage(StorageKeysEnum.OBJECT_TREE_OPEN_NODES);
this.focusedNode = null; // Currently focused node for navigation
this.initTree(name, parent);
}

Expand All @@ -48,6 +49,150 @@ export default class ObjectTree extends Observable {
this.pathString = ''; // 'A/B'
}

/**
* Focus the node identified by a slash-separated path (e.g. "A/B/C").
* Does nothing if path is empty or not found.
* @param {string} pathString - A slash-separated string representing the path to the node (e.g., "A/B/C").
* @returns {void}
*/
focusNodeByPath(pathString) {
if (!pathString) {
return;
}
const parts = pathString.split('/').filter(Boolean);
const findNode = (node, remainingParts) => {
if (remainingParts.length === 0) {
return node;
}
const [nextPart, ...rest] = remainingParts;
const nextNode = node.children.find((child) => child.name === nextPart);
return nextNode ? findNode(nextNode, rest) : null;
};
const targetNode = findNode(this, parts);
if (targetNode) {
this._setFocusedNode(targetNode);
}
}

/**
* Set the currently focused node
* @param {ObjectTree} node - node to be focused
* @returns {undefined}
*/
_setFocusedNode(node) {
this.focusedNode = node;
this.notify();
}

/**
* Collapse the currently focused node or move focus to its parent.
* @returns {undefined}
*/
collapseFocusedNode() {
if (!this.focusedNode) {
return;
}
// If focus is on a object, collapse its parent (if any) and focus the parent.
if (this.focusedNode.object) {
const { parent } = this.focusedNode;
if (!parent) {
return;
}
parent.open = false;
this._setFocusedNode(parent);
return;
}
// If focus is on a branch: collapse it if open, otherwise move focus to parent.
if (this.focusedNode.open && this.focusedNode.children.length > 0) {
this.focusedNode.toggle(); // collapse current branch, keep focus here
return;
}
if (this.focusedNode.parent) {
this._setFocusedNode(this.focusedNode.parent);
}
}

/**
* If focus is on a branch: expand it if closed, otherwise move focus to first child.
* @returns {undefined}
*/
expandFocusedNode() {
if (!this.focusedNode) {
return;
}
if (!this.focusedNode.open && this.focusedNode.children.length > 0) {
this.focusedNode.toggle();
} else if (this.focusedNode.open && this.focusedNode.children.length > 0) {
this._setFocusedNode(this.focusedNode.children[0]);
}
}

/**
* Get all visible nodes in the tree (for navigation)
* @returns {Array.<ObjectTree>} - 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.
Expand Down Expand Up @@ -231,16 +376,6 @@ 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<object>} objects - children to be added
Expand Down
47 changes: 47 additions & 0 deletions QualityControl/public/object/QCObject.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export default class QCObject extends BaseViewModel {

this.searchInput = ''; // String - content of input search
this.searchResult = []; // Array<object> - result list of search
this.focusedSearchResult = null; // Object - focused item in search results for keyboard navigation
this.focusedSearchIndex = -1; // Number - index of focused search result

this.sortBy = {
field: 'name',
title: 'Name',
Expand Down Expand Up @@ -82,6 +85,36 @@ export default class QCObject extends BaseViewModel {
this.notify();
}

/**
* Set focused item in search results (used by keyboard navigation).
* @param {number} nextIndex - index of the next object to be focused
* @returns {undefined}
*/
setFocusedSearchResult(nextIndex) {
if (!this.searchResult.length) {
return;
}
if (nextIndex < 0 || nextIndex >= this.searchResult.length) {
return;
}
if (nextIndex === this.focusedSearchIndex &&
this.searchResult[nextIndex] === this.focusedSearchResult) {
return;
}
this.focusedSearchIndex = nextIndex;
this.focusedSearchResult = this.searchResult[nextIndex];
this.notify();
}

/**
* Focus a search result by index without a linear search.
* @param {number} index - index to focus
* @returns {undefined}
*/
setFocusedSearchResultAt(index) {
this.setFocusedSearchResult(index);
}

/**
* Set searched items table UI sizes to allow virtual scrolling
* @param {number} scrollTop - position of the user's scroll cursor
Expand Down Expand Up @@ -391,6 +424,17 @@ export default class QCObject extends BaseViewModel {
} else {
await this.loadObjectByName(this.selected.name);
}

// Move focus to selected object
if (this.searchInput === '') {
this.tree.focusNodeByPath(this.selected.name);
} else {
const idx = this.searchResult.findIndex(({ name }) => name === this.selected.name);
if (idx >= 0) {
this.setFocusedSearchResultAt(idx);
}
}

this.notify();
}

Expand All @@ -404,6 +448,9 @@ export default class QCObject extends BaseViewModel {
this._computeFilters();

this.sortListByField(this.searchResult, this.sortBy.field, this.sortBy.order);
this.focusedSearchResult = null;
this.focusedSearchIndex = -1;

this.notify();
}

Expand Down
12 changes: 9 additions & 3 deletions QualityControl/public/object/objectTreePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () =>
Expand Down Expand Up @@ -207,9 +207,15 @@ 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 (model.object.tree.focusedNode === tree) {
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,
name,
Expand All @@ -229,7 +235,7 @@ function treeRow(model, tree, level = 0) {
name,
() => tree.toggle(),
open ? iconCaretBottom : iconCaretRight,
'',
className,
{
paddingLeft: `${level + 0.3}em`,
},
Expand Down
17 changes: 14 additions & 3 deletions QualityControl/public/object/virtualTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,18 @@ 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 && model.object.focusedSearchResult && item.name === model.object.focusedSearchResult.name;

let className = '';
if (isSelected) {
className = 'table-primary'; // Selected object
} else if (isFocused) {
className = 'focused-node'; // Focused node
}

return h('tr.object-selectable', {
key: item.name,
title: item.name,
onclick: () => model.object.select(item),
Expand All @@ -84,7 +94,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', [
Expand All @@ -93,6 +103,7 @@ const objectFullRow = (model, item, location) =>
item.name,
]),
]);
};

/**
* Create a table header separately so that it does not get included
Expand Down
Loading
Loading