From 1e3804257e8d5218280182fbe4e7439f2d8fd77a Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:57:47 +0200 Subject: [PATCH 1/4] Fix log table multiple row copy/paste behaviour Simplifies log cell markup by removing `.cell-content` and `.cell-text` wrappers so text sits directly in ``, avoiding extra line breaks when copying multi-row selections. Thus updates `.cell` styling to handle truncation directly and repositions the context-menu hint as an absolute, non-selectable overlay so it doesn't get copied. --- InfoLogger/public/app.css | 21 +++++++++++++++------ InfoLogger/public/log/tableLogsContent.js | 21 ++++++++++----------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index 655fbf54b..e2e6bc1f5 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -51,15 +51,17 @@ td, th { max-width: 0; /* allow ellipsis on tables */ vertical-align: top; } -.cell { line-height: var(--row-height); font-size: 1rem; padding: 0rem 0.2rem; font-weight: 100; } -.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 { +.cell { + position: relative; + line-height: var(--row-height); + font-size: 1rem; + padding: 0rem 0.2rem; + font-weight: 100; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - width: 100%; } +.cell-bordered { border-left: 1px solid rgb(170, 170, 170); } .cell-xs { width: 2rem; } .cell-s { width: 4rem; } @@ -74,9 +76,14 @@ 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 */ +/* context menu hint - out of flow and unselectable so it is never part of a copied selection */ .cell-context-menu-hint { display: none; + /* position absolutely within the cell */ + position: absolute; + top: 50%; + right: 0.2rem; + transform: translateY(-50%); font-size: 0.80rem; font-weight: bold; color: var(--color-black); @@ -85,6 +92,8 @@ th { max-width: 0; /* allow ellipsis on tables */ vertical-align: top; } padding: 0 3px; line-height: 1; cursor: pointer; + user-select: none; + -webkit-user-select: none; } .cell:hover .cell-context-menu-hint { display: block; } .cell-context-menu-hint:hover { background-color: rgba(0, 0, 0, .15); } diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 8fd4ee8d0..e0a207a48 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -119,17 +119,16 @@ const cellWithContextMenu = (model, row, field, content, extraClasses = '', extr ...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', - }, - '⋮', - ), - ]), + // content sits directly in the so that it can be selected/copied without new lines + content, + hasContent && h( + 'span.cell-context-menu-hint', + { + onclick: openContextMenu, + title: 'Right-click also opens this menu', + }, + '⋮', + ), ]); }; From fd70032fa749416d41f7c06ebda24956bebfd069 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:32:08 +0200 Subject: [PATCH 2/4] Remove empty CSS rule --- InfoLogger/public/app.css | 1 - 1 file changed, 1 deletion(-) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index e2e6bc1f5..b3decfe96 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -43,7 +43,6 @@ .table-logs-header td { padding: 0.3rem 0.2rem; } .table-logs-content { width: 100%; border-collapse: collapse; } -.table-logs-content td {} .table-logs-header td, .table-logs-content td { font-size: var(--log-font-size); } From 6ac5f8acb7be84568a1d634dbe76c93cbeee367b Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:12:07 +0200 Subject: [PATCH 3/4] Add multiline selection test for bug and extract test helpers Add new test covering bug. Extract shared Puppeteer log/table helpers into a reusable test utility. Update context-menu assertions to read cell text from `td.cell`. --- .../test/public/log-context-menu-mocha.js | 24 ++++----- InfoLogger/test/public/query-mode-mocha.js | 33 ++++++++++++ InfoLogger/test/public/status-bar-mocha.js | 33 +----------- InfoLogger/test/utils/utils.js | 50 +++++++++++++++++++ 4 files changed, 93 insertions(+), 47 deletions(-) create mode 100644 InfoLogger/test/utils/utils.js diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index 8b98f6482..ed2ba06e6 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -78,9 +78,9 @@ describe('Cell Context Menu', async () => { }, 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'); + const cells = Array.from(document.querySelectorAll('td.cell')); + return cells.some((cell) => cell.textContent.includes('ctx-host-01')) + && cells.some((cell) => cell.textContent.includes('ctx-message-01')); }); }); @@ -94,8 +94,8 @@ 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('.cell-text')) - .find((cell) => cell.textContent.trim() === 'ctx-host-01'); + const hostNameCell = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.includes('ctx-host-01')); hostNameCell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, @@ -164,8 +164,8 @@ 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('.cell-text')) - .find((cell) => cell.textContent.trim() === 'ctx-message-01'); + const cell = Array.from(document.querySelectorAll('td.cell')) + .find((cell) => cell.textContent.includes('ctx-message-01')); cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, @@ -183,10 +183,7 @@ describe('Cell Context Menu', async () => { 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() === ''; - }); + .find((cell) => cell.textContent.trim() === ''); emptyCell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 120, button: 2, })); @@ -748,10 +745,7 @@ describe('Cell Context Menu', async () => { 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() === ''; - }); + .filter((cell) => cell.textContent.trim() === ''); return emptyCells.filter((cell) => cell.querySelector('.cell-context-menu-hint')).length; }); diff --git a/InfoLogger/test/public/query-mode-mocha.js b/InfoLogger/test/public/query-mode-mocha.js index aeba2d673..f6de022fb 100644 --- a/InfoLogger/test/public/query-mode-mocha.js +++ b/InfoLogger/test/public/query-mode-mocha.js @@ -14,6 +14,7 @@ const assert = require('assert'); const test = require('../mocha-index'); +const { injectLogs, waitForTextInElement } = require('../utils/utils'); const TEXT_FILTER_VALUE_BY_OPERATOR = { since: '2026-01-01T00:00:00.000Z', @@ -139,6 +140,38 @@ describe('Query Mode test-suite', async () => { } }); + it('should copy multiple rows in the correct format', async () => { + await injectLogs(page, [ + { severity: 'I', message: 'info log', timestamp: Date.now() }, + { severity: 'E', message: 'error log', timestamp: Date.now() }, + { severity: 'W', message: 'warning log', timestamp: Date.now() }, + ]); + await waitForTextInElement(page, '.table-logs-content tbody tr:first-child', 'info log'); + + // select the first two rows entirely, as a user dragging across them would + const copied = await page.evaluate(() => { + const rows = document.querySelectorAll('.table-logs-content tbody tr'); + const range = document.createRange(); + range.setStartBefore(rows[0].querySelector('td:first-child')); + range.setEndAfter(rows[1].querySelector('td:last-child')); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + // what the browser puts on the clipboard as text/plain for this selection + return selection.toString(); + }); + + const lines = copied.split('\n').filter((line) => line.trim() !== ''); + console.log('copied text:', copied); + + assert.strictEqual(lines.length, 2, `selection should be one line per row, got:\n${copied}`); + assert.ok(lines[0].includes('info log'), 'first line should hold the first row message'); + assert.ok(lines[1].includes('error log'), 'second line should hold the second row message'); + assert.ok(!copied.includes('⋮'), 'the context menu hint should not be part of the copied text'); + }); + describe('no-text-filter confirmation dialog', () => { let textFilterOperators; diff --git a/InfoLogger/test/public/status-bar-mocha.js b/InfoLogger/test/public/status-bar-mocha.js index de41fbc15..6002f4685 100644 --- a/InfoLogger/test/public/status-bar-mocha.js +++ b/InfoLogger/test/public/status-bar-mocha.js @@ -14,6 +14,7 @@ const assert = require('assert'); const test = require('../mocha-index'); +const { injectLogs, waitForTextInElement } = require('../utils/utils'); /** * Helper function to get the counts of each severity type from the status bar. @@ -33,38 +34,6 @@ async function getSeverityCounts(page) { }); } -/** - * Helper function to inject logs into the model and trigger a re-render. - * @param {Page} page - puppeteer page - * @param {Array<{severity: string}>} logs - array of log objects to inject - */ -async function injectLogs(page, logs) { - await page.evaluate((logs) => { - window.model.log.list = logs; - window.model.log.resetStats(); - window.model.log.list.forEach((log) => window.model.log.addStats(log)); - window.model.notify(); - }, logs); -} - -/** - * Helper to wait until an element's text includes the expected substring. - * @param {Page} page - puppeteer page - * @param {string} selector - CSS selector - * @param {string} text - substring to wait for - */ -async function waitForTextInElement(page, selector, text) { - await page.waitForFunction( - (sel, txt) => { - const el = document.querySelector(sel); - return el && el.textContent.includes(txt); - }, - { timeout: 2000 }, - selector, - text, - ); -} - describe('Status Bar test-suite', async () => { const AUTOSCROLL_SELECTOR = '#status-bar-application-options label[title*="Scroll down"] input'; const INSPECTOR_SELECTOR = '#status-bar-application-options label[title*="Show details"] input'; diff --git a/InfoLogger/test/utils/utils.js b/InfoLogger/test/utils/utils.js new file mode 100644 index 000000000..d4a21ae58 --- /dev/null +++ b/InfoLogger/test/utils/utils.js @@ -0,0 +1,50 @@ +/** + * @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. + */ + +/** + * Helper function to inject logs into the model and trigger a re-render. + * @param {Page} page - puppeteer page + * @param {Array<{severity: string}>} logs - array of log objects to inject + */ +async function injectLogs(page, logs) { + await page.evaluate((logs) => { + window.model.log.list = logs; + window.model.log.resetStats(); + window.model.log.list.forEach((log) => window.model.log.addStats(log)); + window.model.notify(); + }, logs); +}; + +/** + * Helper to wait until an element's text includes the expected substring. + * @param {Page} page - puppeteer page + * @param {string} selector - CSS selector + * @param {string} text - substring to wait for + */ +async function waitForTextInElement(page, selector, text) { + await page.waitForFunction( + (sel, txt) => { + const el = document.querySelector(sel); + return el && el.textContent.includes(txt); + }, + { timeout: 2000 }, + selector, + text, + ); +} + +module.exports = { + injectLogs, + waitForTextInElement, +}; From 42300a498388be5b66aaf3ceba6cfb396e6470f3 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:16:42 +0200 Subject: [PATCH 4/4] Remove debug log from query mode test --- InfoLogger/test/public/query-mode-mocha.js | 1 - 1 file changed, 1 deletion(-) diff --git a/InfoLogger/test/public/query-mode-mocha.js b/InfoLogger/test/public/query-mode-mocha.js index f6de022fb..f15ea3b11 100644 --- a/InfoLogger/test/public/query-mode-mocha.js +++ b/InfoLogger/test/public/query-mode-mocha.js @@ -164,7 +164,6 @@ describe('Query Mode test-suite', async () => { }); const lines = copied.split('\n').filter((line) => line.trim() !== ''); - console.log('copied text:', copied); assert.strictEqual(lines.length, 2, `selection should be one line per row, got:\n${copied}`); assert.ok(lines[0].includes('info log'), 'first line should hold the first row message');