diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css
index 655fbf54b..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); }
@@ -51,15 +50,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 +75,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 +91,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',
+ },
+ '⋮',
+ ),
]);
};
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..f15ea3b11 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,37 @@ 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() !== '');
+
+ 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,
+};
|