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.
This commit is contained in:
n30nex authored and GitHub committed 2026-10-25 12:10:25 +01:00
1 parent 12e0a40a82
commit 7ebbed58dc
3 files changed
+125

No files matched your search

+2
View File
@@ -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) => {
+79
View File
@@ -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);
+44
View File
@@ -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();