feat(dashboard): add revision-safe interactive widget grid
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user