From 7ebbed58dcfc2b054df06bb689e3945c9393be3b Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 25 Oct 2026 07:10:25 -0400 Subject: [PATCH] fix(analytics): anchor resize handles to table headers (#2274) Resize handles now use their static table header as the containing block. Already-positioned headers, including sticky Nodes headers, retain their existing positioning. Fixes #2217. Validation: assertion-failing RED commit followed by the fix; all standalone frontend suites and the unchanged server suite pass. The browser suite passes 48 checks covering desktop/mobile bounds, real dragging, saved widths and sticky headers. The browser regression waits for the initial theme render before measuring. The fix adds one positioning check per header during setup, with no new requests, dependencies or settings. Common ingestor validation is covered by the separate #2272 test-stability change. --- public/app.js | 2 + tests/e2e/test-table-fluid-e2e.js | 79 +++++++++++++++++++++++++++++ tests/unit/test-frontend-helpers.js | 44 ++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/public/app.js b/public/app.js index 6b177c89..84bfcc0b 100644 --- a/public/app.js +++ b/public/app.js @@ -2137,6 +2137,8 @@ function makeColumnsResizable(tableSelector, storageKey) { // Add resize handles ths.forEach((th, i) => { if (i === ths.length - 1) return; + // Anchor the handle to static headers without overriding sticky headers. + if (getComputedStyle(th).position === 'static') th.style.position = 'relative'; const handle = document.createElement('div'); handle.className = 'col-resize-handle'; handle.addEventListener('mousedown', (e) => { diff --git a/tests/e2e/test-table-fluid-e2e.js b/tests/e2e/test-table-fluid-e2e.js index 6795d030..189619f6 100644 --- a/tests/e2e/test-table-fluid-e2e.js +++ b/tests/e2e/test-table-fluid-e2e.js @@ -46,6 +46,13 @@ function expectHidden(pageName, w) { return vp ? vp.expectHidden : false; } +async function waitForAnalyticsTables(page) { + // The initial theme-refresh replaces the Nodes tab's tables. Wait for that + // lifecycle event and the handles on the resulting render before measuring. + await page.waitForFunction(() => window.__columnResizeThemeReady && + document.querySelector('#analyticsContent .analytics-table .col-resize-handle')); +} + (async () => { const browser = await chromium.launch({ headless: true, @@ -55,6 +62,66 @@ function expectHidden(pageName, w) { console.log(`\n=== #1056 fluid tables E2E against ${BASE} ===`); + for (const width of [1250, 375]) { + await step(`#2217: analytics resize handles stay on their headers at ${width}px`, async () => { + const ctx = await browser.newContext({ viewport: { width, height: 900 } }); + try { + await ctx.addInitScript(() => { + window.addEventListener('theme-refresh', () => { window.__columnResizeThemeReady = true; }, { once: true }); + }); + const page = await ctx.newPage(); + await page.goto(BASE + '/#/analytics?tab=nodes', { waitUntil: 'domcontentloaded' }); + await waitForAnalyticsTables(page); + const geometry = await page.evaluate(() => ({ + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + handles: Array.from(document.querySelectorAll('#analyticsContent .col-resize-handle')).map(handle => { + const header = handle.parentElement; + const h = header.getBoundingClientRect(), r = handle.getBoundingClientRect(); + return { anchored: handle.offsetParent === header, height: r.height, + top: r.top - h.top, edge: r.left + r.width / 2 - h.right }; + }), + })); + assert(geometry.handles.length > 0, 'expected analytics resize handles'); + assert(geometry.handles.every(h => h.anchored), 'resize handle has the page as its containing block'); + assert(geometry.handles.every(h => h.height > 0 && Math.abs(h.top) <= 2 && Math.abs(h.edge) <= 2), + 'resize handles must follow the header edge: ' + JSON.stringify(geometry.handles)); + assert(geometry.overflow <= 1, `analytics page overflows by ${geometry.overflow}px`); + + if (width === 1250) { + const selector = '#analyticsContent .analytics-table thead th:first-child'; + const header = page.locator(selector).first(); + await header.scrollIntoViewIfNeeded(); + const before = await header.evaluate(th => { + const r = th.getBoundingClientRect(); + return { width: th.offsetWidth, x: r.right - 2, y: r.top + r.height / 2 }; + }); + await page.mouse.move(before.x, before.y); + await page.mouse.down(); + await page.mouse.move(before.x + 40, before.y, { steps: 4 }); + await page.mouse.up(); + const dragged = await header.evaluate(th => ({ + width: th.offsetWidth, + percent: th.offsetWidth / th.closest('table').offsetWidth * 100, + saved: JSON.parse(localStorage.getItem('meshcore-analytics-nodes-0-col-widths')), + })); + assert(dragged.width > before.width + 20, 'dragging must resize the analytics column'); + assert(Array.isArray(dragged.saved), 'drag must save column widths'); + assert(Math.abs(dragged.saved[0] - dragged.percent) < 1, 'saved percentage must match dragged width'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForAnalyticsTables(page); + const restored = await header.evaluate(th => ({ + percent: th.offsetWidth / th.closest('table').offsetWidth * 100, + anchored: th.querySelector('.col-resize-handle').offsetParent === th, + })); + assert(Math.abs(restored.percent - dragged.saved[0]) < 1, 'reload must restore the saved column width'); + assert(restored.anchored, 'restored widths must still anchor the resize handle to its header'); + } + } finally { + await ctx.close(); + } + }); + } + await step('#2079: responsive columns preserve full-width status and spacer rows', async () => { const ctx = await browser.newContext({ viewport: { width: 375, height: 900 } }); try { @@ -134,6 +201,18 @@ function expectHidden(pageName, w) { assert(overflow.ok, `table overflows: tableW=${overflow.tableW} wrapW=${overflow.wrapW}`); }); + if (p.name === 'nodes') { + await step(`${tag}: resize handles preserve sticky headers (#2217)`, async () => { + const headers = await page.$$eval('#nodesTable th', ths => ths + .filter(th => th.offsetParent !== null && th.querySelector('.col-resize-handle')) + .map(th => ({ position: getComputedStyle(th).position, + anchored: th.querySelector('.col-resize-handle').offsetParent === th }))); + assert(headers.length > 0, 'expected visible Nodes resize handles'); + assert(headers.every(h => h.position === 'sticky' && h.anchored), + 'Nodes headers must remain sticky and contain their handles: ' + JSON.stringify(headers)); + }); + } + await step(`${tag}: +N hidden pill state matches hidden columns`, async () => { const info = await page.evaluate((sel) => { const t = document.querySelector(sel); diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index 240a05c2..35b71c0f 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -118,6 +118,50 @@ function realPathHashSize() { } // ===== APP.JS TESTS ===== +console.log('\n=== app.js: column resize handle positioning (#2217) ==='); +{ + function resizeFixture(positions, saved) { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + const ths = positions.map(position => ({ + style: { position: '' }, dataset: {}, scrollWidth: 80, + computedPosition: position, children: [], + appendChild(handle) { this.children.push(handle); }, + })); + const table = { + style: {}, dataset: {}, parentElement: { clientWidth: 400 }, + querySelector: sel => sel === 'thead' ? { querySelectorAll: () => ths } : null, + querySelectorAll: () => ths, + }; + ctx.document.querySelector = () => table; + ctx.document.createElement = () => ({ addEventListener: () => {} }); + ctx.getComputedStyle = th => ({ position: th.style.position || th.computedPosition }); + if (saved) ctx.localStorage.setItem('column-widths', JSON.stringify(ths.map(() => 100 / ths.length))); + return { ctx, table, ths }; + } + + for (const saved of [false, true]) { + test('static headers anchor handles with ' + (saved ? 'saved' : 'measured') + ' widths', () => { + const { ctx, ths } = resizeFixture(['static', 'static', 'static'], saved); + ctx.makeColumnsResizable('#table', 'column-widths'); + assert.strictEqual(ths[0].style.position, 'relative', 'first handle needs its own header as containing block'); + assert.strictEqual(ths[1].style.position, 'relative', 'each handle needs its own containing block'); + assert.strictEqual(ths[2].style.position, '', 'last header has no handle and needs no change'); + ctx.makeColumnsResizable('#table', 'column-widths'); + assert.deepStrictEqual(ths.map(th => th.children.length), [1, 1, 0], 'repeated setup must not duplicate handles'); + }); + + test('already positioned headers are preserved with ' + (saved ? 'saved' : 'measured') + ' widths', () => { + const { ctx, ths } = resizeFixture(['sticky', 'relative', 'absolute', 'fixed', 'static'], saved); + ctx.makeColumnsResizable('#table', 'column-widths'); + assert.deepStrictEqual(ths.map(th => th.style.position), ['', '', '', '', ''], + 'computed CSS positioning must not be overridden by an inline style'); + assert.deepStrictEqual(ths.map(th => th.children.length), [1, 1, 1, 1, 0]); + }); + } +} + console.log('\n=== app.js: timeAgo ==='); { const ctx = makeSandbox();