From b44f9ddf4ec0328b2acce630d6e1cff42f6bde0f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 15 May 2026 17:57:53 +0200 Subject: [PATCH 01/24] [OGUI-1453] State and model base --- InfoLogger/public/Model.js | 2 ++ InfoLogger/public/log/Log.js | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 308633a6a..76397a171 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -209,6 +209,7 @@ export default class Model extends Observable { // Enter if ((code === 13 && !this.messageFocused || code === 13 && e.metaKey) && !this.log.isLiveModeEnabled()) { this.log.query(); + this.log.hideContextMenu(); } if (!this.messageFocused) { // don't listen to keys when it comes from an input (they transform into letters) @@ -223,6 +224,7 @@ export default class Model extends Observable { case 27: // escape this.log.removeLogDownloadContent(); this.accountMenuEnabled = false; + this.log.hideContextMenu(); break; case 37: // left if (e.altKey) { diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index a7f615841..67b9e9d94 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -70,6 +70,30 @@ export default class Log extends Observable { this.dom = { table: '', }; + + this.contextMenu = { + isOpen: false, + field: null, + value: null, + x: 0, + y: 0, + }; + } + + showContextMenu(field, value, x, y) { + this.contextMenu = { + isOpen: true, + field, + value, + x, + y, + }; + this.notify(); + } + + hideContextMenu() { + this.contextMenu.isOpen = false; + this.notify(); } /** From 791ce46f5544e40b3e8adcb0fd52de3f781f8a90 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 15 May 2026 17:58:32 +0200 Subject: [PATCH 02/24] [OGUI-1453] Context menu component + styles --- InfoLogger/public/app.css | 75 ++++++++++++++ InfoLogger/public/log/cellContextMenu.js | 118 +++++++++++++++++++++++ InfoLogger/public/view.js | 2 + 3 files changed, 195 insertions(+) create mode 100644 InfoLogger/public/log/cellContextMenu.js diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index f3d4cba4e..a1c9e99b3 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -95,3 +95,78 @@ footer { border-top: 1px solid var(--color-gray); } .text-area-for-message:focus { width: 50%; height: 10rem !important; right: 0; position: absolute; } a.disabled { pointer-events: none; cursor: default; } + +.cell-context-menu-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1100; +} + +.cell-context-menu { + position: fixed; + z-index: 1101; + display: flex; + flex-direction: column; + background-color: #fff; + border: 1px solid rgba(0,0,0,.15); + border-radius: .25rem; + min-width: 220px; + max-width: 220px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,.1); +} + +.cell-context-menu-item { + cursor: pointer; + text-decoration: none; + padding: 0.55rem 0.75rem; + line-height: 1em; + color: var(--color-gray-darker); + font-weight: 100; + display: flex; + flex-direction: row; + user-select: none; +} + +.cell-context-menu-item + .cell-context-menu-item { + border-top: 1px solid var(--color-gray-light); +} + +.cell-context-menu:first-child { + border-radius: 0.25em 0.25em 0 0; +} + +.cell-context-menu-item:last-child { + border-radius: 0 0 0.25em 0.25em; +} + +.cell-context-menu-item:hover { + text-decoration: none; + background-color: var(--color-gray-dark); + color: var(--color-gray-lighter); +} + +.cell-context-menu-item:active { + background-color: var(--color-gray-dark); + color: var(--color-black); +} + +.cell-context-menu-item.selected { + background-color: var(--color-primary); + color: var(--color-white); +} + +.cell-context-menu-header { + padding: 0.4rem 0.75rem; + color: var(--color-gray-darker); + background-color: var(--color-gray-light); + border-bottom: 1px solid rgba(0,0,0,.1); + border-radius: 0.25rem 0.25rem 0 0; + user-select: none; + display: flex; + flex-direction: column; + gap: 0.25rem; +} diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js new file mode 100644 index 000000000..a6ac7b397 --- /dev/null +++ b/InfoLogger/public/log/cellContextMenu.js @@ -0,0 +1,118 @@ +/** + * @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 { h } from '/js/src/index.js'; +import { iconCheck, iconBan, iconClipboard, iconTrash } from '/js/src/index.js'; + +const MENU_WIDTH = 220; +const MENU_HEIGHT_ESTIMATE = 90; +const INSPECTOR_WIDTH_REM = 20; +const SEVERITY_CANVAS_WIDTH_PX = 10; + +const remToPx = (rem) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize); + +/** + * Clamp menu position so it stays within the viewport + * @param {number} x mouse x position + * @param {number} y mouse y position + * @param {boolean} inspectorEnabled whether inspector panel is open + * @returns {{left: number, top: number}} clamped menu position + */ +const clampPosition = (x, y, inspectorEnabled) => ({ + left: Math.min( + x, + window.innerWidth - MENU_WIDTH - SEVERITY_CANVAS_WIDTH_PX - (inspectorEnabled ? remToPx(INSPECTOR_WIDTH_REM) : 0), + ), // 10px margin from the edge because of the severity canvas + top: Math.min(y, window.innerHeight - MENU_HEIGHT_ESTIMATE), +}); + +/** + * Context menu for log table cells — allows quick filter actions. + * Rendered at view root with position:fixed to avoid virtual scroll issues. + * @param {Model} model root application model + * @returns {Array|null} rendered menu nodes + */ +export default (model) => { + const { contextMenu } = model.log; + if (!contextMenu.isOpen) { + return null; + } + + const { field, value, x, y } = contextMenu; + const pos = clampPosition(x, y, model.inspectorEnabled); + + const hideMenu = () => model.log.hideContextMenu(); + + const isTimestamp = field === 'timestamp'; + + return [ + // Full-screen transparent overlay to catch click-outside + h('.cell-context-menu-overlay', { + onclick: hideMenu, + oncontextmenu: (e) => { + e.preventDefault(); + hideMenu(); + }, + }), + h('.cell-context-menu', { + style: { + left: `${pos.left}px`, + top: `${pos.top}px`, + }, + }, [ + h('div.cell-context-menu-header.f7', [ + h('span.f7', { style: { fontWeight: 'bold' } }, isTimestamp + ? 'Timestamp' : field.charAt(0).toUpperCase() + field.slice(1)), + h('span.f6.text-ellipsis', { title: value }, value), + ]), + createMenuItem(iconCheck(), 'var(--color-success)', isTimestamp ? 'From' : 'Match', () => { + model.log.setCriteria(field, isTimestamp ? 'since' : 'match', value); + hideMenu(); + }), + createMenuItem(iconBan(), 'var(--color-danger)', isTimestamp ? 'To' : 'Exclude', () => { + model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', value); + hideMenu(); + }), + createMenuItem(iconTrash(), 'var(--color-danger)', 'Clear filter', () => { + model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', ''); + model.log.setCriteria(field, isTimestamp ? 'since' : 'match', ''); + hideMenu(); + }), + createMenuItem(iconClipboard(), 'var(--color-primary)', 'Copy', () => { + navigator.clipboard.writeText(value); + hideMenu(); + }), + ]), + ]; +}; + +/** + * Creates menu item for the context menu of a cell with given icon, label and click action. + * @param {string} icon - icon to display in the menu item + * @param {string} iconColor - color of the icon + * @param {string} label - label to display in the menu item + * @param {() => void} onClick - function to execute on click + * @returns {vnode} - the menu item as a vnode + */ +function createMenuItem(icon, iconColor, label, onClick) { + return h('a.cell-context-menu-item.f7', { + onclick: onClick, + }, [ + h('span', { style: { color: iconColor } }, icon), + h( + 'span.flex-row.justify-between.w-100', + h('span.ph2.w-100', { style: { fontWeight: 'bold' } }, label), + ), + ]); +}; diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 8f744afce..c6f80c3f1 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -24,6 +24,7 @@ import tableLogsContent from './log/tableLogsContent.js'; import tableLogsScrollMap from './log/tableLogsScrollMap.js'; import aboutComponent from './about/about.component.js'; import errorComponent from './common/errorComponent.js'; +import cellContextMenu from './log/cellContextMenu.js'; /** * Main view of the application @@ -32,6 +33,7 @@ import errorComponent from './common/errorComponent.js'; */ export default (model) => [ notification(model.notification), + cellContextMenu(model), h('.flex-column absolute-fill', [ h('.shadow-level2', [ h('header.p1.flex-row.f7', [ From f0547542b6ad47c3a7d0c07d499676772a7b9c2c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 15 May 2026 17:59:12 +0200 Subject: [PATCH 03/24] [OGUI-1453] Connect table cells --- InfoLogger/public/log/tableLogsContent.js | 72 ++++++++++++++++++----- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 9c45c283b..3031ef8be 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -71,6 +71,30 @@ const tableLogLine = (model, row) => h('tr.row-hover', { ondblclick: () => model.toggleInspector(), }, tableRows(model, model.table.colsHeader, row)); +const resolveContextMenuData = (model, row, field, content) => { + if (field === 'date') { + return row.timestamp ? { field: 'timestamp', value: String(content) } : null; + } + if (field === 'time') { + return row.timestamp + ? { field: 'timestamp', value: `${model.timezone.format(row.timestamp, 'date')} ${content}` } + : null; + } + return row[field] != null && row[field] !== '' ? { field, value: String(row[field]) } : null; +}; + +const cellWithContextMenu = (model, row, field, content, extraClasses = '', extraAttrs = {}) => + h(`td.cell${extraClasses}`, { + ...extraAttrs, + oncontextmenu: (e) => { + const data = resolveContextMenuData(model, row, field, content); + if (data) { + e.preventDefault(); + model.log.showContextMenu(data.field, data.value, e.clientX, e.clientY); + } + }, + }, content); + /** * Array of table rows * @param {Model} model - root model of the application @@ -82,21 +106,39 @@ const tableRows = (model, colsHeader, row) => [ h('td.cell.text-center', { className: model.log.item === row ? null : severityClass(row.severity) }, row.severity), h('td.cell.text-center.cell-bordered', row.level), - colsHeader.date.visible && h('td.cell.cell-bordered', model.timezone.format(row.timestamp, 'date')), - colsHeader.time.visible && h('td.cell.cell-bordered', model.timezone.format(row.timestamp, model.log.timeFormat)), - colsHeader.hostname.visible && h('td.cell.cell-bordered', row.hostname), - colsHeader.rolename.visible && h('td.cell.cell-bordered', row.rolename), - colsHeader.pid.visible && h('td.cell.cell-bordered', row.pid), - colsHeader.username.visible && h('td.cell.cell-bordered', row.username), - colsHeader.system.visible && h('td.cell.cell-bordered', row.system), - colsHeader.facility.visible && h('td.cell.cell-bordered', row.facility), - colsHeader.detector.visible && h('td.cell.cell-bordered', row.detector), - colsHeader.partition.visible && h('td.cell.cell-bordered', row.partition), - colsHeader.run.visible && h('td.cell.cell-bordered', row.run), - colsHeader.errcode.visible && h('td.cell.cell-bordered', linkToWikiErrors(row.errcode)), - colsHeader.errline.visible && h('td.cell.cell-bordered', row.errline), - colsHeader.errsource.visible && h('td.cell.cell-bordered', row.errsource), - colsHeader.message.visible && h('td.cell.cell-bordered', { title: row.message }, row.message), + colsHeader.date.visible && cellWithContextMenu( + model, + row, + 'date', + model.timezone.format(row.timestamp, 'date'), + '.cell-bordered', + ), + colsHeader.time.visible && cellWithContextMenu( + model, + row, + 'time', + model.timezone.format(row.timestamp, model.log.timeFormat), + '.cell-bordered', + ), + colsHeader.hostname.visible && cellWithContextMenu(model, row, 'hostname', row.hostname, '.cell-bordered'), + colsHeader.rolename.visible && cellWithContextMenu(model, row, 'rolename', row.rolename, '.cell-bordered'), + colsHeader.pid.visible && cellWithContextMenu(model, row, 'pid', row.pid, '.cell-bordered'), + colsHeader.username.visible && cellWithContextMenu(model, row, 'username', row.username, '.cell-bordered'), + colsHeader.system.visible && cellWithContextMenu(model, row, 'system', row.system, '.cell-bordered'), + colsHeader.facility.visible && cellWithContextMenu(model, row, 'facility', row.facility, '.cell-bordered'), + colsHeader.detector.visible && cellWithContextMenu(model, row, 'detector', row.detector, '.cell-bordered'), + colsHeader.partition.visible && cellWithContextMenu(model, row, 'partition', row.partition, '.cell-bordered'), + colsHeader.run.visible && cellWithContextMenu(model, row, 'run', row.run, '.cell-bordered'), + colsHeader.errcode.visible && cellWithContextMenu( + model, + row, + 'errcode', + linkToWikiErrors(row.errcode), + '.cell-bordered', + ), + colsHeader.errline.visible && cellWithContextMenu(model, row, 'errline', row.errline, '.cell-bordered'), + colsHeader.errsource.visible && cellWithContextMenu(model, row, 'errsource', row.errsource, '.cell-bordered'), + colsHeader.message.visible && cellWithContextMenu(model, row, 'message', row.message, '', { title: row.message }), ]; /** From f59d6744a1101a83a035ce2a146a65f2db5e59d4 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 20 May 2026 16:47:56 +0200 Subject: [PATCH 04/24] [OGUI-1543] Added test cases for context menu functionality Tests added cover: - Opening and closing of context menu - Match, exclude, from, to, clear and copy buttons functionality --- .../test/public/log-filter-actions-mocha.js | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 1cba99c65..fa24da27b 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -16,6 +16,43 @@ const assert = require('assert'); const test = require('../mocha-index'); +const isContextMenuOpen = async (page) => { + return await page.evaluate(() => window.model.log.contextMenu.isOpen); +}; + +const openContextMenu = async (page, field, value, x, y) => { + await page.evaluate((field, value, x, y) => { + window.model.log.showContextMenu(field, value, x, y); + }, field, value, x, y); + await page.waitForSelector('.cell-context-menu'); + assert.strictEqual(await isContextMenuOpen(page), true); +}; + +const waitForMatchExcludeButtons = async (page) => { + // wait for function as menu sometimes will render previous labels then update + await page.waitForFunction(() => { + const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .map((label) => label.textContent.trim()); + return labels.length === 4 + && labels[0] === 'Match' + && labels[1] === 'Exclude' + && labels[2] === 'Clear filter' + && labels[3] === 'Copy'; + }); +}; + +const waitForFromToButtons = async (page) => { + await page.waitForFunction(() => { + const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .map((label) => label.textContent.trim()); + return labels.length === 4 + && labels[0] === 'From' + && labels[1] === 'To' + && labels[2] === 'Clear filter' + && labels[3] === 'Copy'; + }); +}; + describe('Filter actions test-suite', async () => { let baseUrl; let page; @@ -225,4 +262,291 @@ describe('Filter actions test-suite', async () => { assert.deepStrictEqual(criterias.severity.$in, ['W', 'I', 'E', 'F']); }); + describe('Cell Context Menu', async () => { + const exampleRow = { + severity: 'I', + level: 3, + timestamp: Date.parse('2024-05-11T10:20:30.000Z') / 1000, + hostname: 'ctx-host-01', + rolename: 'ctx-role', + pid: '2001', + username: 'ctx-user', + system: 'ctx-system', + facility: 'ctx-facility', + detector: 'ctx-detector', + partition: 'ctx-partition', + run: '12', + errcode: '404', + errline: '17', + errsource: 'ctx-source', + message: 'ctx-message-01', + }; + + beforeEach(async () => { + await page.evaluate((exampleRow) => { + window.model.log.filter.resetCriteria(); + window.model.log.hideContextMenu(); + window.__copiedContextMenuValue = undefined; + window.model.log.list = [exampleRow]; + window.model.notify(); + }, exampleRow); + + // Wait until the table is updated with the new log entry + await page.waitForFunction(() => { + const cells = Array.from(document.querySelectorAll('td.cell')); + return cells.some((cell) => cell.textContent.trim() === 'ctx-host-01') + && cells.some((cell) => cell.textContent.trim() === 'ctx-message-01'); + }); + }); + + it('should show context menu on right click', async () => { + await page.evaluate(() => { + const hostNameCell = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.trim() === 'ctx-host-01'); + hostNameCell.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 120, + clientY: 140, + button: 2, + })); + }); + + // Wait for render and for model value + await page.waitForSelector('.cell-context-menu'); + assert.strictEqual(await isContextMenuOpen(page), true); + }); + + it('should close context menu on Escape key', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + + await page.evaluate(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + keyCode: 27, + which: 27, + bubbles: true, + cancelable: true, + })); + }); + + assert.strictEqual(await isContextMenuOpen(page), false); + }); + + it('should close context menu on Enter key', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + + const isOpenAfterEnter = await page.evaluate(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true, + })); + + return window.model.log.contextMenu.isOpen; + }); + + assert.strictEqual(isOpenAfterEnter, false); + }); + + it('should close context menu on outside click', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + + const isOpenAfterOutsideClick = await page.evaluate(() => { + const overlay = document.querySelector('.cell-context-menu-overlay'); + overlay.click(); + return window.model.log.contextMenu.isOpen; + }); + + assert.strictEqual(isOpenAfterOutsideClick, false); + }); + + it('should show correct actions for non-timestamp fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 120, 140); + await waitForMatchExcludeButtons(page); + }); + + it('should show correct actions for timestamp fields', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + }); + + it('should apply "match" action for regular fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + + const criteria = await page.evaluate(() => { + const matchButton = document.querySelectorAll('.cell-context-menu-item.f7')[0]; + matchButton.click(); + return { + match: window.model.log.filter.criterias.hostname.match, + $match: window.model.log.filter.criterias.hostname.$match, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(criteria.match, 'ctx-host-01'); + assert.strictEqual(criteria.$match, 'ctx-host-01'); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should apply "exclude" action for regular fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + + const criteria = await page.evaluate(() => { + const excludeButton = document.querySelectorAll('.cell-context-menu-item.f7')[1]; + excludeButton.click(); + return { + exclude: window.model.log.filter.criterias.hostname.exclude, + $exclude: window.model.log.filter.criterias.hostname.$exclude, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(criteria.exclude, 'ctx-host-01'); + assert.strictEqual(criteria.$exclude, 'ctx-host-01'); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should clear criteria for regular fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + + const criteria = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'match', 'ctx-host-01'); + window.model.log.filter.setCriteria('hostname', 'exclude', 'ctx-host-01'); + const clearButton = document.querySelectorAll('.cell-context-menu-item.f7')[2]; + clearButton.click(); + return { + match: window.model.log.filter.criterias.hostname.match, + $match: window.model.log.filter.criterias.hostname.$match, + exclude: window.model.log.filter.criterias.hostname.exclude, + $exclude: window.model.log.filter.criterias.hostname.$exclude, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(criteria.match, ''); + assert.strictEqual(criteria.$match, null); + assert.strictEqual(criteria.exclude, ''); + assert.strictEqual(criteria.$exclude, null); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should apply "from" action for timestamp fields', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + + const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); + + const criteria = await page.evaluate(() => { + const fromButton = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .find((label) => label.textContent.trim() === 'From') + ?.closest('.cell-context-menu-item'); + const expectedIso = window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(); + fromButton.click(); + + return { + since: window.model.log.filter.criterias.timestamp.since, + $since: window.model.log.filter.criterias.timestamp.$since?.toISOString(), + expectedIso, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(criteria.since, menuValue); + assert.strictEqual(criteria.$since, criteria.expectedIso); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should apply "to" action for timestamp fields', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + + const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); + + const criteria = await page.evaluate(() => { + const toButton = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .find((label) => label.textContent.trim() === 'To') + ?.closest('.cell-context-menu-item'); + const expectedIso = window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(); + toButton.click(); + + return { + until: window.model.log.filter.criterias.timestamp.until, + $until: window.model.log.filter.criterias.timestamp.$until?.toISOString(), + expectedIso, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(criteria.until, menuValue); + assert.strictEqual(criteria.$until, criteria.expectedIso); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should clear criteria for timestamp fields', async () => { + const criteria = await page.evaluate(() => { + window.model.log.filter.setCriteria('timestamp', 'since', '17/05/2026 18:42:05.509'); + window.model.log.filter.setCriteria('timestamp', 'until', '17/05/2026 18:42:05.509'); + + window.model.log.showContextMenu('timestamp', '17/05/2026 18:42:05.509', 100, 120); + + const clearButton = document.querySelectorAll('.cell-context-menu-item.f7')[2]; + clearButton.click(); + + return { + since: window.model.log.filter.criterias.timestamp.since, + $since: window.model.log.filter.criterias.timestamp.$since, + until: window.model.log.filter.criterias.timestamp.until, + $until: window.model.log.filter.criterias.timestamp.$until, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(criteria.since, ''); + assert.strictEqual(criteria.$since, null); + + assert.strictEqual(criteria.until, ''); + assert.strictEqual(criteria.$until, null); + + assert.strictEqual(criteria.isOpen, false); + }); + + it('should copy value to clipboard', async () => { + // Mock the clipboard API + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__copiedContextMenuValue = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + + const copied = await page.evaluate(async () => { + const copyButton = document.querySelectorAll('.cell-context-menu-item.f7')[3]; + copyButton.click(); + // Wait for the mocked clipboard writeText to be called + await Promise.resolve(); + + return { + value: window.__copiedContextMenuValue, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(copied.value, 'ctx-host-01'); + assert.strictEqual(copied.isOpen, false); + }); + }); }); From dfc98a68300613817a2f5bf947e590f3bc58ca8b Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 20 May 2026 16:57:27 +0200 Subject: [PATCH 05/24] [OGUI-1453] Fix failing CI/CD test due to slow render --- InfoLogger/test/public/log-filter-actions-mocha.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index fa24da27b..51fdf1403 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -489,12 +489,14 @@ describe('Filter actions test-suite', async () => { }); it('should clear criteria for timestamp fields', async () => { - const criteria = await page.evaluate(() => { + await page.evaluate(() => { window.model.log.filter.setCriteria('timestamp', 'since', '17/05/2026 18:42:05.509'); window.model.log.filter.setCriteria('timestamp', 'until', '17/05/2026 18:42:05.509'); + }); + await openContextMenu(page, 'timestamp', '17/05/2026 18:42:05.509', 100, 120); + await waitForFromToButtons(page); - window.model.log.showContextMenu('timestamp', '17/05/2026 18:42:05.509', 100, 120); - + const criteria = await page.evaluate(() => { const clearButton = document.querySelectorAll('.cell-context-menu-item.f7')[2]; clearButton.click(); From 0351a7eced8d26edabc41e138d3ceec2b09fbc66 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 20 May 2026 23:55:51 +0200 Subject: [PATCH 06/24] [OGUI-1453] improve context menu component Basic admin refactoring, clamp menu position better (no negative coords, simplify component HTML, add error handling for clipboard. --- InfoLogger/public/log/cellContextMenu.js | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index a6ac7b397..0d23c3b0a 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -12,8 +12,7 @@ * or submit itself to any jurisdiction. */ -import { h } from '/js/src/index.js'; -import { iconCheck, iconBan, iconClipboard, iconTrash } from '/js/src/index.js'; +import { h, iconCheck, iconBan, iconClipboard, iconTrash } from '/js/src/index.js'; const MENU_WIDTH = 220; const MENU_HEIGHT_ESTIMATE = 90; @@ -30,11 +29,11 @@ const remToPx = (rem) => rem * parseFloat(getComputedStyle(document.documentElem * @returns {{left: number, top: number}} clamped menu position */ const clampPosition = (x, y, inspectorEnabled) => ({ - left: Math.min( + left: Math.max(0, Math.min( x, window.innerWidth - MENU_WIDTH - SEVERITY_CANVAS_WIDTH_PX - (inspectorEnabled ? remToPx(INSPECTOR_WIDTH_REM) : 0), - ), // 10px margin from the edge because of the severity canvas - top: Math.min(y, window.innerHeight - MENU_HEIGHT_ESTIMATE), + )), + top: Math.max(0, Math.min(y, window.innerHeight - MENU_HEIGHT_ESTIMATE)), }); /** @@ -90,7 +89,9 @@ export default (model) => { hideMenu(); }), createMenuItem(iconClipboard(), 'var(--color-primary)', 'Copy', () => { - navigator.clipboard.writeText(value); + navigator.clipboard.writeText(value).catch(() => { + model.notification.show('Failed to copy to clipboard', 'danger', 2000); + }); hideMenu(); }), ]), @@ -106,13 +107,10 @@ export default (model) => { * @returns {vnode} - the menu item as a vnode */ function createMenuItem(icon, iconColor, label, onClick) { - return h('a.cell-context-menu-item.f7', { + return h('.cell-context-menu-item.f7', { onclick: onClick, }, [ h('span', { style: { color: iconColor } }, icon), - h( - 'span.flex-row.justify-between.w-100', - h('span.ph2.w-100', { style: { fontWeight: 'bold' } }, label), - ), + h('span.ph2.w-100', { style: { fontWeight: 'bold' } }, label), ]); -}; +} From f07c082b7f07614c8b986c8d1271473c6226d62f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 00:00:38 +0200 Subject: [PATCH 07/24] [OGUI-1453] Improve context menu testing Remove index-based menu button selectors, mock window.confirm to prevent query confirmation blocking tests, separate click actions from assertions, add clipboard error test. --- .../test/public/log-filter-actions-mocha.js | 160 ++++++++++-------- 1 file changed, 85 insertions(+), 75 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 51fdf1403..1aaedc144 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -53,6 +53,15 @@ const waitForFromToButtons = async (page) => { }); }; +const clickMenuItemByLabel = async (page, label) => { + await page.evaluate((label) => { + const item = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .find((el) => el.textContent.trim() === label) + ?.closest('.cell-context-menu-item'); + item.click(); + }, label); +}; + describe('Filter actions test-suite', async () => { let baseUrl; let page; @@ -284,6 +293,7 @@ describe('Filter actions test-suite', async () => { beforeEach(async () => { await page.evaluate((exampleRow) => { + window.confirm = () => false; window.model.log.filter.resetCriteria(); window.model.log.hideContextMenu(); window.__copiedContextMenuValue = undefined; @@ -376,16 +386,13 @@ describe('Filter actions test-suite', async () => { it('should apply "match" action for regular fields', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); - const criteria = await page.evaluate(() => { - const matchButton = document.querySelectorAll('.cell-context-menu-item.f7')[0]; - matchButton.click(); - return { - match: window.model.log.filter.criterias.hostname.match, - $match: window.model.log.filter.criterias.hostname.$match, - isOpen: window.model.log.contextMenu.isOpen, - }; - }); + const criteria = await page.evaluate(() => ({ + match: window.model.log.filter.criterias.hostname.match, + $match: window.model.log.filter.criterias.hostname.$match, + isOpen: window.model.log.contextMenu.isOpen, + })); assert.strictEqual(criteria.match, 'ctx-host-01'); assert.strictEqual(criteria.$match, 'ctx-host-01'); @@ -395,16 +402,13 @@ describe('Filter actions test-suite', async () => { it('should apply "exclude" action for regular fields', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Exclude'); - const criteria = await page.evaluate(() => { - const excludeButton = document.querySelectorAll('.cell-context-menu-item.f7')[1]; - excludeButton.click(); - return { - exclude: window.model.log.filter.criterias.hostname.exclude, - $exclude: window.model.log.filter.criterias.hostname.$exclude, - isOpen: window.model.log.contextMenu.isOpen, - }; - }); + const criteria = await page.evaluate(() => ({ + exclude: window.model.log.filter.criterias.hostname.exclude, + $exclude: window.model.log.filter.criterias.hostname.$exclude, + isOpen: window.model.log.contextMenu.isOpen, + })); assert.strictEqual(criteria.exclude, 'ctx-host-01'); assert.strictEqual(criteria.$exclude, 'ctx-host-01'); @@ -415,19 +419,19 @@ describe('Filter actions test-suite', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); - const criteria = await page.evaluate(() => { + await page.evaluate(() => { window.model.log.filter.setCriteria('hostname', 'match', 'ctx-host-01'); window.model.log.filter.setCriteria('hostname', 'exclude', 'ctx-host-01'); - const clearButton = document.querySelectorAll('.cell-context-menu-item.f7')[2]; - clearButton.click(); - return { - match: window.model.log.filter.criterias.hostname.match, - $match: window.model.log.filter.criterias.hostname.$match, - exclude: window.model.log.filter.criterias.hostname.exclude, - $exclude: window.model.log.filter.criterias.hostname.$exclude, - isOpen: window.model.log.contextMenu.isOpen, - }; }); + await clickMenuItemByLabel(page, 'Clear filter'); + + const criteria = await page.evaluate(() => ({ + match: window.model.log.filter.criterias.hostname.match, + $match: window.model.log.filter.criterias.hostname.$match, + exclude: window.model.log.filter.criterias.hostname.exclude, + $exclude: window.model.log.filter.criterias.hostname.$exclude, + isOpen: window.model.log.contextMenu.isOpen, + })); assert.strictEqual(criteria.match, ''); assert.strictEqual(criteria.$match, null); @@ -441,24 +445,19 @@ describe('Filter actions test-suite', async () => { await waitForFromToButtons(page); const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); + const expectedIso = await page.evaluate( + () => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(), + ); + await clickMenuItemByLabel(page, 'From'); - const criteria = await page.evaluate(() => { - const fromButton = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .find((label) => label.textContent.trim() === 'From') - ?.closest('.cell-context-menu-item'); - const expectedIso = window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(); - fromButton.click(); - - return { - since: window.model.log.filter.criterias.timestamp.since, - $since: window.model.log.filter.criterias.timestamp.$since?.toISOString(), - expectedIso, - isOpen: window.model.log.contextMenu.isOpen, - }; - }); + const criteria = await page.evaluate(() => ({ + since: window.model.log.filter.criterias.timestamp.since, + $since: window.model.log.filter.criterias.timestamp.$since?.toISOString(), + isOpen: window.model.log.contextMenu.isOpen, + })); assert.strictEqual(criteria.since, menuValue); - assert.strictEqual(criteria.$since, criteria.expectedIso); + assert.strictEqual(criteria.$since, expectedIso); assert.strictEqual(criteria.isOpen, false); }); @@ -467,24 +466,19 @@ describe('Filter actions test-suite', async () => { await waitForFromToButtons(page); const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); + const expectedIso = await page.evaluate( + () => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(), + ); + await clickMenuItemByLabel(page, 'To'); - const criteria = await page.evaluate(() => { - const toButton = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .find((label) => label.textContent.trim() === 'To') - ?.closest('.cell-context-menu-item'); - const expectedIso = window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(); - toButton.click(); - - return { - until: window.model.log.filter.criterias.timestamp.until, - $until: window.model.log.filter.criterias.timestamp.$until?.toISOString(), - expectedIso, - isOpen: window.model.log.contextMenu.isOpen, - }; - }); + const criteria = await page.evaluate(() => ({ + until: window.model.log.filter.criterias.timestamp.until, + $until: window.model.log.filter.criterias.timestamp.$until?.toISOString(), + isOpen: window.model.log.contextMenu.isOpen, + })); assert.strictEqual(criteria.until, menuValue); - assert.strictEqual(criteria.$until, criteria.expectedIso); + assert.strictEqual(criteria.$until, expectedIso); assert.strictEqual(criteria.isOpen, false); }); @@ -495,19 +489,15 @@ describe('Filter actions test-suite', async () => { }); await openContextMenu(page, 'timestamp', '17/05/2026 18:42:05.509', 100, 120); await waitForFromToButtons(page); + await clickMenuItemByLabel(page, 'Clear filter'); - const criteria = await page.evaluate(() => { - const clearButton = document.querySelectorAll('.cell-context-menu-item.f7')[2]; - clearButton.click(); - - return { - since: window.model.log.filter.criterias.timestamp.since, - $since: window.model.log.filter.criterias.timestamp.$since, - until: window.model.log.filter.criterias.timestamp.until, - $until: window.model.log.filter.criterias.timestamp.$until, - isOpen: window.model.log.contextMenu.isOpen, - }; - }); + const criteria = await page.evaluate(() => ({ + since: window.model.log.filter.criterias.timestamp.since, + $since: window.model.log.filter.criterias.timestamp.$since, + until: window.model.log.filter.criterias.timestamp.until, + $until: window.model.log.filter.criterias.timestamp.$until, + isOpen: window.model.log.contextMenu.isOpen, + })); assert.strictEqual(criteria.since, ''); assert.strictEqual(criteria.$since, null); @@ -519,7 +509,6 @@ describe('Filter actions test-suite', async () => { }); it('should copy value to clipboard', async () => { - // Mock the clipboard API await page.evaluate(() => { Object.defineProperty(navigator, 'clipboard', { value: { @@ -534,13 +523,10 @@ describe('Filter actions test-suite', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Copy'); const copied = await page.evaluate(async () => { - const copyButton = document.querySelectorAll('.cell-context-menu-item.f7')[3]; - copyButton.click(); - // Wait for the mocked clipboard writeText to be called await Promise.resolve(); - return { value: window.__copiedContextMenuValue, isOpen: window.model.log.contextMenu.isOpen, @@ -550,5 +536,29 @@ describe('Filter actions test-suite', async () => { assert.strictEqual(copied.value, 'ctx-host-01'); assert.strictEqual(copied.isOpen, false); }); + + it('should show notification when clipboard write fails', async () => { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: () => Promise.reject(new Error('Clipboard access denied')), + }, + configurable: true, + }); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Copy'); + + await page.waitForFunction(() => window.model.notification.state === 'shown'); + const notification = await page.evaluate(() => ({ + message: window.model.notification.message, + type: window.model.notification.type, + })); + + assert.strictEqual(notification.message, 'Failed to copy to clipboard'); + assert.strictEqual(notification.type, 'danger'); + }); }); }); From f495da50327cf35f5c2447815e60c9d0e657cbca Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 15:54:26 +0200 Subject: [PATCH 08/24] [OGUI-1453] Export not as default --- InfoLogger/public/log/cellContextMenu.js | 2 +- InfoLogger/public/view.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 0d23c3b0a..6f88a7073 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -42,7 +42,7 @@ const clampPosition = (x, y, inspectorEnabled) => ({ * @param {Model} model root application model * @returns {Array|null} rendered menu nodes */ -export default (model) => { +export const cellContextMenu = (model) => { const { contextMenu } = model.log; if (!contextMenu.isOpen) { return null; diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index c6f80c3f1..35057213c 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -24,7 +24,7 @@ import tableLogsContent from './log/tableLogsContent.js'; import tableLogsScrollMap from './log/tableLogsScrollMap.js'; import aboutComponent from './about/about.component.js'; import errorComponent from './common/errorComponent.js'; -import cellContextMenu from './log/cellContextMenu.js'; +import { cellContextMenu } from './log/cellContextMenu.js'; /** * Main view of the application From 5bee9140dec4dc509df46c3b1c937c22b358a355 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 15:56:59 +0200 Subject: [PATCH 09/24] [OGUI-1453] JSDoc fixes --- InfoLogger/public/log/cellContextMenu.js | 2 +- InfoLogger/public/log/tableLogsContent.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 6f88a7073..c9c3efada 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -100,8 +100,8 @@ export const cellContextMenu = (model) => { /** * Creates menu item for the context menu of a cell with given icon, label and click action. - * @param {string} icon - icon to display in the menu item * @param {string} iconColor - color of the icon + * @param {vnode} icon - icon to display in the menu item * @param {string} label - label to display in the menu item * @param {() => void} onClick - function to execute on click * @returns {vnode} - the menu item as a vnode diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 3031ef8be..22f119d19 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -71,6 +71,14 @@ const tableLogLine = (model, row) => h('tr.row-hover', { ondblclick: () => model.toggleInspector(), }, tableRows(model, model.table.colsHeader, row)); +/** + * Resolves the required data to send to the context menu based on the cell's field and content. + * @param {Model} model - root model of the application + * @param {object} row - values for each cell of the row + * @param {string} field - the field associated to the cell (e.g. 'hostname', 'severity', etc.) + * @param {string} content - the content of the cell + * @returns {object|null} - the data for the context menu or null if not applicable + */ const resolveContextMenuData = (model, row, field, content) => { if (field === 'date') { return row.timestamp ? { field: 'timestamp', value: String(content) } : null; @@ -83,6 +91,16 @@ const resolveContextMenuData = (model, row, field, content) => { return row[field] != null && row[field] !== '' ? { field, value: String(row[field]) } : null; }; +/** + * Wraps a cell with a context menu with filtering and general options. + * @param {Model} model - root model of the application + * @param {object} row - values for each cell of the row + * @param {string} field - the field associated to the cell (e.g. 'hostname', 'severity', etc.) + * @param {string} content - the content of the cell + * @param {string} extraClasses - extra CSS classes to add to the cell + * @param {object} extraAttrs - extra attributes to add to the cell + * @returns {vnode} - the cell wrapped with the context menu + */ const cellWithContextMenu = (model, row, field, content, extraClasses = '', extraAttrs = {}) => h(`td.cell${extraClasses}`, { ...extraAttrs, From a2a99657f3bc7356932bf4e753b716b1af168dd8 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 16:04:02 +0200 Subject: [PATCH 10/24] [OGUI-1453] Add openInspector action Caveat: I added colours as classes at the same time and hard to add both so this commit on it's own is incomplete see next. --- InfoLogger/public/log/Log.js | 3 ++- InfoLogger/public/log/cellContextMenu.js | 15 ++++++++++++--- InfoLogger/public/log/tableLogsContent.js | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 67b9e9d94..fb7858e80 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -80,13 +80,14 @@ export default class Log extends Observable { }; } - showContextMenu(field, value, x, y) { + showContextMenu(field, value, x, y, row) { this.contextMenu = { isOpen: true, field, value, x, y, + row, }; this.notify(); } diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index c9c3efada..dca1884cb 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -12,10 +12,10 @@ * or submit itself to any jurisdiction. */ -import { h, iconCheck, iconBan, iconClipboard, iconTrash } from '/js/src/index.js'; +import { h, iconCheck, iconBan, iconClipboard, iconTrash, iconMagnifyingGlass } from '/js/src/index.js'; const MENU_WIDTH = 220; -const MENU_HEIGHT_ESTIMATE = 90; +const MENU_HEIGHT_ESTIMATE = 120; const INSPECTOR_WIDTH_REM = 20; const SEVERITY_CANVAS_WIDTH_PX = 10; @@ -48,7 +48,7 @@ export const cellContextMenu = (model) => { return null; } - const { field, value, x, y } = contextMenu; + const { field, value, x, y, row } = contextMenu; const pos = clampPosition(x, y, model.inspectorEnabled); const hideMenu = () => model.log.hideContextMenu(); @@ -94,6 +94,15 @@ export const cellContextMenu = (model) => { }); hideMenu(); }), + createMenuItem(iconMagnifyingGlass(), 'primary', 'Open Inspector', () => { + if (row) { + model.log.setItem(row); + } + if (!model.inspectorEnabled) { + model.toggleInspector(); + } + hideMenu(); + }), ]), ]; }; diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 22f119d19..829b1410d 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -108,7 +108,7 @@ const cellWithContextMenu = (model, row, field, content, extraClasses = '', extr const data = resolveContextMenuData(model, row, field, content); if (data) { e.preventDefault(); - model.log.showContextMenu(data.field, data.value, e.clientX, e.clientY); + model.log.showContextMenu(data.field, data.value, e.clientX, e.clientY, row); } }, }, content); From ba741041dd188527faa521b6a2932b71f589391d Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 16:08:32 +0200 Subject: [PATCH 11/24] [OGUI-1453] Refactor tableRows in tableLogsContent Caveat: Wrapped Severity and Level with context menu as was doing at the same time so commit it not complete without next. --- InfoLogger/public/log/tableLogsContent.js | 87 ++++++++++++----------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 829b1410d..9e9c0c3b0 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -65,11 +65,15 @@ const scrollStyling = (model) => ({ * @param {Log} row - a row of this table is a raw log * @returns {vnode} - the log build as a table row */ -const tableLogLine = (model, row) => h('tr.row-hover', { - className: model.log.item === row ? 'row-selected' : '', - onclick: () => model.log.setItem(row), - ondblclick: () => model.toggleInspector(), -}, tableRows(model, model.table.colsHeader, row)); +const tableLogLine = (model, row) => { + const { log, table } = model; + return h('tr.row-hover', { + className: log.item === row ? 'row-selected' : '', + title: 'Right-click for more options', + onclick: () => log.setItem(row), + ondblclick: () => model.toggleInspector(), + }, tableRows(model, table.colsHeader, row)); +}; /** * Resolves the required data to send to the context menu based on the cell's field and content. @@ -120,44 +124,45 @@ const cellWithContextMenu = (model, row, field, content, extraClasses = '', extr * @param {object} row - values for each cell of the row * @returns {vnode} - the row of the table */ -const tableRows = (model, colsHeader, row) => - [ - h('td.cell.text-center', { className: model.log.item === row ? null : severityClass(row.severity) }, row.severity), - h('td.cell.text-center.cell-bordered', row.level), - colsHeader.date.visible && cellWithContextMenu( - model, - row, - 'date', - model.timezone.format(row.timestamp, 'date'), - '.cell-bordered', - ), - colsHeader.time.visible && cellWithContextMenu( - model, - row, - 'time', - model.timezone.format(row.timestamp, model.log.timeFormat), - '.cell-bordered', - ), - colsHeader.hostname.visible && cellWithContextMenu(model, row, 'hostname', row.hostname, '.cell-bordered'), - colsHeader.rolename.visible && cellWithContextMenu(model, row, 'rolename', row.rolename, '.cell-bordered'), - colsHeader.pid.visible && cellWithContextMenu(model, row, 'pid', row.pid, '.cell-bordered'), - colsHeader.username.visible && cellWithContextMenu(model, row, 'username', row.username, '.cell-bordered'), - colsHeader.system.visible && cellWithContextMenu(model, row, 'system', row.system, '.cell-bordered'), - colsHeader.facility.visible && cellWithContextMenu(model, row, 'facility', row.facility, '.cell-bordered'), - colsHeader.detector.visible && cellWithContextMenu(model, row, 'detector', row.detector, '.cell-bordered'), - colsHeader.partition.visible && cellWithContextMenu(model, row, 'partition', row.partition, '.cell-bordered'), - colsHeader.run.visible && cellWithContextMenu(model, row, 'run', row.run, '.cell-bordered'), - colsHeader.errcode.visible && cellWithContextMenu( - model, - row, - 'errcode', - linkToWikiErrors(row.errcode), - '.cell-bordered', +const tableRows = (model, colsHeader, row) => { + const cell = (field, content, extraClasses = '', extraAttrs = {}) => + cellWithContextMenu(model, row, field, content, extraClasses, extraAttrs); + + const { date, time, hostname, rolename, pid, username, + system, facility, detector, partition, run, + errcode, errline, errsource, message } = colsHeader; + + const { severity, level, timestamp, hostname: hostnameVal, rolename: rolenameVal, + pid: pidVal, username: usernameVal, system: systemVal, facility: facilityVal, + detector: detectorVal, partition: partitionVal, run: runVal, + errcode: errcodeVal, errline: errlineVal, errsource: errsourceVal, + message: messageVal } = row; + + return [ + cell( + 'severity', + severity, + '.text-center', + { className: model.log.item === row ? null : severityClass(severity) }, ), - colsHeader.errline.visible && cellWithContextMenu(model, row, 'errline', row.errline, '.cell-bordered'), - colsHeader.errsource.visible && cellWithContextMenu(model, row, 'errsource', row.errsource, '.cell-bordered'), - colsHeader.message.visible && cellWithContextMenu(model, row, 'message', row.message, '', { title: row.message }), + cell('level', level, '.text-center.cell-bordered'), + date.visible && cell('date', model.timezone.format(timestamp, 'date'), '.cell-bordered'), + time.visible && cell('time', model.timezone.format(timestamp, model.log.timeFormat), '.cell-bordered'), + hostname.visible && cell('hostname', hostnameVal, '.cell-bordered'), + rolename.visible && cell('rolename', rolenameVal, '.cell-bordered'), + pid.visible && cell('pid', pidVal, '.cell-bordered'), + username.visible && cell('username', usernameVal, '.cell-bordered'), + system.visible && cell('system', systemVal, '.cell-bordered'), + facility.visible && cell('facility', facilityVal, '.cell-bordered'), + detector.visible && cell('detector', detectorVal, '.cell-bordered'), + partition.visible && cell('partition', partitionVal, '.cell-bordered'), + run.visible && cell('run', runVal, '.cell-bordered'), + errcode.visible && cell('errcode', linkToWikiErrors(errcodeVal), '.cell-bordered'), + errline.visible && cell('errline', errlineVal, '.cell-bordered'), + errsource.visible && cell('errsource', errsourceVal, '.cell-bordered'), + message.visible && cell('message', messageVal, '', { title: messageVal }), ]; +}; /** * Creates link of error code to open in a new tab the wiki page associated From cd81418a14475509b13020b9df5ddb4047beebbf Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 16:11:22 +0200 Subject: [PATCH 12/24] [OGUI-1453] Add actions for severity and level As well as making "Clear filter" buttons be disabled when there is nothing to clear for that filter. --- InfoLogger/public/app.css | 7 ++ InfoLogger/public/log/cellContextMenu.js | 118 +++++++++++++++++++---- 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index a1c9e99b3..5a76f162a 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -159,6 +159,13 @@ a.disabled { pointer-events: none; cursor: default; } color: var(--color-white); } +.cell-context-menu-item.disabled, +.cell-context-menu-item.disabled:hover, +.cell-context-menu-item.disabled:active { + cursor: not-allowed; + opacity: 0.4; +} + .cell-context-menu-header { padding: 0.4rem 0.75rem; color: var(--color-gray-darker); diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index dca1884cb..6b9d06d6f 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -55,6 +55,99 @@ export const cellContextMenu = (model) => { const isTimestamp = field === 'timestamp'; + const appendFilter = (operator) => { + const separator = field === 'message' ? '\n' : ' '; + const existing = model.log.filter.criterias[field][operator] || ''; + const parts = existing ? existing.split(separator) : []; + if (!parts.includes(value)) { + parts.push(value); + } + return parts.join(separator); + }; + + const filterItems = () => { + if (field === 'severity') { + const isActive = model.log.filter.criterias.severity.$in?.includes(value); + return [ + createMenuItem( + iconCheck(), + 'success', + 'Show severity', + () => { + model.log.setCriteria('severity', 'in', value); + hideMenu(); + }, + isActive, + ), + createMenuItem( + iconBan(), + 'danger', + 'Hide severity', + () => { + model.log.setCriteria('severity', 'in', value); + hideMenu(); + }, + !isActive, + ), + createMenuItem(iconTrash(), 'danger', 'Reset severity filter', () => { + model.log.filter.setCriteria('severity', 'in', 'I W E F'); + hideMenu(); + }, model.log.filter.criterias.severity.in === 'I W E F'), + ]; + } + if (field === 'level') { + const numValue = Number(value); + const thresholds = [ + { max: 1, label: 'Ops' }, + { max: 6, label: 'Support' }, + { max: 11, label: 'Devel' }, + ]; + const include = thresholds.find((t) => t.max >= numValue); + const exclude = [...thresholds].reverse().find((t) => t.max < numValue); + return [ + createMenuItem( + iconCheck(), + 'success', + include ? `Set level to ${include.label}` : 'Show all levels', + () => { + model.log.setCriteria('level', 'max', include?.max ?? null); + hideMenu(); + }, + ), + createMenuItem( + iconBan(), + 'danger', + exclude ? `Set level to ${exclude.label}` : 'Show all levels', + () => { + model.log.setCriteria('level', 'max', exclude?.max ?? null); + hideMenu(); + }, + ), + createMenuItem(iconTrash(), 'danger', 'Clear level filter', () => { + model.log.setCriteria('level', 'max', null); + hideMenu(); + }, model.log.filter.criterias.level.max === null), + ]; + } + return [ + createMenuItem(iconCheck(), 'success', isTimestamp ? 'From' : 'Match', () => { + model.log.setCriteria(field, isTimestamp ? 'since' : 'match', isTimestamp ? value : appendFilter('match')); + hideMenu(); + }), + createMenuItem(iconBan(), 'danger', isTimestamp ? 'To' : 'Exclude', () => { + model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', isTimestamp ? value : appendFilter('exclude')); + hideMenu(); + }), + createMenuItem(iconTrash(), 'danger', 'Clear filter', () => { + model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', ''); + model.log.setCriteria(field, isTimestamp ? 'since' : 'match', ''); + hideMenu(); + }, isTimestamp + ? !model.log.filter.criterias.timestamp.since && !model.log.filter.criterias.timestamp.until + : !model.log.filter.criterias[field].match && !model.log.filter.criterias[field].exclude), + ]; + }; + return [ // Full-screen transparent overlay to catch click-outside h('.cell-context-menu-overlay', { @@ -75,20 +168,8 @@ export const cellContextMenu = (model) => { ? 'Timestamp' : field.charAt(0).toUpperCase() + field.slice(1)), h('span.f6.text-ellipsis', { title: value }, value), ]), - createMenuItem(iconCheck(), 'var(--color-success)', isTimestamp ? 'From' : 'Match', () => { - model.log.setCriteria(field, isTimestamp ? 'since' : 'match', value); - hideMenu(); - }), - createMenuItem(iconBan(), 'var(--color-danger)', isTimestamp ? 'To' : 'Exclude', () => { - model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', value); - hideMenu(); - }), - createMenuItem(iconTrash(), 'var(--color-danger)', 'Clear filter', () => { - model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', ''); - model.log.setCriteria(field, isTimestamp ? 'since' : 'match', ''); - hideMenu(); - }), - createMenuItem(iconClipboard(), 'var(--color-primary)', 'Copy', () => { + ...filterItems(), + createMenuItem(iconClipboard(), 'primary', 'Copy', () => { navigator.clipboard.writeText(value).catch(() => { model.notification.show('Failed to copy to clipboard', 'danger', 2000); }); @@ -109,17 +190,18 @@ export const cellContextMenu = (model) => { /** * Creates menu item for the context menu of a cell with given icon, label and click action. - * @param {string} iconColor - color of the icon * @param {vnode} icon - icon to display in the menu item + * @param {string} iconClass - CSS class for the icon color (e.g. 'success', 'danger', 'primary') * @param {string} label - label to display in the menu item * @param {() => void} onClick - function to execute on click * @returns {vnode} - the menu item as a vnode */ -function createMenuItem(icon, iconColor, label, onClick) { +function createMenuItem(icon, iconClass, label, onClick, disabled = false) { return h('.cell-context-menu-item.f7', { - onclick: onClick, + onclick: disabled ? null : onClick, + className: disabled ? 'disabled' : '', }, [ - h('span', { style: { color: iconColor } }, icon), + h(`span.${iconClass}`, icon), h('span.ph2.w-100', { style: { fontWeight: 'bold' } }, label), ]); } From 91e00a0e9895d21bc827c9d181577b8f6d7e7c2d Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 16:12:58 +0200 Subject: [PATCH 13/24] [OGUI-1453] Add tests for severity, level and "Clear Filter" disablement Additionally refactor into utils and own suite. --- InfoLogger/test/mocha-index.js | 1 + .../test/public/context-menu-test-utils.js | 114 +++ .../test/public/log-context-menu-mocha.js | 684 ++++++++++++++++++ .../test/public/log-filter-actions-mocha.js | 337 --------- 4 files changed, 799 insertions(+), 337 deletions(-) create mode 100644 InfoLogger/test/public/context-menu-test-utils.js create mode 100644 InfoLogger/test/public/log-context-menu-mocha.js diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 051a18aa1..8225532fa 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -104,6 +104,7 @@ describe('InfoLogger', function() { require('./public/log-filter-actions-mocha'); require('./public/live-mode-mocha'); require('./public/query-mode-mocha'); + require('./public/log-context-menu-mocha'); after(async () => { await browser.close(); diff --git a/InfoLogger/test/public/context-menu-test-utils.js b/InfoLogger/test/public/context-menu-test-utils.js new file mode 100644 index 000000000..f7959cada --- /dev/null +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -0,0 +1,114 @@ +/** + * @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. + */ + +const CONTEXT_MENU_RENDER_DELAY = 25; // delay to wait for context menu to render new actions after opening + +const isContextMenuOpen = async (page) => await page.evaluate(() => window.model.log.contextMenu.isOpen); + +const openContextMenu = async (page, field, value, x, y, row = null) => { + await page.evaluate((field, value, x, y, row) => { + window.model.log.showContextMenu(field, value, x, y, row); + }, field, value, x, y, row); + await page.waitForSelector('.cell-context-menu'); + await new Promise((resolve) => setTimeout(resolve, CONTEXT_MENU_RENDER_DELAY)); +}; + +const waitForMatchExcludeButtons = async (page) => { + // wait for function as menu sometimes will render previous labels then update + await page.waitForFunction(() => { + const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .map((label) => label.textContent.trim()); + return labels.length === 5 + && labels[0] === 'Match' + && labels[1] === 'Exclude' + && labels[2] === 'Clear filter' + && labels[3] === 'Copy' + && labels[4] === 'Open Inspector'; + }); +}; + +const waitForFromToButtons = async (page) => { + await page.waitForFunction(() => { + const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .map((label) => label.textContent.trim()); + return labels.length === 5 + && labels[0] === 'From' + && labels[1] === 'To' + && labels[2] === 'Clear filter' + && labels[3] === 'Copy' + && labels[4] === 'Open Inspector'; + }); +}; + +const waitForSeverityButtons = async (page) => { + await page.waitForFunction(() => { + const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .map((label) => label.textContent.trim()); + return labels.length === 5 + && labels[0] === 'Show severity' + && labels[1] === 'Hide severity' + && labels[2] === 'Reset severity filter' + && labels[3] === 'Copy' + && labels[4] === 'Open Inspector'; + }); +}; + +const waitForLevelButtons = async (page) => { + await page.waitForFunction(() => { + const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .map((label) => label.textContent.trim()); + return labels.length === 5 + && labels[0] === 'Set level to Support' + && labels[1] === 'Set level to Ops' + && labels[2] === 'Clear level filter' + && labels[3] === 'Copy' + && labels[4] === 'Open Inspector'; + }); +}; + +const isMenuItemDisabled = async (page, label) => await page.evaluate((label) => { + const item = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .find((el) => el.textContent.trim() === label) + ?.closest('.cell-context-menu-item'); + return item?.classList.contains('disabled') ?? false; +}, label); + +const clickMenuItemByLabel = async (page, label) => { + await page.waitForFunction((label) => { + const item = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .find((el) => el.textContent.trim() === label) + ?.closest('.cell-context-menu-item'); + + return Boolean(item) && !item.classList.contains('disabled'); + }, {}, label); + + await page.evaluate((label) => { + const item = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .find((el) => el.textContent.trim() === label) + ?.closest('.cell-context-menu-item'); + item.click(); + }, label); +}; + +module.exports = { + CONTEXT_MENU_RENDER_DELAY, + isContextMenuOpen, + openContextMenu, + waitForMatchExcludeButtons, + waitForFromToButtons, + waitForSeverityButtons, + waitForLevelButtons, + isMenuItemDisabled, + clickMenuItemByLabel, +}; diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js new file mode 100644 index 000000000..fd437e4f2 --- /dev/null +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -0,0 +1,684 @@ +/** + * @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. + */ +/* eslint-disable @stylistic/js/max-len */ + +const assert = require('assert'); +const test = require('../mocha-index'); +const { + isContextMenuOpen, + openContextMenu, + waitForMatchExcludeButtons, + waitForFromToButtons, + waitForSeverityButtons, + waitForLevelButtons, + isMenuItemDisabled, + clickMenuItemByLabel, +} = require('./context-menu-test-utils'); + +describe('Cell Context Menu', async () => { + const exampleRow = { + severity: 'I', + level: 3, + timestamp: Date.parse('2024-05-11T10:20:30.000Z') / 1000, + hostname: 'ctx-host-01', + rolename: 'ctx-role', + pid: '2001', + username: 'ctx-user', + system: 'ctx-system', + facility: 'ctx-facility', + detector: 'ctx-detector', + partition: 'ctx-partition', + run: '12', + errcode: '404', + errline: '17', + errsource: 'ctx-source', + message: 'ctx-message-01', + }; + + let baseUrl = null; + let page = null; + + before(async () => { + ({ baseUrl } = test.helpers); + ({ page } = test); + + await page.goto(`${baseUrl}?profile=physicist`, { waitUntil: 'networkidle0' }); + + await page.evaluate((exampleRow) => { + window.confirm = () => false; + window.model.log.list = [exampleRow]; + window.model.notify(); + }, exampleRow); + + // Wait until the table is updated with the new log entry + await page.waitForFunction(() => { + const cells = Array.from(document.querySelectorAll('td.cell')); + return cells.some((cell) => cell.textContent.trim() === 'ctx-host-01') + && cells.some((cell) => cell.textContent.trim() === 'ctx-message-01'); + }); + }); + + beforeEach(async () => { + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.hideContextMenu(); + }); + }); + + describe('Menu visibility', async () => { + it('should show context menu on right-click', async () => { + await page.evaluate(() => { + const hostNameCell = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.trim() === 'ctx-host-01'); + hostNameCell.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 120, + clientY: 140, + button: 2, + })); + }); + + // Wait for render and for model value + await page.waitForSelector('.cell-context-menu'); + assert.strictEqual(await isContextMenuOpen(page), true); + }); + + it('should close context menu on "Escape" key', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + + await page.evaluate(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + keyCode: 27, + which: 27, + bubbles: true, + cancelable: true, + })); + }); + + assert.strictEqual(await isContextMenuOpen(page), false); + }); + + it('should close context menu on "Enter" key', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + + const isOpenAfterEnter = await page.evaluate(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true, + })); + + return window.model.log.contextMenu.isOpen; + }); + + assert.strictEqual(isOpenAfterEnter, false); + }); + + it('should close context menu on overlay click', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + + const isOpenAfterOutsideClick = await page.evaluate(() => { + const overlay = document.querySelector('.cell-context-menu-overlay'); + overlay.click(); + return window.model.log.contextMenu.isOpen; + }); + + assert.strictEqual(isOpenAfterOutsideClick, false); + }); + + it('should show hover tooltip on table rows', async () => { + const title = await page.evaluate(() => { + const row = document.querySelector('tr.row-hover'); + return row?.getAttribute('title'); + }); + assert.strictEqual(title, 'Right-click for more options'); + }); + }); + + describe('Menu header', async () => { + it('should display the capitalized field name for regular fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await page.waitForSelector('.cell-context-menu-header'); + + const header = await page.evaluate(() => { + const headerEl = document.querySelector('.cell-context-menu-header'); + const spans = headerEl.querySelectorAll('span'); + return { + fieldName: spans[0]?.textContent.trim(), + value: spans[1]?.textContent.trim(), + }; + }); + + assert.strictEqual(header.fieldName, 'Hostname'); + assert.strictEqual(header.value, 'ctx-host-01'); + }); + + it('should display "Timestamp" for timestamp fields', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await page.waitForSelector('.cell-context-menu-header'); + + const fieldName = await page.evaluate(() => { + const headerEl = document.querySelector('.cell-context-menu-header'); + return headerEl.querySelector('span')?.textContent.trim(); + }); + + assert.strictEqual(fieldName, 'Timestamp'); + }); + + it('should display the cell value in the header', async () => { + await openContextMenu(page, 'message', 'ctx-message-01', 100, 120); + await page.waitForSelector('.cell-context-menu-header'); + + const value = await page.evaluate(() => { + const headerEl = document.querySelector('.cell-context-menu-header'); + const spans = headerEl.querySelectorAll('span'); + return spans[1]?.textContent.trim(); + }); + + assert.strictEqual(value, 'ctx-message-01'); + }); + + it('should have a title attribute on the value span for tooltip', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await page.waitForSelector('.cell-context-menu-header'); + + const title = await page.evaluate(() => { + const headerEl = document.querySelector('.cell-context-menu-header'); + const [, valueSpan] = headerEl.querySelectorAll('span'); + return valueSpan?.getAttribute('title'); + }); + + assert.strictEqual(title, 'ctx-host-01'); + }); + }); + + describe('Menu actions', async () => { + describe('Menu actions visibility', async () => { + it('should show correct actions for Match/Exclude fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 120, 140); + await waitForMatchExcludeButtons(page); + }); + + it('should show correct actions for From/To fields', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + }); + + it('should show correct actions for severity field', async () => { + await openContextMenu(page, 'severity', 'I', 100, 120); + await waitForSeverityButtons(page); + }); + + it('should show correct actions for level field', async () => { + await openContextMenu(page, 'level', '3', 100, 120); + await waitForLevelButtons(page); + }); + }); + + describe('Menu actions functionality', async () => { + describe('Match/Exclude/Clear', async () => { + it('should apply "match" action for regular fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); + + const criteria = await page.evaluate(() => ({ + match: window.model.log.filter.criterias.hostname.match, + $match: window.model.log.filter.criterias.hostname.$match, + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(criteria.match, 'ctx-host-01'); + assert.strictEqual(criteria.$match, 'ctx-host-01'); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should apply "exclude" action for regular fields', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Exclude'); + + const criteria = await page.evaluate(() => ({ + exclude: window.model.log.filter.criterias.hostname.exclude, + $exclude: window.model.log.filter.criterias.hostname.$exclude, + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(criteria.exclude, 'ctx-host-01'); + assert.strictEqual(criteria.$exclude, 'ctx-host-01'); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should clear criteria for regular fields', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'match', 'ctx-host-01'); + window.model.log.filter.setCriteria('hostname', 'exclude', 'ctx-host-01'); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Clear filter'); + + const criteria = await page.evaluate(() => ({ + match: window.model.log.filter.criterias.hostname.match, + $match: window.model.log.filter.criterias.hostname.$match, + exclude: window.model.log.filter.criterias.hostname.exclude, + $exclude: window.model.log.filter.criterias.hostname.$exclude, + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(criteria.match, ''); + assert.strictEqual(criteria.$match, null); + assert.strictEqual(criteria.exclude, ''); + assert.strictEqual(criteria.$exclude, null); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should append to existing match filter instead of replacing', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('system', 'match', 'existing-system'); + }); + + await openContextMenu(page, 'system', 'ctx-system-01', 100, 120); + await page.waitForFunction(() => { + const menu = document.querySelector('.cell-context-menu'); + return menu && menu.textContent.includes('ctx-system-01'); + }); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); + + const match = await page.evaluate(() => window.model.log.filter.criterias.system.match); + assert.strictEqual(match, 'existing-system ctx-system-01'); + }); + + it('should append to existing exclude filter instead of replacing', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'exclude', 'existing-host'); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await page.waitForFunction(() => { + const menu = document.querySelector('.cell-context-menu'); + return menu && menu.textContent.includes('ctx-host-01'); + }); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Exclude'); + + const exclude = await page.evaluate(() => window.model.log.filter.criterias.hostname.exclude); + assert.strictEqual(exclude, 'existing-host ctx-host-01'); + }); + + it('should not duplicate value when appending to filter', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'match', 'ctx-host-01'); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); + + const match = await page.evaluate(() => window.model.log.filter.criterias.hostname.match); + assert.strictEqual(match, 'ctx-host-01'); + }); + + it('should use newline separator when appending to message filter', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('message', 'match', 'first message'); + }); + + await openContextMenu(page, 'message', 'ctx-message-01', 100, 120); + await page.waitForFunction(() => { + const menu = document.querySelector('.cell-context-menu'); + return menu && menu.textContent.includes('ctx-message-01'); + }); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); + + const match = await page.evaluate(() => window.model.log.filter.criterias.message.match); + assert.strictEqual(match, 'first message\nctx-message-01'); + }); + + it('should disable "Clear filter" for regular fields when no filter is set', async () => { + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), true); + }); + + it('should enable "Clear filter" for regular fields when a filter is active', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'match', 'some-host'); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), false); + }); + }); + + describe('From/To/Clear', async () => { + it('should apply "from" action for timestamp fields', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + + const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); + const expectedIso = await page.evaluate(() => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString()); + await clickMenuItemByLabel(page, 'From'); + + const criteria = await page.evaluate(() => ({ + since: window.model.log.filter.criterias.timestamp.since, + $since: window.model.log.filter.criterias.timestamp.$since?.toISOString(), + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(criteria.since, menuValue); + assert.strictEqual(criteria.$since, expectedIso); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should apply "to" action for timestamp fields', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + + const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); + const expectedIso = await page.evaluate(() => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString()); + await clickMenuItemByLabel(page, 'To'); + + const criteria = await page.evaluate(() => ({ + until: window.model.log.filter.criterias.timestamp.until, + $until: window.model.log.filter.criterias.timestamp.$until?.toISOString(), + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(criteria.until, menuValue); + assert.strictEqual(criteria.$until, expectedIso); + assert.strictEqual(criteria.isOpen, false); + }); + + it('should clear criteria for timestamp fields', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('timestamp', 'since', '17/05/2026 18:42:05.509'); + window.model.log.filter.setCriteria('timestamp', 'until', '17/05/2026 18:42:05.509'); + }); + await openContextMenu(page, 'timestamp', '17/05/2026 18:42:05.509', 100, 120); + await waitForFromToButtons(page); + await clickMenuItemByLabel(page, 'Clear filter'); + + const criteria = await page.evaluate(() => ({ + since: window.model.log.filter.criterias.timestamp.since, + $since: window.model.log.filter.criterias.timestamp.$since, + until: window.model.log.filter.criterias.timestamp.until, + $until: window.model.log.filter.criterias.timestamp.$until, + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(criteria.since, ''); + assert.strictEqual(criteria.$since, null); + + assert.strictEqual(criteria.until, ''); + assert.strictEqual(criteria.$until, null); + + assert.strictEqual(criteria.isOpen, false); + }); + + it('should disable "Clear filter" for timestamp when no filter is set', async () => { + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), true); + }); + + it('should enable "Clear filter" for timestamp when a filter is active', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('timestamp', 'since', '17/05/2026 18:42:05.509'); + }); + + await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); + await waitForFromToButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), false); + }); + }); + + describe('Show/Hide/Reset for severity field', async () => { + it('should disable "Show severity" when severity is already active', async () => { + await openContextMenu(page, 'severity', 'I', 100, 120); + await waitForSeverityButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Show severity'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Hide severity'), false); + }); + + it('should toggle severity off via "Hide severity"', async () => { + let severity = await page.evaluate(() => window.model.log.filter.criterias.severity.$in); + assert.ok(severity.includes('W')); + await openContextMenu(page, 'severity', 'W', 100, 120); + await waitForSeverityButtons(page); + await page.waitForFunction(() => { + const menu = document.querySelector('.cell-context-menu'); + return menu && menu.textContent.includes('W'); + }); + await clickMenuItemByLabel(page, 'Hide severity'); + + severity = await page.evaluate(() => window.model.log.filter.criterias.severity.$in); + assert.ok(!severity.includes('W')); + }); + + it('should disable "Hide severity" when severity is already hidden', async () => { + await page.evaluate(() => { + window.model.log.setCriteria('severity', 'in', 'W'); + }); + + await openContextMenu(page, 'severity', 'W', 100, 120); + await waitForSeverityButtons(page); + // wait 200ms with promise + + assert.strictEqual(await isMenuItemDisabled(page, 'Hide severity'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Show severity'), false); + }); + + it('should reset severity filter to default', async () => { + await page.evaluate(() => { + window.model.log.setCriteria('severity', 'in', 'I'); + }); + + await openContextMenu(page, 'severity', 'I', 100, 120); + await waitForSeverityButtons(page); + await clickMenuItemByLabel(page, 'Reset severity filter'); + + const severity = await page.evaluate(() => ({ + in: window.model.log.filter.criterias.severity.in, + $in: window.model.log.filter.criterias.severity.$in, + })); + + assert.strictEqual(severity.in, 'I W E F'); + assert.deepStrictEqual(severity.$in, ['I', 'W', 'E', 'F']); + }); + + it('should disable "Reset severity filter" when all severities are already shown', async () => { + await openContextMenu(page, 'severity', 'I', 100, 120); + await waitForSeverityButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Reset severity filter'), true); + }); + }); + + describe('Set/Clear level filter for level field', async () => { + it('should set level to nearest threshold above via include', async () => { + await openContextMenu(page, 'level', '3', 100, 120); + await waitForLevelButtons(page); + await clickMenuItemByLabel(page, 'Set level to Support'); + + const level = await page.evaluate(() => window.model.log.filter.criterias.level); + assert.strictEqual(level.max, 6); + assert.strictEqual(level.$max, 6); + }); + + it('should set level to nearest threshold below via exclude', async () => { + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + }); + + await openContextMenu(page, 'level', '3', 100, 120); + await waitForLevelButtons(page); + await clickMenuItemByLabel(page, 'Set level to Ops'); + + const level = await page.evaluate(() => window.model.log.filter.criterias.level); + assert.strictEqual(level.max, 1); + assert.strictEqual(level.$max, 1); + }); + + it('should disable "Clear level filter" when no level filter is set', async () => { + await openContextMenu(page, 'level', '3', 100, 120); + await waitForLevelButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Clear level filter'), true); + }); + + it('should enable "Clear level filter" when a level filter is active', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 6); + }); + + await openContextMenu(page, 'level', '3', 100, 120); + await waitForLevelButtons(page); + + assert.strictEqual(await isMenuItemDisabled(page, 'Clear level filter'), false); + }); + + it('should clear level filter back to null', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 6); + }); + + await openContextMenu(page, 'level', '3', 100, 120); + await waitForLevelButtons(page); + await clickMenuItemByLabel(page, 'Clear level filter'); + + const level = await page.evaluate(() => window.model.log.filter.criterias.level); + assert.strictEqual(level.max, null); + assert.strictEqual(level.$max, null); + }); + }); + + describe('Clipboard', async () => { + before(async () => { + await page.evaluate(() => { + window.__copiedContextMenuValue = undefined; + }); + }); + + it('should copy value to clipboard', async () => { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__copiedContextMenuValue = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Copy'); + + const copied = await page.evaluate(async () => { + await Promise.resolve(); + return { + value: window.__copiedContextMenuValue, + isOpen: window.model.log.contextMenu.isOpen, + }; + }); + + assert.strictEqual(copied.value, 'ctx-host-01'); + assert.strictEqual(copied.isOpen, false); + }); + + it('should show notification when clipboard write fails', async () => { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: () => Promise.reject(new Error('Clipboard access denied')), + }, + configurable: true, + }); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Copy'); + + await page.waitForFunction(() => window.model.notification.state === 'shown'); + const notification = await page.evaluate(() => ({ + message: window.model.notification.message, + type: window.model.notification.type, + })); + + assert.strictEqual(notification.message, 'Failed to copy to clipboard'); + assert.strictEqual(notification.type, 'danger'); + }); + }); + + describe('Inspector', async () => { + it('should open inspector and select the right-clicked row via "Open Inspector"', async () => { + await openContextMenu(page, 'message', 'ctx-message-01', 100, 120, exampleRow); + await waitForMatchExcludeButtons(page); + // wait for the menu to have the ctx-message-01 label to ensure the menu has rendered for the correct row before clicking + await page.waitForFunction(() => { + const menu = document.querySelector('.cell-context-menu'); + return menu && menu.textContent.includes('ctx-message-01'); + }); + await clickMenuItemByLabel(page, 'Open Inspector'); + + const result = await page.evaluate(() => ({ + inspectorEnabled: window.model.inspectorEnabled, + selectedMessage: window.model.log.item?.message, + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(result.inspectorEnabled, true); + assert.strictEqual(result.selectedMessage, 'ctx-message-01'); + assert.strictEqual(result.isOpen, false); + }); + + it('should not toggle inspector off if already open when clicking "Open Inspector"', async () => { + await page.evaluate(() => { + window.model.inspectorEnabled = true; + window.model.notify(); + }); + + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120, exampleRow); + await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Open Inspector'); + + const result = await page.evaluate(() => ({ + inspectorEnabled: window.model.inspectorEnabled, + selectedMessage: window.model.log.item?.message, + })); + + assert.strictEqual(result.inspectorEnabled, true); + assert.strictEqual(result.selectedMessage, 'ctx-message-01'); + }); + }); + }); + }); +}); diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 1aaedc144..904b53ad0 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -16,52 +16,6 @@ const assert = require('assert'); const test = require('../mocha-index'); -const isContextMenuOpen = async (page) => { - return await page.evaluate(() => window.model.log.contextMenu.isOpen); -}; - -const openContextMenu = async (page, field, value, x, y) => { - await page.evaluate((field, value, x, y) => { - window.model.log.showContextMenu(field, value, x, y); - }, field, value, x, y); - await page.waitForSelector('.cell-context-menu'); - assert.strictEqual(await isContextMenuOpen(page), true); -}; - -const waitForMatchExcludeButtons = async (page) => { - // wait for function as menu sometimes will render previous labels then update - await page.waitForFunction(() => { - const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .map((label) => label.textContent.trim()); - return labels.length === 4 - && labels[0] === 'Match' - && labels[1] === 'Exclude' - && labels[2] === 'Clear filter' - && labels[3] === 'Copy'; - }); -}; - -const waitForFromToButtons = async (page) => { - await page.waitForFunction(() => { - const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .map((label) => label.textContent.trim()); - return labels.length === 4 - && labels[0] === 'From' - && labels[1] === 'To' - && labels[2] === 'Clear filter' - && labels[3] === 'Copy'; - }); -}; - -const clickMenuItemByLabel = async (page, label) => { - await page.evaluate((label) => { - const item = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .find((el) => el.textContent.trim() === label) - ?.closest('.cell-context-menu-item'); - item.click(); - }, label); -}; - describe('Filter actions test-suite', async () => { let baseUrl; let page; @@ -270,295 +224,4 @@ describe('Filter actions test-suite', async () => { assert.strictEqual(criterias.severity.in, 'I W E F'); assert.deepStrictEqual(criterias.severity.$in, ['W', 'I', 'E', 'F']); }); - - describe('Cell Context Menu', async () => { - const exampleRow = { - severity: 'I', - level: 3, - timestamp: Date.parse('2024-05-11T10:20:30.000Z') / 1000, - hostname: 'ctx-host-01', - rolename: 'ctx-role', - pid: '2001', - username: 'ctx-user', - system: 'ctx-system', - facility: 'ctx-facility', - detector: 'ctx-detector', - partition: 'ctx-partition', - run: '12', - errcode: '404', - errline: '17', - errsource: 'ctx-source', - message: 'ctx-message-01', - }; - - beforeEach(async () => { - await page.evaluate((exampleRow) => { - window.confirm = () => false; - window.model.log.filter.resetCriteria(); - window.model.log.hideContextMenu(); - window.__copiedContextMenuValue = undefined; - window.model.log.list = [exampleRow]; - window.model.notify(); - }, exampleRow); - - // Wait until the table is updated with the new log entry - await page.waitForFunction(() => { - const cells = Array.from(document.querySelectorAll('td.cell')); - return cells.some((cell) => cell.textContent.trim() === 'ctx-host-01') - && cells.some((cell) => cell.textContent.trim() === 'ctx-message-01'); - }); - }); - - it('should show context menu on right click', async () => { - await page.evaluate(() => { - const hostNameCell = Array.from(document.querySelectorAll('td.cell')) - .find((cell) => cell.textContent.trim() === 'ctx-host-01'); - hostNameCell.dispatchEvent(new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, - clientX: 120, - clientY: 140, - button: 2, - })); - }); - - // Wait for render and for model value - await page.waitForSelector('.cell-context-menu'); - assert.strictEqual(await isContextMenuOpen(page), true); - }); - - it('should close context menu on Escape key', async () => { - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - - await page.evaluate(() => { - document.body.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Escape', - keyCode: 27, - which: 27, - bubbles: true, - cancelable: true, - })); - }); - - assert.strictEqual(await isContextMenuOpen(page), false); - }); - - it('should close context menu on Enter key', async () => { - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - - const isOpenAfterEnter = await page.evaluate(() => { - document.body.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', - keyCode: 13, - which: 13, - bubbles: true, - cancelable: true, - })); - - return window.model.log.contextMenu.isOpen; - }); - - assert.strictEqual(isOpenAfterEnter, false); - }); - - it('should close context menu on outside click', async () => { - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - - const isOpenAfterOutsideClick = await page.evaluate(() => { - const overlay = document.querySelector('.cell-context-menu-overlay'); - overlay.click(); - return window.model.log.contextMenu.isOpen; - }); - - assert.strictEqual(isOpenAfterOutsideClick, false); - }); - - it('should show correct actions for non-timestamp fields', async () => { - await openContextMenu(page, 'hostname', 'ctx-host-01', 120, 140); - await waitForMatchExcludeButtons(page); - }); - - it('should show correct actions for timestamp fields', async () => { - await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); - }); - - it('should apply "match" action for regular fields', async () => { - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); - await clickMenuItemByLabel(page, 'Match'); - - const criteria = await page.evaluate(() => ({ - match: window.model.log.filter.criterias.hostname.match, - $match: window.model.log.filter.criterias.hostname.$match, - isOpen: window.model.log.contextMenu.isOpen, - })); - - assert.strictEqual(criteria.match, 'ctx-host-01'); - assert.strictEqual(criteria.$match, 'ctx-host-01'); - assert.strictEqual(criteria.isOpen, false); - }); - - it('should apply "exclude" action for regular fields', async () => { - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); - await clickMenuItemByLabel(page, 'Exclude'); - - const criteria = await page.evaluate(() => ({ - exclude: window.model.log.filter.criterias.hostname.exclude, - $exclude: window.model.log.filter.criterias.hostname.$exclude, - isOpen: window.model.log.contextMenu.isOpen, - })); - - assert.strictEqual(criteria.exclude, 'ctx-host-01'); - assert.strictEqual(criteria.$exclude, 'ctx-host-01'); - assert.strictEqual(criteria.isOpen, false); - }); - - it('should clear criteria for regular fields', async () => { - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); - - await page.evaluate(() => { - window.model.log.filter.setCriteria('hostname', 'match', 'ctx-host-01'); - window.model.log.filter.setCriteria('hostname', 'exclude', 'ctx-host-01'); - }); - await clickMenuItemByLabel(page, 'Clear filter'); - - const criteria = await page.evaluate(() => ({ - match: window.model.log.filter.criterias.hostname.match, - $match: window.model.log.filter.criterias.hostname.$match, - exclude: window.model.log.filter.criterias.hostname.exclude, - $exclude: window.model.log.filter.criterias.hostname.$exclude, - isOpen: window.model.log.contextMenu.isOpen, - })); - - assert.strictEqual(criteria.match, ''); - assert.strictEqual(criteria.$match, null); - assert.strictEqual(criteria.exclude, ''); - assert.strictEqual(criteria.$exclude, null); - assert.strictEqual(criteria.isOpen, false); - }); - - it('should apply "from" action for timestamp fields', async () => { - await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); - - const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); - const expectedIso = await page.evaluate( - () => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(), - ); - await clickMenuItemByLabel(page, 'From'); - - const criteria = await page.evaluate(() => ({ - since: window.model.log.filter.criterias.timestamp.since, - $since: window.model.log.filter.criterias.timestamp.$since?.toISOString(), - isOpen: window.model.log.contextMenu.isOpen, - })); - - assert.strictEqual(criteria.since, menuValue); - assert.strictEqual(criteria.$since, expectedIso); - assert.strictEqual(criteria.isOpen, false); - }); - - it('should apply "to" action for timestamp fields', async () => { - await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); - - const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); - const expectedIso = await page.evaluate( - () => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString(), - ); - await clickMenuItemByLabel(page, 'To'); - - const criteria = await page.evaluate(() => ({ - until: window.model.log.filter.criterias.timestamp.until, - $until: window.model.log.filter.criterias.timestamp.$until?.toISOString(), - isOpen: window.model.log.contextMenu.isOpen, - })); - - assert.strictEqual(criteria.until, menuValue); - assert.strictEqual(criteria.$until, expectedIso); - assert.strictEqual(criteria.isOpen, false); - }); - - it('should clear criteria for timestamp fields', async () => { - await page.evaluate(() => { - window.model.log.filter.setCriteria('timestamp', 'since', '17/05/2026 18:42:05.509'); - window.model.log.filter.setCriteria('timestamp', 'until', '17/05/2026 18:42:05.509'); - }); - await openContextMenu(page, 'timestamp', '17/05/2026 18:42:05.509', 100, 120); - await waitForFromToButtons(page); - await clickMenuItemByLabel(page, 'Clear filter'); - - const criteria = await page.evaluate(() => ({ - since: window.model.log.filter.criterias.timestamp.since, - $since: window.model.log.filter.criterias.timestamp.$since, - until: window.model.log.filter.criterias.timestamp.until, - $until: window.model.log.filter.criterias.timestamp.$until, - isOpen: window.model.log.contextMenu.isOpen, - })); - - assert.strictEqual(criteria.since, ''); - assert.strictEqual(criteria.$since, null); - - assert.strictEqual(criteria.until, ''); - assert.strictEqual(criteria.$until, null); - - assert.strictEqual(criteria.isOpen, false); - }); - - it('should copy value to clipboard', async () => { - await page.evaluate(() => { - Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: (value) => { - window.__copiedContextMenuValue = value; - return Promise.resolve(); - }, - }, - configurable: true, - }); - }); - - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); - await clickMenuItemByLabel(page, 'Copy'); - - const copied = await page.evaluate(async () => { - await Promise.resolve(); - return { - value: window.__copiedContextMenuValue, - isOpen: window.model.log.contextMenu.isOpen, - }; - }); - - assert.strictEqual(copied.value, 'ctx-host-01'); - assert.strictEqual(copied.isOpen, false); - }); - - it('should show notification when clipboard write fails', async () => { - await page.evaluate(() => { - Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: () => Promise.reject(new Error('Clipboard access denied')), - }, - configurable: true, - }); - }); - - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); - await clickMenuItemByLabel(page, 'Copy'); - - await page.waitForFunction(() => window.model.notification.state === 'shown'); - const notification = await page.evaluate(() => ({ - message: window.model.notification.message, - type: window.model.notification.type, - })); - - assert.strictEqual(notification.message, 'Failed to copy to clipboard'); - assert.strictEqual(notification.type, 'danger'); - }); - }); }); From 0feda58c75ae019abe905fef956b1db0fefa9cfc Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 16:36:36 +0200 Subject: [PATCH 14/24] [OGUI-1453] Select row on right-click and add test Right-clicking a table cell now selects the row as well as opening the context menu as it is disorienting it doing otherwise. Since the row is now already selected by the time any menu action runs, the row parameter chain was removed. Adds a test that verifies this functionality and simplifies "Open Inspector" tests. --- InfoLogger/public/log/Log.js | 3 +- InfoLogger/public/log/cellContextMenu.js | 5 +-- InfoLogger/public/log/tableLogsContent.js | 9 ++-- .../test/public/context-menu-test-utils.js | 8 ++-- .../test/public/log-context-menu-mocha.js | 45 ++++++++++++------- 5 files changed, 40 insertions(+), 30 deletions(-) diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index fb7858e80..67b9e9d94 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -80,14 +80,13 @@ export default class Log extends Observable { }; } - showContextMenu(field, value, x, y, row) { + showContextMenu(field, value, x, y) { this.contextMenu = { isOpen: true, field, value, x, y, - row, }; this.notify(); } diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 6b9d06d6f..1fc01f4b5 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -48,7 +48,7 @@ export const cellContextMenu = (model) => { return null; } - const { field, value, x, y, row } = contextMenu; + const { field, value, x, y } = contextMenu; const pos = clampPosition(x, y, model.inspectorEnabled); const hideMenu = () => model.log.hideContextMenu(); @@ -176,9 +176,6 @@ export const cellContextMenu = (model) => { hideMenu(); }), createMenuItem(iconMagnifyingGlass(), 'primary', 'Open Inspector', () => { - if (row) { - model.log.setItem(row); - } if (!model.inspectorEnabled) { model.toggleInspector(); } diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 9e9c0c3b0..af8ea92ec 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -78,12 +78,12 @@ const tableLogLine = (model, row) => { /** * Resolves the required data to send to the context menu based on the cell's field and content. * @param {Model} model - root model of the application - * @param {object} row - values for each cell of the row * @param {string} field - the field associated to the cell (e.g. 'hostname', 'severity', etc.) * @param {string} content - the content of the cell * @returns {object|null} - the data for the context menu or null if not applicable */ -const resolveContextMenuData = (model, row, field, content) => { +const resolveContextMenuData = (model, field, content) => { + const row = model.log.item; if (field === 'date') { return row.timestamp ? { field: 'timestamp', value: String(content) } : null; } @@ -109,10 +109,11 @@ const cellWithContextMenu = (model, row, field, content, extraClasses = '', extr h(`td.cell${extraClasses}`, { ...extraAttrs, oncontextmenu: (e) => { - const data = resolveContextMenuData(model, row, field, content); + model.log.setItem(row); + const data = resolveContextMenuData(model, field, content); if (data) { e.preventDefault(); - model.log.showContextMenu(data.field, data.value, e.clientX, e.clientY, row); + model.log.showContextMenu(data.field, data.value, e.clientX, e.clientY); } }, }, content); diff --git a/InfoLogger/test/public/context-menu-test-utils.js b/InfoLogger/test/public/context-menu-test-utils.js index f7959cada..57e60ca39 100644 --- a/InfoLogger/test/public/context-menu-test-utils.js +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -16,10 +16,10 @@ const CONTEXT_MENU_RENDER_DELAY = 25; // delay to wait for context menu to rende const isContextMenuOpen = async (page) => await page.evaluate(() => window.model.log.contextMenu.isOpen); -const openContextMenu = async (page, field, value, x, y, row = null) => { - await page.evaluate((field, value, x, y, row) => { - window.model.log.showContextMenu(field, value, x, y, row); - }, field, value, x, y, row); +const openContextMenu = async (page, field, value, x, y) => { + await page.evaluate((field, value, x, y) => { + window.model.log.showContextMenu(field, value, x, y); + }, field, value, x, y); await page.waitForSelector('.cell-context-menu'); await new Promise((resolve) => setTimeout(resolve, CONTEXT_MENU_RENDER_DELAY)); }; diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index fd437e4f2..63a7f720f 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -141,6 +141,30 @@ describe('Cell Context Menu', async () => { assert.strictEqual(isOpenAfterOutsideClick, false); }); + it('should select the row on right-click', async () => { + await page.evaluate(() => { + window.model.log.setItem(null); + window.model.notify(); + }); + + // Dispatch actual right-click event on the cell to trigger the context menu and row selection + await page.evaluate(() => { + const cell = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.trim() === 'ctx-message-01'); + cell.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 120, + button: 2, + })); + }); + + await page.waitForSelector('.cell-context-menu'); + const selectedMessage = await page.evaluate(() => window.model.log.item?.message); + assert.strictEqual(selectedMessage, 'ctx-message-01'); + }); + it('should show hover tooltip on table rows', async () => { const title = await page.evaluate(() => { const row = document.querySelector('tr.row-hover'); @@ -639,24 +663,17 @@ describe('Cell Context Menu', async () => { }); describe('Inspector', async () => { - it('should open inspector and select the right-clicked row via "Open Inspector"', async () => { - await openContextMenu(page, 'message', 'ctx-message-01', 100, 120, exampleRow); + it('should open inspector via "Open Inspector"', async () => { + await openContextMenu(page, 'message', 'ctx-message-01', 100, 120); await waitForMatchExcludeButtons(page); - // wait for the menu to have the ctx-message-01 label to ensure the menu has rendered for the correct row before clicking - await page.waitForFunction(() => { - const menu = document.querySelector('.cell-context-menu'); - return menu && menu.textContent.includes('ctx-message-01'); - }); await clickMenuItemByLabel(page, 'Open Inspector'); const result = await page.evaluate(() => ({ inspectorEnabled: window.model.inspectorEnabled, - selectedMessage: window.model.log.item?.message, isOpen: window.model.log.contextMenu.isOpen, })); assert.strictEqual(result.inspectorEnabled, true); - assert.strictEqual(result.selectedMessage, 'ctx-message-01'); assert.strictEqual(result.isOpen, false); }); @@ -666,17 +683,13 @@ describe('Cell Context Menu', async () => { window.model.notify(); }); - await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120, exampleRow); + await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); await clickMenuItemByLabel(page, 'Open Inspector'); - const result = await page.evaluate(() => ({ - inspectorEnabled: window.model.inspectorEnabled, - selectedMessage: window.model.log.item?.message, - })); + const result = await page.evaluate(() => window.model.inspectorEnabled); - assert.strictEqual(result.inspectorEnabled, true); - assert.strictEqual(result.selectedMessage, 'ctx-message-01'); + assert.strictEqual(result, true); }); }); }); From 026cc97353e98e5d656c64ea76b653a3fd8fe9c8 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 16:45:26 +0200 Subject: [PATCH 15/24] [OGUI-1453] Fix JSDoc warning missing param --- InfoLogger/public/log/cellContextMenu.js | 1 + 1 file changed, 1 insertion(+) diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 1fc01f4b5..646e301c1 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -191,6 +191,7 @@ export const cellContextMenu = (model) => { * @param {string} iconClass - CSS class for the icon color (e.g. 'success', 'danger', 'primary') * @param {string} label - label to display in the menu item * @param {() => void} onClick - function to execute on click + * @param {boolean} disabled - whether the menu item should be disabled * @returns {vnode} - the menu item as a vnode */ function createMenuItem(icon, iconClass, label, onClick, disabled = false) { From ea5ca592170039a3e200581b8c5c2075aa2fd5f3 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 16:57:15 +0200 Subject: [PATCH 16/24] [OGUI-1453] Extract context menu into its own model Move context menu state and methods from Log into a dedicated model and update all calls. --- InfoLogger/public/Model.js | 4 +- InfoLogger/public/log/ContextMenu.js | 54 +++++++++++++++++++ InfoLogger/public/log/Log.js | 26 ++------- InfoLogger/public/log/cellContextMenu.js | 2 +- InfoLogger/public/log/tableLogsContent.js | 2 +- .../test/public/context-menu-test-utils.js | 2 +- .../test/public/log-context-menu-mocha.js | 2 +- 7 files changed, 63 insertions(+), 29 deletions(-) create mode 100644 InfoLogger/public/log/ContextMenu.js diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 76397a171..75b3c7c2b 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -209,7 +209,7 @@ export default class Model extends Observable { // Enter if ((code === 13 && !this.messageFocused || code === 13 && e.metaKey) && !this.log.isLiveModeEnabled()) { this.log.query(); - this.log.hideContextMenu(); + this.log.contextMenu.hide(); } if (!this.messageFocused) { // don't listen to keys when it comes from an input (they transform into letters) @@ -224,7 +224,7 @@ export default class Model extends Observable { case 27: // escape this.log.removeLogDownloadContent(); this.accountMenuEnabled = false; - this.log.hideContextMenu(); + this.log.contextMenu.hide(); break; case 37: // left if (e.altKey) { diff --git a/InfoLogger/public/log/ContextMenu.js b/InfoLogger/public/log/ContextMenu.js new file mode 100644 index 000000000..052739c02 --- /dev/null +++ b/InfoLogger/public/log/ContextMenu.js @@ -0,0 +1,54 @@ +/** + * @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 { Observable } from '/js/src/index.js'; + +/** + * Model for the log table cell context menu state. + */ +export default class ContextMenu extends Observable { + constructor() { + super(); + + this.isOpen = false; + this.field = null; + this.value = null; + this.x = 0; + this.y = 0; + } + + /** + * Open the context menu at the given position for a specific field/value. + * @param {string} field - the log field (e.g. 'hostname', 'severity', 'timestamp') + * @param {string} value - the cell value + * @param {number} x - mouse x position + * @param {number} y - mouse y position + */ + show(field, value, x, y) { + this.isOpen = true; + this.field = field; + this.value = value; + this.x = x; + this.y = y; + this.notify(); + } + + /** + * Close the context menu. + */ + hide() { + this.isOpen = false; + this.notify(); + } +} diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 67b9e9d94..5ea88dc3f 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -14,6 +14,7 @@ import { Observable, RemoteData } from '/js/src/index.js'; import LogFilter from '../logFilter/LogFilter.js'; +import ContextMenu from './ContextMenu.js'; import { MODE } from '../constants/mode.const.js'; import { TIME_MS } from '../common/Timezone.js'; import { ROW_HEIGHT } from '../constants/visual.const.js'; @@ -71,29 +72,8 @@ export default class Log extends Observable { table: '', }; - this.contextMenu = { - isOpen: false, - field: null, - value: null, - x: 0, - y: 0, - }; - } - - showContextMenu(field, value, x, y) { - this.contextMenu = { - isOpen: true, - field, - value, - x, - y, - }; - this.notify(); - } - - hideContextMenu() { - this.contextMenu.isOpen = false; - this.notify(); + this.contextMenu = new ContextMenu(); + this.contextMenu.bubbleTo(this); } /** diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 646e301c1..460e85adb 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -51,7 +51,7 @@ export const cellContextMenu = (model) => { const { field, value, x, y } = contextMenu; const pos = clampPosition(x, y, model.inspectorEnabled); - const hideMenu = () => model.log.hideContextMenu(); + const hideMenu = () => contextMenu.hide(); const isTimestamp = field === 'timestamp'; diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index af8ea92ec..d3f4325a7 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -113,7 +113,7 @@ const cellWithContextMenu = (model, row, field, content, extraClasses = '', extr const data = resolveContextMenuData(model, field, content); if (data) { e.preventDefault(); - model.log.showContextMenu(data.field, data.value, e.clientX, e.clientY); + model.log.contextMenu.show(data.field, data.value, e.clientX, e.clientY); } }, }, content); diff --git a/InfoLogger/test/public/context-menu-test-utils.js b/InfoLogger/test/public/context-menu-test-utils.js index 57e60ca39..ff225e69f 100644 --- a/InfoLogger/test/public/context-menu-test-utils.js +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -18,7 +18,7 @@ const isContextMenuOpen = async (page) => await page.evaluate(() => window.model const openContextMenu = async (page, field, value, x, y) => { await page.evaluate((field, value, x, y) => { - window.model.log.showContextMenu(field, value, x, y); + window.model.log.contextMenu.show(field, value, x, y); }, field, value, x, y); await page.waitForSelector('.cell-context-menu'); await new Promise((resolve) => setTimeout(resolve, CONTEXT_MENU_RENDER_DELAY)); diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index 63a7f720f..818304d8b 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -72,7 +72,7 @@ describe('Cell Context Menu', async () => { beforeEach(async () => { await page.evaluate(() => { window.model.log.filter.resetCriteria(); - window.model.log.hideContextMenu(); + window.model.log.contextMenu.hide(); }); }); From 9a73d89aac8228d7c1cf8b53dee460e3d2495256 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 17:57:07 +0200 Subject: [PATCH 17/24] [OGUI-1453] Make action labels title case Changes the labels in cellContextMenu. Changes the names in the affected tests and test utils. --- InfoLogger/public/log/cellContextMenu.js | 14 ++--- .../test/public/context-menu-test-utils.js | 16 +++--- .../test/public/log-context-menu-mocha.js | 56 +++++++++---------- 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 460e85adb..3655f962b 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -72,7 +72,7 @@ export const cellContextMenu = (model) => { createMenuItem( iconCheck(), 'success', - 'Show severity', + 'Show Severity', () => { model.log.setCriteria('severity', 'in', value); hideMenu(); @@ -82,14 +82,14 @@ export const cellContextMenu = (model) => { createMenuItem( iconBan(), 'danger', - 'Hide severity', + 'Hide Severity', () => { model.log.setCriteria('severity', 'in', value); hideMenu(); }, !isActive, ), - createMenuItem(iconTrash(), 'danger', 'Reset severity filter', () => { + createMenuItem(iconTrash(), 'danger', 'Reset Severity Filter', () => { model.log.filter.setCriteria('severity', 'in', 'I W E F'); hideMenu(); }, model.log.filter.criterias.severity.in === 'I W E F'), @@ -108,7 +108,7 @@ export const cellContextMenu = (model) => { createMenuItem( iconCheck(), 'success', - include ? `Set level to ${include.label}` : 'Show all levels', + include ? `Set Level To ${include.label}` : 'Show All Levels', () => { model.log.setCriteria('level', 'max', include?.max ?? null); hideMenu(); @@ -117,13 +117,13 @@ export const cellContextMenu = (model) => { createMenuItem( iconBan(), 'danger', - exclude ? `Set level to ${exclude.label}` : 'Show all levels', + exclude ? `Set Level To ${exclude.label}` : 'Show All Levels', () => { model.log.setCriteria('level', 'max', exclude?.max ?? null); hideMenu(); }, ), - createMenuItem(iconTrash(), 'danger', 'Clear level filter', () => { + createMenuItem(iconTrash(), 'danger', 'Clear Level Filter', () => { model.log.setCriteria('level', 'max', null); hideMenu(); }, model.log.filter.criterias.level.max === null), @@ -138,7 +138,7 @@ export const cellContextMenu = (model) => { model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', isTimestamp ? value : appendFilter('exclude')); hideMenu(); }), - createMenuItem(iconTrash(), 'danger', 'Clear filter', () => { + createMenuItem(iconTrash(), 'danger', 'Clear Filter', () => { model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', ''); model.log.setCriteria(field, isTimestamp ? 'since' : 'match', ''); hideMenu(); diff --git a/InfoLogger/test/public/context-menu-test-utils.js b/InfoLogger/test/public/context-menu-test-utils.js index ff225e69f..aba621cfe 100644 --- a/InfoLogger/test/public/context-menu-test-utils.js +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -32,7 +32,7 @@ const waitForMatchExcludeButtons = async (page) => { return labels.length === 5 && labels[0] === 'Match' && labels[1] === 'Exclude' - && labels[2] === 'Clear filter' + && labels[2] === 'Clear Filter' && labels[3] === 'Copy' && labels[4] === 'Open Inspector'; }); @@ -45,7 +45,7 @@ const waitForFromToButtons = async (page) => { return labels.length === 5 && labels[0] === 'From' && labels[1] === 'To' - && labels[2] === 'Clear filter' + && labels[2] === 'Clear Filter' && labels[3] === 'Copy' && labels[4] === 'Open Inspector'; }); @@ -56,9 +56,9 @@ const waitForSeverityButtons = async (page) => { const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) .map((label) => label.textContent.trim()); return labels.length === 5 - && labels[0] === 'Show severity' - && labels[1] === 'Hide severity' - && labels[2] === 'Reset severity filter' + && labels[0] === 'Show Severity' + && labels[1] === 'Hide Severity' + && labels[2] === 'Reset Severity Filter' && labels[3] === 'Copy' && labels[4] === 'Open Inspector'; }); @@ -69,9 +69,9 @@ const waitForLevelButtons = async (page) => { const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) .map((label) => label.textContent.trim()); return labels.length === 5 - && labels[0] === 'Set level to Support' - && labels[1] === 'Set level to Ops' - && labels[2] === 'Clear level filter' + && labels[0] === 'Set Level To Support' + && labels[1] === 'Set Level To Ops' + && labels[2] === 'Clear Level Filter' && labels[3] === 'Copy' && labels[4] === 'Open Inspector'; }); diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index 818304d8b..d65a88203 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -296,7 +296,7 @@ describe('Cell Context Menu', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); - await clickMenuItemByLabel(page, 'Clear filter'); + await clickMenuItemByLabel(page, 'Clear Filter'); const criteria = await page.evaluate(() => ({ match: window.model.log.filter.criterias.hostname.match, @@ -377,14 +377,14 @@ describe('Cell Context Menu', async () => { assert.strictEqual(match, 'first message\nctx-message-01'); }); - it('should disable "Clear filter" for regular fields when no filter is set', async () => { + it('should disable "Clear Filter" for regular fields when no filter is set', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), true); }); - it('should enable "Clear filter" for regular fields when a filter is active', async () => { + it('should enable "Clear Filter" for regular fields when a filter is active', async () => { await page.evaluate(() => { window.model.log.filter.setCriteria('hostname', 'match', 'some-host'); }); @@ -392,7 +392,7 @@ describe('Cell Context Menu', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); await waitForMatchExcludeButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), false); + assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), false); }); }); @@ -442,7 +442,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'timestamp', '17/05/2026 18:42:05.509', 100, 120); await waitForFromToButtons(page); - await clickMenuItemByLabel(page, 'Clear filter'); + await clickMenuItemByLabel(page, 'Clear Filter'); const criteria = await page.evaluate(() => ({ since: window.model.log.filter.criterias.timestamp.since, @@ -461,14 +461,14 @@ describe('Cell Context Menu', async () => { assert.strictEqual(criteria.isOpen, false); }); - it('should disable "Clear filter" for timestamp when no filter is set', async () => { + it('should disable "Clear Filter" for timestamp when no filter is set', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); await waitForFromToButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), true); }); - it('should enable "Clear filter" for timestamp when a filter is active', async () => { + it('should enable "Clear Filter" for timestamp when a filter is active', async () => { await page.evaluate(() => { window.model.log.filter.setCriteria('timestamp', 'since', '17/05/2026 18:42:05.509'); }); @@ -476,20 +476,20 @@ describe('Cell Context Menu', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); await waitForFromToButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear filter'), false); + assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), false); }); }); describe('Show/Hide/Reset for severity field', async () => { - it('should disable "Show severity" when severity is already active', async () => { + it('should disable "Show Severity" when severity is already active', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); await waitForSeverityButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Show severity'), true); - assert.strictEqual(await isMenuItemDisabled(page, 'Hide severity'), false); + assert.strictEqual(await isMenuItemDisabled(page, 'Show Severity'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Hide Severity'), false); }); - it('should toggle severity off via "Hide severity"', async () => { + it('should toggle severity off via "Hide Severity"', async () => { let severity = await page.evaluate(() => window.model.log.filter.criterias.severity.$in); assert.ok(severity.includes('W')); await openContextMenu(page, 'severity', 'W', 100, 120); @@ -498,13 +498,13 @@ describe('Cell Context Menu', async () => { const menu = document.querySelector('.cell-context-menu'); return menu && menu.textContent.includes('W'); }); - await clickMenuItemByLabel(page, 'Hide severity'); + await clickMenuItemByLabel(page, 'Hide Severity'); severity = await page.evaluate(() => window.model.log.filter.criterias.severity.$in); assert.ok(!severity.includes('W')); }); - it('should disable "Hide severity" when severity is already hidden', async () => { + it('should disable "Hide Severity" when severity is already hidden', async () => { await page.evaluate(() => { window.model.log.setCriteria('severity', 'in', 'W'); }); @@ -513,8 +513,8 @@ describe('Cell Context Menu', async () => { await waitForSeverityButtons(page); // wait 200ms with promise - assert.strictEqual(await isMenuItemDisabled(page, 'Hide severity'), true); - assert.strictEqual(await isMenuItemDisabled(page, 'Show severity'), false); + assert.strictEqual(await isMenuItemDisabled(page, 'Hide Severity'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Show Severity'), false); }); it('should reset severity filter to default', async () => { @@ -524,7 +524,7 @@ describe('Cell Context Menu', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); await waitForSeverityButtons(page); - await clickMenuItemByLabel(page, 'Reset severity filter'); + await clickMenuItemByLabel(page, 'Reset Severity Filter'); const severity = await page.evaluate(() => ({ in: window.model.log.filter.criterias.severity.in, @@ -535,11 +535,11 @@ describe('Cell Context Menu', async () => { assert.deepStrictEqual(severity.$in, ['I', 'W', 'E', 'F']); }); - it('should disable "Reset severity filter" when all severities are already shown', async () => { + it('should disable "Reset Severity Filter" when all severities are already shown', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); await waitForSeverityButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Reset severity filter'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Reset Severity Filter'), true); }); }); @@ -547,7 +547,7 @@ describe('Cell Context Menu', async () => { it('should set level to nearest threshold above via include', async () => { await openContextMenu(page, 'level', '3', 100, 120); await waitForLevelButtons(page); - await clickMenuItemByLabel(page, 'Set level to Support'); + await clickMenuItemByLabel(page, 'Set Level To Support'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); assert.strictEqual(level.max, 6); @@ -561,21 +561,21 @@ describe('Cell Context Menu', async () => { await openContextMenu(page, 'level', '3', 100, 120); await waitForLevelButtons(page); - await clickMenuItemByLabel(page, 'Set level to Ops'); + await clickMenuItemByLabel(page, 'Set Level To Ops'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); assert.strictEqual(level.max, 1); assert.strictEqual(level.$max, 1); }); - it('should disable "Clear level filter" when no level filter is set', async () => { + it('should disable "Clear Level Filter" when no level filter is set', async () => { await openContextMenu(page, 'level', '3', 100, 120); await waitForLevelButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear level filter'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), true); }); - it('should enable "Clear level filter" when a level filter is active', async () => { + it('should enable "Clear Level Filter" when a level filter is active', async () => { await page.evaluate(() => { window.model.log.filter.setCriteria('level', 'max', 6); }); @@ -583,7 +583,7 @@ describe('Cell Context Menu', async () => { await openContextMenu(page, 'level', '3', 100, 120); await waitForLevelButtons(page); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear level filter'), false); + assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), false); }); it('should clear level filter back to null', async () => { @@ -593,7 +593,7 @@ describe('Cell Context Menu', async () => { await openContextMenu(page, 'level', '3', 100, 120); await waitForLevelButtons(page); - await clickMenuItemByLabel(page, 'Clear level filter'); + await clickMenuItemByLabel(page, 'Clear Level Filter'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); assert.strictEqual(level.max, null); From ad5e88eaec7382354ee8831ed03ab45d5bdbcac5 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 18:06:29 +0200 Subject: [PATCH 18/24] [OGUI-1453] Simplify test helpers having consolidated timeout into openContextMenu It used to be that in the case that two following tests clicked on cells with different field types, you could use the labels to determine if the context menu had rerendered or not. This is a janky solution and it is easier to rely just on a small timeout in the openContextMenu helper instead. Thus I am removing these helpers. --- .../test/public/log-context-menu-mocha.js | 65 +++++++++---------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index d65a88203..c3c683c0a 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -18,10 +18,6 @@ const test = require('../mocha-index'); const { isContextMenuOpen, openContextMenu, - waitForMatchExcludeButtons, - waitForFromToButtons, - waitForSeverityButtons, - waitForLevelButtons, isMenuItemDisabled, clickMenuItemByLabel, } = require('./context-menu-test-utils'); @@ -235,22 +231,21 @@ describe('Cell Context Menu', async () => { describe('Menu actions visibility', async () => { it('should show correct actions for Match/Exclude fields', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 120, 140); - await waitForMatchExcludeButtons(page); }); it('should show correct actions for From/To fields', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); + ; }); it('should show correct actions for severity field', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); - await waitForSeverityButtons(page); + ; }); it('should show correct actions for level field', async () => { await openContextMenu(page, 'level', '3', 100, 120); - await waitForLevelButtons(page); + ; }); }); @@ -258,7 +253,7 @@ describe('Cell Context Menu', async () => { describe('Match/Exclude/Clear', async () => { it('should apply "match" action for regular fields', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); const criteria = await page.evaluate(() => ({ @@ -274,7 +269,7 @@ describe('Cell Context Menu', async () => { it('should apply "exclude" action for regular fields', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Exclude'); const criteria = await page.evaluate(() => ({ @@ -295,7 +290,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Clear Filter'); const criteria = await page.evaluate(() => ({ @@ -323,7 +318,7 @@ describe('Cell Context Menu', async () => { const menu = document.querySelector('.cell-context-menu'); return menu && menu.textContent.includes('ctx-system-01'); }); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); const match = await page.evaluate(() => window.model.log.filter.criterias.system.match); @@ -340,7 +335,7 @@ describe('Cell Context Menu', async () => { const menu = document.querySelector('.cell-context-menu'); return menu && menu.textContent.includes('ctx-host-01'); }); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Exclude'); const exclude = await page.evaluate(() => window.model.log.filter.criterias.hostname.exclude); @@ -353,7 +348,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); const match = await page.evaluate(() => window.model.log.filter.criterias.hostname.match); @@ -370,7 +365,7 @@ describe('Cell Context Menu', async () => { const menu = document.querySelector('.cell-context-menu'); return menu && menu.textContent.includes('ctx-message-01'); }); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Match'); const match = await page.evaluate(() => window.model.log.filter.criterias.message.match); @@ -379,7 +374,6 @@ describe('Cell Context Menu', async () => { it('should disable "Clear Filter" for regular fields when no filter is set', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), true); }); @@ -390,7 +384,6 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), false); }); @@ -399,7 +392,7 @@ describe('Cell Context Menu', async () => { describe('From/To/Clear', async () => { it('should apply "from" action for timestamp fields', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); + ; const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); const expectedIso = await page.evaluate(() => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString()); @@ -418,7 +411,7 @@ describe('Cell Context Menu', async () => { it('should apply "to" action for timestamp fields', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); + ; const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); const expectedIso = await page.evaluate(() => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString()); @@ -441,7 +434,7 @@ describe('Cell Context Menu', async () => { window.model.log.filter.setCriteria('timestamp', 'until', '17/05/2026 18:42:05.509'); }); await openContextMenu(page, 'timestamp', '17/05/2026 18:42:05.509', 100, 120); - await waitForFromToButtons(page); + ; await clickMenuItemByLabel(page, 'Clear Filter'); const criteria = await page.evaluate(() => ({ @@ -463,7 +456,7 @@ describe('Cell Context Menu', async () => { it('should disable "Clear Filter" for timestamp when no filter is set', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); + ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), true); }); @@ -474,7 +467,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - await waitForFromToButtons(page); + ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), false); }); @@ -483,7 +476,7 @@ describe('Cell Context Menu', async () => { describe('Show/Hide/Reset for severity field', async () => { it('should disable "Show Severity" when severity is already active', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); - await waitForSeverityButtons(page); + ; assert.strictEqual(await isMenuItemDisabled(page, 'Show Severity'), true); assert.strictEqual(await isMenuItemDisabled(page, 'Hide Severity'), false); @@ -493,7 +486,7 @@ describe('Cell Context Menu', async () => { let severity = await page.evaluate(() => window.model.log.filter.criterias.severity.$in); assert.ok(severity.includes('W')); await openContextMenu(page, 'severity', 'W', 100, 120); - await waitForSeverityButtons(page); + ; await page.waitForFunction(() => { const menu = document.querySelector('.cell-context-menu'); return menu && menu.textContent.includes('W'); @@ -510,7 +503,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'severity', 'W', 100, 120); - await waitForSeverityButtons(page); + ; // wait 200ms with promise assert.strictEqual(await isMenuItemDisabled(page, 'Hide Severity'), true); @@ -523,7 +516,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'severity', 'I', 100, 120); - await waitForSeverityButtons(page); + ; await clickMenuItemByLabel(page, 'Reset Severity Filter'); const severity = await page.evaluate(() => ({ @@ -537,7 +530,7 @@ describe('Cell Context Menu', async () => { it('should disable "Reset Severity Filter" when all severities are already shown', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); - await waitForSeverityButtons(page); + ; assert.strictEqual(await isMenuItemDisabled(page, 'Reset Severity Filter'), true); }); @@ -546,7 +539,7 @@ describe('Cell Context Menu', async () => { describe('Set/Clear level filter for level field', async () => { it('should set level to nearest threshold above via include', async () => { await openContextMenu(page, 'level', '3', 100, 120); - await waitForLevelButtons(page); + ; await clickMenuItemByLabel(page, 'Set Level To Support'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); @@ -560,7 +553,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'level', '3', 100, 120); - await waitForLevelButtons(page); + ; await clickMenuItemByLabel(page, 'Set Level To Ops'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); @@ -570,7 +563,7 @@ describe('Cell Context Menu', async () => { it('should disable "Clear Level Filter" when no level filter is set', async () => { await openContextMenu(page, 'level', '3', 100, 120); - await waitForLevelButtons(page); + ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), true); }); @@ -581,7 +574,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'level', '3', 100, 120); - await waitForLevelButtons(page); + ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), false); }); @@ -592,7 +585,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'level', '3', 100, 120); - await waitForLevelButtons(page); + ; await clickMenuItemByLabel(page, 'Clear Level Filter'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); @@ -622,7 +615,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Copy'); const copied = await page.evaluate(async () => { @@ -648,7 +641,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Copy'); await page.waitForFunction(() => window.model.notification.state === 'shown'); @@ -665,7 +658,7 @@ describe('Cell Context Menu', async () => { describe('Inspector', async () => { it('should open inspector via "Open Inspector"', async () => { await openContextMenu(page, 'message', 'ctx-message-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Open Inspector'); const result = await page.evaluate(() => ({ @@ -684,7 +677,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); - await waitForMatchExcludeButtons(page); + await clickMenuItemByLabel(page, 'Open Inspector'); const result = await page.evaluate(() => window.model.inspectorEnabled); From 8642faf00673a1d625d44d0ebf79038e75fd546b Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 18:43:09 +0200 Subject: [PATCH 19/24] [OGUI-1453] Remove unused render helpers --- .../test/public/context-menu-test-utils.js | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/InfoLogger/test/public/context-menu-test-utils.js b/InfoLogger/test/public/context-menu-test-utils.js index aba621cfe..64654b774 100644 --- a/InfoLogger/test/public/context-menu-test-utils.js +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -24,59 +24,6 @@ const openContextMenu = async (page, field, value, x, y) => { await new Promise((resolve) => setTimeout(resolve, CONTEXT_MENU_RENDER_DELAY)); }; -const waitForMatchExcludeButtons = async (page) => { - // wait for function as menu sometimes will render previous labels then update - await page.waitForFunction(() => { - const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .map((label) => label.textContent.trim()); - return labels.length === 5 - && labels[0] === 'Match' - && labels[1] === 'Exclude' - && labels[2] === 'Clear Filter' - && labels[3] === 'Copy' - && labels[4] === 'Open Inspector'; - }); -}; - -const waitForFromToButtons = async (page) => { - await page.waitForFunction(() => { - const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .map((label) => label.textContent.trim()); - return labels.length === 5 - && labels[0] === 'From' - && labels[1] === 'To' - && labels[2] === 'Clear Filter' - && labels[3] === 'Copy' - && labels[4] === 'Open Inspector'; - }); -}; - -const waitForSeverityButtons = async (page) => { - await page.waitForFunction(() => { - const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .map((label) => label.textContent.trim()); - return labels.length === 5 - && labels[0] === 'Show Severity' - && labels[1] === 'Hide Severity' - && labels[2] === 'Reset Severity Filter' - && labels[3] === 'Copy' - && labels[4] === 'Open Inspector'; - }); -}; - -const waitForLevelButtons = async (page) => { - await page.waitForFunction(() => { - const labels = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) - .map((label) => label.textContent.trim()); - return labels.length === 5 - && labels[0] === 'Set Level To Support' - && labels[1] === 'Set Level To Ops' - && labels[2] === 'Clear Level Filter' - && labels[3] === 'Copy' - && labels[4] === 'Open Inspector'; - }); -}; - const isMenuItemDisabled = async (page, label) => await page.evaluate((label) => { const item = Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) .find((el) => el.textContent.trim() === label) @@ -105,10 +52,6 @@ module.exports = { CONTEXT_MENU_RENDER_DELAY, isContextMenuOpen, openContextMenu, - waitForMatchExcludeButtons, - waitForFromToButtons, - waitForSeverityButtons, - waitForLevelButtons, isMenuItemDisabled, clickMenuItemByLabel, }; From 819fb5d7accbc2b363dc1b83125acf2c3f19976c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 21 May 2026 18:50:19 +0200 Subject: [PATCH 20/24] [OGUI-1453] Add simpler action label tests Add tests plus add utility function --- .../test/public/context-menu-test-utils.js | 5 +++ .../test/public/log-context-menu-mocha.js | 34 +++++++++---------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/InfoLogger/test/public/context-menu-test-utils.js b/InfoLogger/test/public/context-menu-test-utils.js index 64654b774..af93c98d2 100644 --- a/InfoLogger/test/public/context-menu-test-utils.js +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -16,6 +16,10 @@ const CONTEXT_MENU_RENDER_DELAY = 25; // delay to wait for context menu to rende const isContextMenuOpen = async (page) => await page.evaluate(() => window.model.log.contextMenu.isOpen); +const getMenuActionLabels = async (page) => page.evaluate(() => + Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) + .map((el) => el.textContent.trim())); + const openContextMenu = async (page, field, value, x, y) => { await page.evaluate((field, value, x, y) => { window.model.log.contextMenu.show(field, value, x, y); @@ -51,6 +55,7 @@ const clickMenuItemByLabel = async (page, label) => { module.exports = { CONTEXT_MENU_RENDER_DELAY, isContextMenuOpen, + getMenuActionLabels, openContextMenu, isMenuItemDisabled, clickMenuItemByLabel, diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index c3c683c0a..8e723c7d3 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -17,6 +17,7 @@ const assert = require('assert'); const test = require('../mocha-index'); const { isContextMenuOpen, + getMenuActionLabels, openContextMenu, isMenuItemDisabled, clickMenuItemByLabel, @@ -231,21 +232,26 @@ describe('Cell Context Menu', async () => { describe('Menu actions visibility', async () => { it('should show correct actions for Match/Exclude fields', async () => { await openContextMenu(page, 'hostname', 'ctx-host-01', 120, 140); + const labels = await getMenuActionLabels(page); + assert.deepStrictEqual(labels, ['Match', 'Exclude', 'Clear Filter', 'Copy', 'Open Inspector']); }); it('should show correct actions for From/To fields', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - ; + const labels = await getMenuActionLabels(page); + assert.deepStrictEqual(labels, ['From', 'To', 'Clear Filter', 'Copy', 'Open Inspector']); }); it('should show correct actions for severity field', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); - ; + const labels = await getMenuActionLabels(page); + assert.deepStrictEqual(labels, ['Show Severity', 'Hide Severity', 'Reset Severity Filter', 'Copy', 'Open Inspector']); }); it('should show correct actions for level field', async () => { await openContextMenu(page, 'level', '3', 100, 120); - ; + const labels = await getMenuActionLabels(page); + assert.deepStrictEqual(labels, ['Set Level To Support', 'Set Level To Ops', 'Clear Level Filter', 'Copy', 'Open Inspector']); }); }); @@ -392,7 +398,6 @@ describe('Cell Context Menu', async () => { describe('From/To/Clear', async () => { it('should apply "from" action for timestamp fields', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - ; const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); const expectedIso = await page.evaluate(() => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString()); @@ -411,7 +416,6 @@ describe('Cell Context Menu', async () => { it('should apply "to" action for timestamp fields', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - ; const menuValue = await page.evaluate(() => window.model.log.contextMenu.value); const expectedIso = await page.evaluate(() => window.model.timezone.parse(window.model.log.contextMenu.value)?.toISOString()); @@ -434,7 +438,7 @@ describe('Cell Context Menu', async () => { window.model.log.filter.setCriteria('timestamp', 'until', '17/05/2026 18:42:05.509'); }); await openContextMenu(page, 'timestamp', '17/05/2026 18:42:05.509', 100, 120); - ; + await clickMenuItemByLabel(page, 'Clear Filter'); const criteria = await page.evaluate(() => ({ @@ -456,7 +460,6 @@ describe('Cell Context Menu', async () => { it('should disable "Clear Filter" for timestamp when no filter is set', async () => { await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), true); }); @@ -467,7 +470,6 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'timestamp', '2024-05-11T10:20:30.000Z', 100, 120); - ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Filter'), false); }); @@ -476,7 +478,6 @@ describe('Cell Context Menu', async () => { describe('Show/Hide/Reset for severity field', async () => { it('should disable "Show Severity" when severity is already active', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); - ; assert.strictEqual(await isMenuItemDisabled(page, 'Show Severity'), true); assert.strictEqual(await isMenuItemDisabled(page, 'Hide Severity'), false); @@ -486,7 +487,7 @@ describe('Cell Context Menu', async () => { let severity = await page.evaluate(() => window.model.log.filter.criterias.severity.$in); assert.ok(severity.includes('W')); await openContextMenu(page, 'severity', 'W', 100, 120); - ; + await page.waitForFunction(() => { const menu = document.querySelector('.cell-context-menu'); return menu && menu.textContent.includes('W'); @@ -503,7 +504,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'severity', 'W', 100, 120); - ; + // wait 200ms with promise assert.strictEqual(await isMenuItemDisabled(page, 'Hide Severity'), true); @@ -516,7 +517,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'severity', 'I', 100, 120); - ; + await clickMenuItemByLabel(page, 'Reset Severity Filter'); const severity = await page.evaluate(() => ({ @@ -530,7 +531,6 @@ describe('Cell Context Menu', async () => { it('should disable "Reset Severity Filter" when all severities are already shown', async () => { await openContextMenu(page, 'severity', 'I', 100, 120); - ; assert.strictEqual(await isMenuItemDisabled(page, 'Reset Severity Filter'), true); }); @@ -539,7 +539,7 @@ describe('Cell Context Menu', async () => { describe('Set/Clear level filter for level field', async () => { it('should set level to nearest threshold above via include', async () => { await openContextMenu(page, 'level', '3', 100, 120); - ; + await clickMenuItemByLabel(page, 'Set Level To Support'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); @@ -553,7 +553,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'level', '3', 100, 120); - ; + await clickMenuItemByLabel(page, 'Set Level To Ops'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); @@ -563,7 +563,6 @@ describe('Cell Context Menu', async () => { it('should disable "Clear Level Filter" when no level filter is set', async () => { await openContextMenu(page, 'level', '3', 100, 120); - ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), true); }); @@ -574,7 +573,6 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'level', '3', 100, 120); - ; assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), false); }); @@ -585,7 +583,7 @@ describe('Cell Context Menu', async () => { }); await openContextMenu(page, 'level', '3', 100, 120); - ; + await clickMenuItemByLabel(page, 'Clear Level Filter'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); From ea1681e3d28be1892731114b2b944d40e6017851 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 12:58:27 +0200 Subject: [PATCH 21/24] [OGUI-1453] Fix message cell border --- InfoLogger/public/log/tableLogsContent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index d3f4325a7..9ea3492c5 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -161,7 +161,7 @@ const tableRows = (model, colsHeader, row) => { errcode.visible && cell('errcode', linkToWikiErrors(errcodeVal), '.cell-bordered'), errline.visible && cell('errline', errlineVal, '.cell-bordered'), errsource.visible && cell('errsource', errsourceVal, '.cell-bordered'), - message.visible && cell('message', messageVal, '', { title: messageVal }), + message.visible && cell('message', messageVal, '.cell-bordered', { title: messageVal }), ]; }; From f44868f55f70baa070582efae6e686a004b2b770 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:09:56 +0200 Subject: [PATCH 22/24] [OGUI-1216] - Add context menu hint button to hovered cells Adds a hint button inside each non-empty cell as a suggestion to the user that there are menu options available. --- InfoLogger/public/app.css | 28 +++++++++++++++- InfoLogger/public/log/tableLogsContent.js | 40 ++++++++++++++++------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index 5a76f162a..c262d759c 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -40,8 +40,15 @@ td, th { max-width: 0; /* allow ellipsis on tables */ vertical-align: top; } -.cell { line-height: 1rem; font-size: 1rem; padding: 0rem 0.2rem; font-weight: 100; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 18px; /* must be sync with rowHeight constant in view */ } +.cell { line-height: 1rem; font-size: 1rem; padding: 0rem 0.2rem; font-weight: 100; line-height: 18px; /* must be sync with rowHeight constant in view */ } .cell-bordered { border-left: 1px solid rgb(170, 170, 170); } +.cell-content { display: flex; justify-content: space-between; align-items: center; max-width: 100%; } +.cell-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} .cell-xs { width: 2rem; } .cell-s { width: 4rem; } @@ -56,6 +63,25 @@ th { max-width: 0; /* allow ellipsis on tables */ vertical-align: top; } .row-hover:hover { background-color: rgba(0, 0, 0, .075); } .row-selected, .row-selected:hover { background-color: #007bff; color: white; } +/* context menu hint */ +.cell-context-menu-hint { + display: none; + font-size: 0.80rem; + font-weight: bold; + color: var(--color-black); + background-color: #e0e0e0; + border-radius: 3px; + padding: 0 3px; + line-height: 1; + cursor: pointer; +} +.cell:hover .cell-context-menu-hint { display: block; } +.cell-context-menu-hint:hover { background-color: rgba(0, 0, 0, .15); } + +/* invert colors for selected rows */ +.row-selected .cell-context-menu-hint { color: var(--color-white); background-color: rgba(255, 255, 255, .15); } +.row-selected .cell-context-menu-hint:hover { background-color: rgba(255, 255, 255, .3); } + .table-max { width: 100%; } .pull-right { float: right; } diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 9ea3492c5..56e214175 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -69,7 +69,6 @@ const tableLogLine = (model, row) => { const { log, table } = model; return h('tr.row-hover', { className: log.item === row ? 'row-selected' : '', - title: 'Right-click for more options', onclick: () => log.setItem(row), ondblclick: () => model.toggleInspector(), }, tableRows(model, table.colsHeader, row)); @@ -105,18 +104,35 @@ const resolveContextMenuData = (model, field, content) => { * @param {object} extraAttrs - extra attributes to add to the cell * @returns {vnode} - the cell wrapped with the context menu */ -const cellWithContextMenu = (model, row, field, content, extraClasses = '', extraAttrs = {}) => - h(`td.cell${extraClasses}`, { +const cellWithContextMenu = (model, row, field, content, extraClasses = '', extraAttrs = {}) => { + const openContextMenu = (e) => { + model.log.setItem(row); + const data = resolveContextMenuData(model, field, content); + if (data) { + e.preventDefault(); + model.log.contextMenu.show(data.field, data.value, e.clientX, e.clientY); + } + }; + + const hasContent = content != null && content !== ''; + + return h(`td.cell${extraClasses}`, { ...extraAttrs, - oncontextmenu: (e) => { - model.log.setItem(row); - const data = resolveContextMenuData(model, field, content); - if (data) { - e.preventDefault(); - model.log.contextMenu.show(data.field, data.value, e.clientX, e.clientY); - } - }, - }, content); + oncontextmenu: hasContent ? openContextMenu : null, + }, [ + h('.cell-content', [ + h('.cell-text', content), + hasContent && h( + 'span.cell-context-menu-hint', + { + onclick: openContextMenu, + title: 'Right-click also opens this menu', + }, + '⋮', + ), + ]), + ]); +}; /** * Array of table rows From 91e68e4776224f0dd6346736968ffc53acbd0d8e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:11:16 +0200 Subject: [PATCH 23/24] [OGUI-1453] Fix context menu positioning for bottom of the screen openings Update menu height as it was not at all correct and legacy left unchecked. Accounts for the footer/status-bar so that is always visible. --- InfoLogger/public/log/cellContextMenu.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 3655f962b..25af0b0d0 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -15,7 +15,8 @@ import { h, iconCheck, iconBan, iconClipboard, iconTrash, iconMagnifyingGlass } from '/js/src/index.js'; const MENU_WIDTH = 220; -const MENU_HEIGHT_ESTIMATE = 120; +const MENU_HEIGHT_ESTIMATE = 236; +const FOOTER_HEIGHT = 26; const INSPECTOR_WIDTH_REM = 20; const SEVERITY_CANVAS_WIDTH_PX = 10; @@ -33,7 +34,7 @@ const clampPosition = (x, y, inspectorEnabled) => ({ x, window.innerWidth - MENU_WIDTH - SEVERITY_CANVAS_WIDTH_PX - (inspectorEnabled ? remToPx(INSPECTOR_WIDTH_REM) : 0), )), - top: Math.max(0, Math.min(y, window.innerHeight - MENU_HEIGHT_ESTIMATE)), + top: Math.max(0, Math.min(y, window.innerHeight - MENU_HEIGHT_ESTIMATE - FOOTER_HEIGHT)), }); /** From d4d29604ce1db4283c47477e93e7aa89e962ca26 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 22 May 2026 13:27:43 +0200 Subject: [PATCH 24/24] [OGUI-1453] Add tests for context hint button and empty cell behaviour Add context menu hint tests covering rendering, click behaviour and hover visibility. Refactor filled and empty row adding. --- .../test/public/log-context-menu-mocha.js | 114 ++++++++++++++++-- 1 file changed, 101 insertions(+), 13 deletions(-) diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index 8e723c7d3..522f6cf3b 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -24,7 +24,7 @@ const { } = require('./context-menu-test-utils'); describe('Cell Context Menu', async () => { - const exampleRow = { + const filledRow = { severity: 'I', level: 3, timestamp: Date.parse('2024-05-11T10:20:30.000Z') / 1000, @@ -43,6 +43,25 @@ describe('Cell Context Menu', async () => { message: 'ctx-message-01', }; + const emptyRow = { + severity: 'W', + level: 1, + timestamp: Date.parse('2024-05-11T11:00:00.000Z') / 1000, + hostname: '', + rolename: '', + pid: '', + username: '', + system: '', + facility: '', + detector: '', + partition: '', + run: '', + errcode: '', + errline: '', + errsource: '', + message: '', + }; + let baseUrl = null; let page = null; @@ -52,15 +71,14 @@ describe('Cell Context Menu', async () => { await page.goto(`${baseUrl}?profile=physicist`, { waitUntil: 'networkidle0' }); - await page.evaluate((exampleRow) => { + await page.evaluate((filledRow, emptyRow) => { window.confirm = () => false; - window.model.log.list = [exampleRow]; + window.model.log.list = [filledRow, emptyRow]; window.model.notify(); - }, exampleRow); + }, filledRow, emptyRow); - // Wait until the table is updated with the new log entry await page.waitForFunction(() => { - const cells = Array.from(document.querySelectorAll('td.cell')); + const cells = Array.from(document.querySelectorAll('.cell-text')); return cells.some((cell) => cell.textContent.trim() === 'ctx-host-01') && cells.some((cell) => cell.textContent.trim() === 'ctx-message-01'); }); @@ -76,7 +94,7 @@ describe('Cell Context Menu', async () => { describe('Menu visibility', async () => { it('should show context menu on right-click', async () => { await page.evaluate(() => { - const hostNameCell = Array.from(document.querySelectorAll('td.cell')) + const hostNameCell = Array.from(document.querySelectorAll('.cell-text')) .find((cell) => cell.textContent.trim() === 'ctx-host-01'); hostNameCell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, @@ -146,7 +164,7 @@ describe('Cell Context Menu', async () => { // Dispatch actual right-click event on the cell to trigger the context menu and row selection await page.evaluate(() => { - const cell = Array.from(document.querySelectorAll('td.cell')) + const cell = Array.from(document.querySelectorAll('.cell-text')) .find((cell) => cell.textContent.trim() === 'ctx-message-01'); cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, @@ -162,12 +180,19 @@ describe('Cell Context Menu', async () => { assert.strictEqual(selectedMessage, 'ctx-message-01'); }); - it('should show hover tooltip on table rows', async () => { - const title = await page.evaluate(() => { - const row = document.querySelector('tr.row-hover'); - return row?.getAttribute('title'); + it('should not open context menu on right-click of empty cell', async () => { + await page.evaluate(() => { + const emptyCell = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => { + const textEl = cell.querySelector('.cell-text'); + return textEl && textEl.textContent.trim() === ''; + }); + emptyCell.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, clientX: 100, clientY: 120, button: 2, + })); }); - assert.strictEqual(title, 'Right-click for more options'); + + assert.strictEqual(await isContextMenuOpen(page), false); }); }); @@ -685,4 +710,67 @@ describe('Cell Context Menu', async () => { }); }); }); + + describe('Context hint button', async () => { + beforeEach(async () => { + await page.evaluate(() => { + window.model.log.contextMenu.hide(); + window.model.log.setItem(null); + window.model.notify(); + }); + }); + + it('should render hint on cells with content', async () => { + const cell = await page.evaluateHandle(() => Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.includes('ctx-host-01'))); + await cell.hover(); + + const hintVisible = await page.evaluate(() => { + const cell = Array.from(document.querySelectorAll('td.cell')) + .find((c) => c.textContent.includes('ctx-host-01')); + const hint = cell?.querySelector('.cell-context-menu-hint'); + return hint ? true : false; + }); + + assert.strictEqual(hintVisible, true); + }); + + it('should not render hint on cells with empty content', async () => { + const emptyHints = await page.evaluate(() => { + const emptyCells = Array.from(document.querySelectorAll('td.cell')) + .filter((cell) => { + const textEl = cell.querySelector('.cell-text'); + return textEl && textEl.textContent.trim() === ''; + }); + return emptyCells.filter((cell) => cell.querySelector('.cell-context-menu-hint')).length; + }); + + assert.strictEqual(emptyHints, 0); + }); + + it('should open context menu when hint is clicked', async () => { + await page.evaluate(() => { + const hint = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.includes('ctx-host-01')) + ?.querySelector('.cell-context-menu-hint'); + hint.click(); + }); + + await page.waitForSelector('.cell-context-menu'); + assert.strictEqual(await isContextMenuOpen(page), true); + }); + + it('should select the row when hint is clicked', async () => { + await page.evaluate(() => { + const hint = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.includes('ctx-host-01')) + ?.querySelector('.cell-context-menu-hint'); + hint.click(); + }); + + await page.waitForSelector('.cell-context-menu'); + const selectedHostname = await page.evaluate(() => window.model.log.item?.hostname); + assert.strictEqual(selectedHostname, 'ctx-host-01'); + }); + }); });