feat(dashboard): add revision-safe interactive widget grid

This commit is contained in:
cesnimda
2026-10-28 15:32:47 +01:00
parent aea7c29e70
commit 0bd290e788
21 changed files with 696 additions and 108 deletions
@@ -92,7 +92,8 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
const editor = page.getByRole('region', { name: t.dashboardEditing, exact: true });
await expect(editor).toBeVisible();
await expect(editor.locator('..')).toBeFocused();
await expect(editor.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible();
await editor.locator('summary').click();
await expect(editor.getByRole('group', { name: t.dashboardManageWidgets })).toBeVisible();
await expect(editor.getByRole('checkbox')).toHaveCount(9);
if (language === 'en') {
const editorScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
@@ -117,6 +118,7 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: 'en' } })).status()).toBe(204);
await page.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await page.reload();
await page.getByRole('button', { name: 'Customize dashboard' }).click();
await page.getByRole('region', { name: 'Editing dashboard', exact: true }).locator('summary').click();
for (const item of await page.getByRole('region', { name: 'Editing dashboard', exact: true }).getByRole('checkbox').all()) await item.uncheck();
await page.getByRole('button', { name: 'Save layout', exact: true }).click();
await expect(page.getByRole('region', { name: 'Editing dashboard', exact: true })).toHaveCount(0);
@@ -141,6 +143,7 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
await capture(page, info, 'dashboard-new-account');
await page.getByRole('button', { name: 'Customize dashboard' }).click();
const newAccountEditor = page.getByRole('region', { name: 'Editing dashboard', exact: true });
await newAccountEditor.locator('summary').click();
await expect(newAccountEditor.getByRole('checkbox')).toHaveCount(9);
for (const item of await newAccountEditor.getByRole('checkbox').all()) await expect(item).toBeChecked();
await page.keyboard.press('Escape');
@@ -23,8 +23,15 @@ async function readLayout(api: APIRequestContext): Promise<DashboardLayout> {
return response.json();
}
const editor = (page: Page) => page.getByRole('region', { name: 'Editing dashboard', exact: true });
const preview = (page: Page) => page.getByRole('region', { name: 'Dashboard preview', exact: true });
async function options(page: Page, id: string) {
const widget = page.locator(`[data-dashboard-widget="${id}"]`);
await widget.locator('summary').first().click();
return widget;
}
async function openEditor(page: Page) {
await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click();
await editor(page).locator('summary').click();
await expect(editor(page).getByRole('checkbox')).toHaveCount(9);
}
async function save(page: Page) {
@@ -76,14 +83,15 @@ const test = base.extend<{ account: Account }>({
test('[P8] Saved order, size and visibility survive HTTP readback, a fresh browser and logout/login', async ({ page, browser, account }, info) => {
await openEditor(page);
await editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }).click();
await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
await options(page, 'funnel');
await preview(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }).click();
await preview(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck();
await save(page);
const saved = await readLayout(page.request);
expect(saved.version).toBe(1); expect(saved.revision).toBe(1);
expect(saved.version).toBe(2); expect(saved.revision).toBe(1);
expect(saved.widgets.map(widget => widget.id)).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
expect(saved.widgets.find(widget => widget.id === 'funnel')).toEqual({ id: 'funnel', visible: true, size: 'full' });
expect(saved.widgets.find(widget => widget.id === 'funnel')).toMatchObject({ id: 'funnel', visible: true, size: 'full', w: 12 });
expect(saved.widgets.find(widget => widget.id === 'activity')?.visible).toBe(false);
const assertSaved = async (target: Page) => {
await expect(target.locator('[data-dashboard-widget="activity"]')).toHaveCount(0);
@@ -153,7 +161,8 @@ test('[P8] HTTP 503 and offline transport failure retain draft and support an ac
const before = await readLayout(page.request);
await openEditor(page);
await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck();
await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
await options(page, 'funnel');
await preview(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
let failure: '503' | 'offline' | null = '503';
await page.route(layoutUrl, route => route.request().method() !== 'PUT' || !failure ? route.continue()
: failure === '503' ? route.fulfill({ status: 503, json: { message: 'Controlled unavailable' } }) : route.abort('internetdisconnected'));
@@ -167,7 +176,7 @@ test('[P8] HTTP 503 and offline transport failure retain draft and support an ac
await expect(editor(page).getByRole('alert')).toContainText('Your layout could not be saved');
await expect(editor(page).getByRole('button', { name: 'Save layout', exact: true })).toBeEnabled();
await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
await expect(editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true })).toHaveValue('full');
await expect(preview(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true })).toHaveValue('full');
expect(await readLayout(page.request)).toEqual(before);
await capture(page, info, `draft-retained-${mode}`);
}
@@ -183,7 +192,7 @@ test('[P8] HTTP 503 and offline transport failure retain draft and support an ac
await expect(editor(page).getByRole('button', { name: 'Saving layout…', exact: true })).toBeDisabled();
await expect(editor(page).getByRole('button', { name: 'Cancel', exact: true })).toBeDisabled();
for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeDisabled();
for (const select of await editor(page).getByRole('combobox').all()) await expect(select).toBeDisabled();
for (const select of await preview(page).getByRole('combobox').all()) await expect(select).toBeDisabled();
expect(await readLayout(page.request)).toEqual(before);
} finally { release(); }
await saving;
@@ -283,14 +292,18 @@ test('[P8] Editor reflows across EN/NB themes and desktop/tablet/mobile with nat
await page.reload();
await page.getByRole('button', { name: t.dashboardCustomize, exact: true }).click();
const panel = page.getByRole('region', { name: t.dashboardEditing, exact: true });
await expect(panel.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible();
await panel.locator('summary').click();
await expect(panel.getByRole('group', { name: t.dashboardManageWidgets })).toBeVisible();
await expect(panel.getByRole('checkbox')).toHaveCount(9);
await expect(panel.getByRole('combobox')).toHaveCount(9);
await expect(panel.getByRole('combobox').first()).toBeDisabled();
expect(await panel.getByRole('combobox').evaluateAll(nodes => nodes.every(node => node.tagName === 'SELECT'))).toBe(true);
const widgets = page.getByRole('region', { name: t.dashboardPreview, exact: true });
await widgets.locator('[data-dashboard-widget="cards"] summary').click();
await expect(widgets.getByRole('combobox', { name: t.dashboardSizeFor.replace('{name}', t.dashboardSummaryCards) })).toBeDisabled();
expect(await widgets.getByRole('combobox').evaluateAll(nodes => nodes.every(node => node.tagName === 'SELECT'))).toBe(true);
for (const [width, height] of [[1440, 900], [768, 1024], [390, 844], [320, 740]]) {
await page.setViewportSize({ width, height });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 ? [] :
[...document.querySelectorAll('body *')].map(element => ({ tag: element.tagName, class: element.className?.toString().slice(0, 100), right: element.getBoundingClientRect().right }))
.filter(element => element.right > innerWidth + 1).slice(0, 8))).toEqual([]);
await capture(page, info, `editor-${language}-${mode}-${width}`);
}
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
@@ -305,26 +318,83 @@ test('[P8] Editor reflows across EN/NB themes and desktop/tablet/mobile with nat
test('[P8] Keyboard move focus/live status, pointer drag and emulated touch buttons preserve DOM order', async ({ page, browser, account }, info) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await openEditor(page);
const earlier = editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true });
await options(page, 'funnel');
const earlier = preview(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true });
await earlier.focus(); await page.keyboard.press('Enter');
await expect(earlier).toBeFocused();
await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.');
await expect.poll(() => editor(page).locator('[data-layout-row]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-layout-row')))).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
await editor(page).getByRole('button', { name: 'Drag Current pipeline to reorder', exact: true }).dragTo(editor(page).locator('[data-layout-row="cards"]'));
await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 1 of 9.');
await expect(editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true })).toBeDisabled();
await expect(page.getByRole('status').filter({ hasText: 'Current pipeline moved to position 2 of 9.' })).toBeVisible();
await expect.poll(() => preview(page).locator('[data-dashboard-widget]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dashboard-widget')))).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
const widget = page.locator('[data-dashboard-widget="funnel"]');
await widget.locator('summary').first().click();
const handle = widget.locator('.dashboard-grid-handle');
await handle.scrollIntoViewIfNeeded();
const start = (await handle.boundingBox())!;
const before = (await widget.boundingBox())!;
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2);
await page.mouse.down(); await page.mouse.move(start.x + start.width / 2 + before.width, start.y + start.height / 2, { steps: 15 }); await page.mouse.up();
await expect.poll(async () => Math.round((await widget.boundingBox())!.x)).not.toBe(Math.round(before.x));
const resize = widget.locator('.react-resizable-handle-se');
await resize.scrollIntoViewIfNeeded();
const corner = (await resize.boundingBox())!;
const oldHeight = (await widget.boundingBox())!.height;
await resize.hover();
await page.mouse.down();
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2 + 112, { steps: 12 });
await expect(widget).toHaveClass(/resizing/);
await page.mouse.up();
await expect.poll(async () => (await widget.boundingBox())!.height).toBeGreaterThan(oldHeight + 50);
await save(page);
expect((await readLayout(page.request)).widgets[0].id).toBe('funnel');
const saved = await readLayout(page.request);
expect(saved.widgets.find(widget => widget.id === 'funnel')!.h).toBeGreaterThan(8);
expect(saved.widgets.find(widget => widget.id === 'funnel')!.x).toBeGreaterThan(0);
const movedIndex = saved.widgets.findIndex(widget => widget.id === 'funnel');
const expectedOrder = saved.widgets.map(widget => widget.id);
const [moved] = expectedOrder.splice(movedIndex, 1); expectedOrder.splice(movedIndex + 1, 0, moved);
await page.reload();
expect(await readLayout(page.request)).toEqual(saved);
const touch = await browser.newContext({ baseURL: 'http://localhost:3300', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
try {
await localOnly(touch); await login(touch.request, account.email);
const mobile = await touch.newPage(); await mobile.goto('/dashboard');
await mobile.getByRole('button', { name: 'Customize dashboard', exact: true }).tap();
await editor(mobile).getByRole('button', { name: 'Move Current pipeline later', exact: true }).tap();
await expect(editor(mobile).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.');
expect(await readLayout(touch.request)).toEqual(saved);
await options(mobile, 'funnel');
await preview(mobile).getByRole('button', { name: 'Move Current pipeline later', exact: true }).tap();
await expect(mobile.getByRole('status').filter({ hasText: `Current pipeline moved to position ${movedIndex + 2} of 9.` })).toBeVisible();
await save(mobile);
expect((await readLayout(touch.request)).widgets.slice(0, 2).map(widget => widget.id)).toEqual(['cards', 'funnel']);
expect((await readLayout(touch.request)).widgets.map(widget => widget.id)).toEqual(expectedOrder);
await capture(mobile, info, 'emulated-touch-button-order');
await info.attach('device-evidence-limit', { body: 'Chromium desktop pointer drag and emulated touch button input only. Physical touch, software keyboards and human screen reader acceptance remain untested. Axe incomplete items require review.', contentType: 'text/plain' });
} finally { await touch.close(); }
});
test('[C] Stored gaps and hidden geometry survive viewport changes without automatic writes', async ({ page, account }) => {
const stored = {
version: 2, revision: 0, widgets: [
{ id: 'attention', visible: true, size: 'standard', x: 4, y: 17, w: 8, h: 10 },
{ id: 'funnel', visible: false, size: 'standard', x: 0, y: 40, w: 6, h: 12 },
],
};
expect((await page.request.put(layoutUrl, { headers: account.headers, data: stored })).status()).toBe(200);
const saved = await readLayout(page.request);
let writes = 0;
page.on('request', request => { if (request.url() === layoutUrl && request.method() === 'PUT') writes++; });
await page.setViewportSize({ width: 1440, height: 900 });
await page.reload();
const widget = page.locator('[data-dashboard-widget="attention"]');
await expect(widget).toBeAttached();
await expect.poll(() => widget.evaluate(element => {
const grid = element.closest('.react-grid-layout')!;
return Math.round(element.getBoundingClientRect().top - grid.getBoundingClientRect().top);
})).toBe(17 * 56);
await openEditor(page);
for (const width of [768, 390, 1440]) {
await page.setViewportSize({ width, height: 900 });
await expect.poll(() => page.locator('.react-grid-layout').count()).toBe(width === 1440 ? 1 : 0);
expect(await readLayout(page.request)).toEqual(saved);
}
await expect(editor(page).getByRole('status')).toHaveText('Your saved layout. Changes are only applied when you save.');
expect(writes).toBe(0);
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click();
expect(await readLayout(page.request)).toEqual(saved);
});