diff --git a/e2e/dashboard.spec.js b/e2e/dashboard.spec.js index fd81955b..83c6669f 100644 --- a/e2e/dashboard.spec.js +++ b/e2e/dashboard.spec.js @@ -241,3 +241,17 @@ test('resizing across the breakpoint switches the layout without a reload', asyn await expect(page.locator('#pages .dyn-mob-icon')).toHaveCount(0); expect(pageErrors, 'the rebuild threw').toEqual([]); }); + +test('a docked Settings tile opens the admin in both layouts', async ({ page, request }) => { + await seedConfig(request, { + items: [app('alpha', 'Alpha'), { id: 'settings', type: 'app', system: 'settings', label: 'Settings', dock: true }], + }); + await stubPolls(page); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto('/'); + const link = page.locator('#dock a[aria-label="Settings"]'); + await expect(link).toHaveAttribute('href', '/admin/'); + await expect(link).not.toHaveAttribute('target', /.+/); + } +}); diff --git a/e2e/dock-list.spec.js b/e2e/dock-list.spec.js new file mode 100644 index 00000000..e9cc5304 --- /dev/null +++ b/e2e/dock-list.spec.js @@ -0,0 +1,261 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { seedConfig, readConfig, saveEditor, app, openDashboardList, rowByName } = require('./helpers'); + +const docked = (id, label) => ({ ...app(id, label), dock: true }); + +/** Names in one list group, in order. @param {'dock'|'home'} group */ +function groupNames(page, group) { + return page.locator(`#al .grp[data-group="${group}"] > .drow .rnm`).allTextContents(); +} + +function waitForSave(page) { + return page.waitForResponse(r => r.url().includes('/api/config') && r.request().method() === 'POST'); +} + +async function dragRow(from, to, where = 'above') { + const box = await to.boundingBox(); + if (!box) throw new Error('drop target has no box'); + await from.dragTo(to, { targetPosition: { x: box.width / 2, y: where === 'above' ? 4 : box.height - 4 } }); +} + +/** Flip a switch the way a person does. The checkbox itself is drawn hidden. */ +function flip(page, id) { + return page.locator(`label.tog:has(#${id})`).click(); +} + +/** Ids of the docked items the server holds, in order. */ +async function savedDock(request) { + return (await readConfig(request)).items.filter(i => i.dock).map(i => i.id); +} + +test('docked apps are listed in the Dock group, everything else under Home screen', async ({ page, request }) => { + await seedConfig(request, { + items: [app('alpha', 'Alpha'), docked('dee', 'Dee'), app('bravo', 'Bravo'), docked('eff', 'Eff')], + }); + await openDashboardList(page); + expect(await groupNames(page, 'dock')).toEqual(['Dee', 'Eff']); + expect(await groupNames(page, 'home')).toEqual(['Alpha', 'Bravo', 'Settings']); + await expect(page.locator('#al .al-count')).toHaveText('2/4'); + await expect(page.locator('#al .p-dk')).toHaveCount(0); +}); + +test('dragging an app onto a dock row docks it at that place', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), docked('eff', 'Eff'), app('alpha', 'Alpha')] }); + await openDashboardList(page); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Alpha'), rowByName(page, 'Eff'), 'above'); + expect((await saved).status()).toBe(200); + expect(await groupNames(page, 'dock')).toEqual(['Dee', 'Alpha', 'Eff']); + expect(await savedDock(request)).toEqual(['dee', 'alpha', 'eff']); +}); + +test('dragging a docked app onto a home row undocks it', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), app('alpha', 'Alpha'), app('bravo', 'Bravo')] }); + await openDashboardList(page); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Dee'), rowByName(page, 'Bravo'), 'above'); + expect((await saved).status()).toBe(200); + expect(await groupNames(page, 'home')).toEqual(['Alpha', 'Dee', 'Bravo', 'Settings']); + expect(await savedDock(request)).toEqual([]); + await expect(page.locator('#al .al-dock-empty')).toBeVisible(); +}); + +test('dragging inside the dock reorders it', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), docked('eff', 'Eff'), docked('gee', 'Gee')] }); + await openDashboardList(page); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Gee'), rowByName(page, 'Dee'), 'above'); + await saved; + expect(await savedDock(request)).toEqual(['gee', 'dee', 'eff']); +}); + +test('a full dock refuses a fifth app and says why', async ({ page, request }) => { + const four = ['d1', 'd2', 'd3', 'd4'].map(id => docked(id, id.toUpperCase())); + await seedConfig(request, { items: [...four, app('alpha', 'Alpha')] }); + await openDashboardList(page); + const before = await readConfig(request); + let posted = false; + page.on('request', r => { + if (r.url().includes('/api/config') && r.method() === 'POST') posted = true; + }); + await dragRow(rowByName(page, 'Alpha'), rowByName(page, 'D2'), 'above'); + await expect(page.locator('#toast')).toHaveText('Dock full (4/4). Remove an app first.'); + expect(posted).toBe(false); + expect((await readConfig(request)).items).toEqual(before.items); +}); + +test('the empty dock is a drop target', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha'), app('bravo', 'Bravo')] }); + await openDashboardList(page); + const zone = page.locator('#al .al-dock-empty'); + await expect(zone).toHaveText('Drag an app here, or open it and turn on Show in Dock.'); + const saved = waitForSave(page); + await rowByName(page, 'Bravo').dragTo(zone); + await saved; + expect(await groupNames(page, 'dock')).toEqual(['Bravo']); + expect(await savedDock(request)).toEqual(['bravo']); + await expect(zone).toHaveCount(0); +}); + +test('a widget or folder never joins the dock', async ({ page, request }) => { + await seedConfig(request, { + items: [ + docked('dee', 'Dee'), + { id: 'clk', type: 'widget', label: 'Clock', widgetType: 'clock', widgetSize: 'small' }, + { id: 'box', type: 'folder', label: 'Box', children: [] }, + ], + }); + await openDashboardList(page); + const before = await readConfig(request); + for (const name of ['Clock', 'Box']) { + await dragRow(rowByName(page, name), rowByName(page, 'Dee'), 'above'); + } + await expect(page.locator('#toast')).not.toHaveText(/Dock full/); + expect(await groupNames(page, 'dock')).toEqual(['Dee']); + expect((await readConfig(request)).items).toEqual(before.items); +}); + +test('an app dragged out of a folder into the dock leaves the folder', async ({ page, request }) => { + await seedConfig(request, { + items: [ + docked('dee', 'Dee'), + { id: 'box', type: 'folder', label: 'Box', children: ['alpha'] }, + app('alpha', 'Alpha'), + ], + }); + await openDashboardList(page); + await rowByName(page, 'Box').locator('.rnm').click(); + await expect(rowByName(page, 'Alpha')).toHaveAttribute('data-indent', '1'); + const saved = waitForSave(page); + await dragRow(rowByName(page, 'Alpha'), rowByName(page, 'Dee'), 'below'); + await saved; + const cfg = await readConfig(request); + expect(cfg.items.find(i => i.id === 'box').children).toEqual([]); + expect(await savedDock(request)).toEqual(['dee', 'alpha']); +}); + +test('the move buttons stay inside a group', async ({ page, request }) => { + await seedConfig(request, { items: [docked('dee', 'Dee'), app('alpha', 'Alpha'), docked('eff', 'Eff')] }); + await openDashboardList(page); + await expect(rowByName(page, 'Eff').getByRole('button', { name: 'Move down: Eff' })).toBeDisabled(); + await expect(rowByName(page, 'Alpha').getByRole('button', { name: 'Move up: Alpha' })).toBeDisabled(); + const saved = waitForSave(page); + await rowByName(page, 'Eff').getByRole('button', { name: 'Move up: Eff' }).click(); + await saved; + expect(await savedDock(request)).toEqual(['eff', 'dee']); + expect(await groupNames(page, 'home')).toEqual(['Alpha', 'Settings']); +}); + +test('Settings goes into the dock from its editor', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha')] }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await expect(page.locator('#ev-delete')).toBeHidden(); + const dock = page.locator('#f-dock'); + const shown = page.locator('#f-shown'); + await expect(shown).toBeChecked(); + await expect(dock).not.toBeChecked(); + await flip(page, 'f-dock'); + await expect(dock).toBeChecked(); + await saveEditor(page); + expect(await groupNames(page, 'dock')).toEqual(['Settings']); + const s = (await readConfig(request)).items.find(i => i.system === 'settings'); + expect(s.dock).toBe(true); + expect(s.hidden).toBeUndefined(); +}); + +test('Settings can be hidden and shown again from its editor', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha')] }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await flip(page, 'f-shown'); + await saveEditor(page); + await expect(rowByName(page, 'Settings').locator('.p-hd')).toBeVisible(); + expect((await readConfig(request)).items.find(i => i.system === 'settings').hidden).toBe(true); + + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await expect(page.locator('#f-shown')).not.toBeChecked(); + await flip(page, 'f-shown'); + await saveEditor(page); + expect((await readConfig(request)).items.find(i => i.system === 'settings').hidden).toBeUndefined(); +}); + +test('hiding Settings takes it out of the dock and locks Show in Dock', async ({ page, request }) => { + await seedConfig(request, { items: [app('alpha', 'Alpha')] }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + const dock = page.locator('#f-dock'); + const tip = page.locator('#dock-tip'); + await expect(tip).toBeHidden(); + await flip(page, 'f-dock'); + await flip(page, 'f-shown'); + await expect(dock).not.toBeChecked(); + await expect(dock).toHaveAttribute('aria-disabled', 'true'); + await expect(dock).toHaveAttribute('aria-describedby', 'dock-tip'); + await expect(tip).toHaveText('Turn on Show Settings on dashboard to put it in the dock.'); + await page.locator('label.tog:has(#f-dock)').click({ force: true }); + await expect(dock).not.toBeChecked(); + await saveEditor(page); + const s = (await readConfig(request)).items.find(i => i.system === 'settings'); + expect(s.hidden).toBe(true); + expect(s.dock).toBe(false); + + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await flip(page, 'f-shown'); + await expect(dock).toHaveAttribute('aria-disabled', 'false'); + await expect(dock).not.toBeChecked(); + await expect(tip).toBeHidden(); +}); + +test('a save clears a docked app left in a folder and a hidden Settings left in the dock', async ({ + page, + request, +}) => { + await seedConfig(request, { + items: [ + { id: 'box', type: 'folder', label: 'Box', children: ['alpha', 'dee', 'bravo'] }, + app('alpha', 'Alpha'), + docked('dee', 'Dee'), + app('bravo', 'Bravo'), + app('charlie', 'Charlie'), + { id: 'settings', type: 'app', system: 'settings', label: 'Settings', hidden: true, dock: true }, + ], + }); + await openDashboardList(page); + await rowByName(page, 'Box').locator('.rnm').click(); + await expect(rowByName(page, 'Box').locator('.rmt')).toHaveText('2 apps'); + const saved = waitForSave(page); + await rowByName(page, 'Alpha').getByRole('button', { name: 'Move down: Alpha' }).click(); + expect((await saved).status()).toBe(200); + const cfg = await readConfig(request); + expect(cfg.items.find(i => i.id === 'box').children).toEqual(['bravo', 'alpha']); + expect(cfg.items.find(i => i.system === 'settings').dock).toBe(false); + expect(await savedDock(request)).toEqual(['dee']); +}); + +test('Settings cannot join a full dock from its editor', async ({ page, request }) => { + const four = ['d1', 'd2', 'd3', 'd4'].map(id => docked(id, id.toUpperCase())); + await seedConfig(request, { items: four }); + await openDashboardList(page); + await rowByName(page, 'Settings').getByRole('button', { name: 'Edit' }).click(); + await expect(page.locator('#dock-tip')).toBeVisible(); + await expect(page.locator('#f-dock')).toHaveAttribute('aria-disabled', 'true'); + await page.locator('label.tog:has(#f-dock)').click({ force: true }); + await expect(page.locator('#f-dock')).not.toBeChecked(); +}); + +test('docking an app from its editor takes it out of its folder', async ({ page, request }) => { + await seedConfig(request, { + items: [{ id: 'box', type: 'folder', label: 'Box', children: ['alpha'] }, app('alpha', 'Alpha')], + }); + await openDashboardList(page); + await rowByName(page, 'Box').locator('.rnm').click(); + await rowByName(page, 'Alpha').getByRole('button', { name: 'Edit' }).click(); + await flip(page, 'f-dock'); + await saveEditor(page); + const cfg = await readConfig(request); + expect(cfg.items.find(i => i.id === 'box').children).toEqual([]); + expect(await savedDock(request)).toEqual(['alpha']); + expect(await groupNames(page, 'dock')).toEqual(['Alpha']); +}); diff --git a/e2e/rtl-mirroring.spec.js b/e2e/rtl-mirroring.spec.js index 9c73d991..84e0a7f0 100644 --- a/e2e/rtl-mirroring.spec.js +++ b/e2e/rtl-mirroring.spec.js @@ -44,7 +44,7 @@ test('a row title and its subtitle share a leading edge', async ({ page, request await seedPersian(request, [app('latin', 'Backup and Storage')]); await page.goto('/admin/'); await page.locator('.nl[data-sec="dashboard"]').click(); - const row = page.locator('#al .row').first(); + const row = page.locator('#al .drow').first(); await row.waitFor({ state: 'visible' }); /* Both boxes span the row, so their edges always agree. What moves is where the text sits inside them, which is the rect of the content itself. */ diff --git a/ui/admin/index.html b/ui/admin/index.html index 9030d246..18775d97 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -11,7 +11,7 @@ - + @@ -204,7 +204,7 @@ -