diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 308633a6a..75b3c7c2b 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.contextMenu.hide(); } 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.contextMenu.hide(); break; case 37: // left if (e.altKey) { diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index f3d4cba4e..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; } @@ -95,3 +121,85 @@ 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-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); + 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/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 a7f615841..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'; @@ -70,6 +71,9 @@ export default class Log extends Observable { this.dom = { table: '', }; + + this.contextMenu = new ContextMenu(); + this.contextMenu.bubbleTo(this); } /** diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js new file mode 100644 index 000000000..25af0b0d0 --- /dev/null +++ b/InfoLogger/public/log/cellContextMenu.js @@ -0,0 +1,206 @@ +/** + * @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, iconCheck, iconBan, iconClipboard, iconTrash, iconMagnifyingGlass } from '/js/src/index.js'; + +const MENU_WIDTH = 220; +const MENU_HEIGHT_ESTIMATE = 236; +const FOOTER_HEIGHT = 26; +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.max(0, Math.min( + 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 - FOOTER_HEIGHT)), +}); + +/** + * 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 const cellContextMenu = (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 = () => contextMenu.hide(); + + 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', { + 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), + ]), + ...filterItems(), + createMenuItem(iconClipboard(), 'primary', 'Copy', () => { + navigator.clipboard.writeText(value).catch(() => { + model.notification.show('Failed to copy to clipboard', 'danger', 2000); + }); + hideMenu(); + }), + createMenuItem(iconMagnifyingGlass(), 'primary', 'Open Inspector', () => { + if (!model.inspectorEnabled) { + model.toggleInspector(); + } + hideMenu(); + }), + ]), + ]; +}; + +/** + * Creates menu item for the context menu of a cell with given icon, label and click action. + * @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 + * @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) { + return h('.cell-context-menu-item.f7', { + onclick: disabled ? null : onClick, + className: disabled ? 'disabled' : '', + }, [ + h(`span.${iconClass}`, icon), + h('span.ph2.w-100', { style: { fontWeight: 'bold' } }, label), + ]); +} diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 9c45c283b..56e214175 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -65,11 +65,74 @@ 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' : '', + 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. + * @param {Model} model - root model of the application + * @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, field, content) => { + const row = model.log.item; + 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; +}; + +/** + * 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 = {}) => { + 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: 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 @@ -78,26 +141,45 @@ const tableLogLine = (model, row) => h('tr.row-hover', { * @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 && 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), +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) }, + ), + 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, '.cell-bordered', { title: messageVal }), ]; +}; /** * Creates link of error code to open in a new tab the wiki page associated diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 8f744afce..35057213c 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', [ 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..af93c98d2 --- /dev/null +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -0,0 +1,62 @@ +/** + * @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 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); + }, field, value, x, y); + await page.waitForSelector('.cell-context-menu'); + await new Promise((resolve) => setTimeout(resolve, CONTEXT_MENU_RENDER_DELAY)); +}; + +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, + getMenuActionLabels, + openContextMenu, + 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..522f6cf3b --- /dev/null +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -0,0 +1,776 @@ +/** + * @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, + getMenuActionLabels, + openContextMenu, + isMenuItemDisabled, + clickMenuItemByLabel, +} = require('./context-menu-test-utils'); + +describe('Cell Context Menu', async () => { + const filledRow = { + 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', + }; + + 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; + + before(async () => { + ({ baseUrl } = test.helpers); + ({ page } = test); + + await page.goto(`${baseUrl}?profile=physicist`, { waitUntil: 'networkidle0' }); + + await page.evaluate((filledRow, emptyRow) => { + window.confirm = () => false; + window.model.log.list = [filledRow, emptyRow]; + window.model.notify(); + }, filledRow, emptyRow); + + await page.waitForFunction(() => { + 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'); + }); + }); + + beforeEach(async () => { + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.contextMenu.hide(); + }); + }); + + describe('Menu visibility', async () => { + it('should show context menu on right-click', async () => { + await page.evaluate(() => { + const hostNameCell = Array.from(document.querySelectorAll('.cell-text')) + .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 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('.cell-text')) + .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 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(await isContextMenuOpen(page), false); + }); + }); + + 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); + 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']); + }); + }); + + 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 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 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 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 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 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 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 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); + + 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); + + 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); + + 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); + + 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 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); + + 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); + + 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); + + 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 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); + + // 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 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); + + 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 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 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); + + 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); + + 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 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 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 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 via "Open Inspector"', async () => { + await openContextMenu(page, 'message', 'ctx-message-01', 100, 120); + + await clickMenuItemByLabel(page, 'Open Inspector'); + + const result = await page.evaluate(() => ({ + inspectorEnabled: window.model.inspectorEnabled, + isOpen: window.model.log.contextMenu.isOpen, + })); + + assert.strictEqual(result.inspectorEnabled, true); + 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); + + await clickMenuItemByLabel(page, 'Open Inspector'); + + const result = await page.evaluate(() => window.model.inspectorEnabled); + + assert.strictEqual(result, true); + }); + }); + }); + }); + + 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'); + }); + }); +}); diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 1cba99c65..904b53ad0 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -224,5 +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']); }); - });