Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions e2e/dashboard.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', /.+/);
}
});
261 changes: 261 additions & 0 deletions e2e/dock-list.spec.js
Original file line number Diff line number Diff line change
@@ -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']);
});
2 changes: 1 addition & 1 deletion e2e/rtl-mirroring.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<script src="/js/mobile-class.js?v=fe95da0d"></script>
<script src="/js/admin-theme.js?v=66565254"></script>
<link rel="stylesheet" href="/css/tokens.css?v=0f4ba0b8">
<link rel="stylesheet" href="/css/admin.css?v=713efb5d">
<link rel="stylesheet" href="/css/admin.css?v=a1bed03a">
<link rel="stylesheet" href="/css/widget-config-form.css?v=53b91d5f">
<link rel="stylesheet" href="/css/config-recovery.css?v=faa8f109">
</head>
Expand Down Expand Up @@ -204,7 +204,7 @@
<button type="button" class="chip" data-flt="folder" aria-pressed="false" data-i18n="dashboard.filterFolders">Folders</button>
</div>
</div>
<div class="grp d-none" id="al-grp"><div id="al"></div></div>
<div class="d-none" id="al-grp"><div id="al"></div></div>
</div>

<!-- Push edit view -->
Expand Down Expand Up @@ -302,6 +302,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=02ebf569"></script>
<script type="module" src="/js/admin.js?v=4ec9f747"></script>
</body>
</html>
4 changes: 4 additions & 0 deletions ui/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ html{background:var(--bg-color,var(--bg-base));scrollbar-gutter:stable}
.row.drow.drag-above{box-shadow:0 -2px 0 0 var(--ac)}
.row.drow.drag-below{box-shadow:0 2px 0 0 var(--ac)}
.row.drow.drag-into{background:var(--row-drop);box-shadow:inset 0 0 0 2px var(--gn)}
.al-hdr{align-items:baseline}
.al-count{padding:26px 16px 6px;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);color:var(--dm);font-variant-numeric:tabular-nums}
.row.al-dock-empty{min-height:68px;justify-content:center;text-align:center;padding-block:10px;color:var(--dm);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote)}
.row.al-dock-empty.drag-into{background:var(--row-drop);box-shadow:inset 0 0 0 2px var(--gn);color:var(--tx)}
/* touch-action:none, or a drag starting on the handle scrolls the list instead. */
.rord{display:flex;align-items:center;justify-content:center;color:var(--dm);font-size:var(--fs-callout);line-height:var(--lh-callout);letter-spacing:var(--tr-callout);touch-action:none}
.rico{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--on-tint);flex-shrink:0;overflow:hidden;box-shadow:inset 0 0 0 1px var(--icon-ring)}
Expand Down
8 changes: 4 additions & 4 deletions ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
"chooseFiles": "Dateien wählen",
"configured": "Eingerichtet",
"notSet": "Nicht festgelegt",
"show": "Anzeigen",
"hide": "Ausblenden",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"editNamed": "{name} bearbeiten",
Expand Down Expand Up @@ -70,7 +68,7 @@
"logError": "Fehler",
"passwordPh": "Neues Passwort (mind. 8 Zeichen)",
"showSettingsAria": "Einstellungen im Dashboard anzeigen",
"hideSettingsAria": "Einstellungen im Dashboard ausblenden",
"dockNeedsShown": "Aktiviere „Einstellungen im Dashboard anzeigen“, um sie ins Dock zu legen.",
"importJson": "Konfigurations-JSON importieren",
"importForeignFiles": "Homepage- oder Dashy-YAML-Konfiguration importieren"
},
Expand Down Expand Up @@ -392,7 +390,9 @@
},
"list": {
"empty": "Noch keine Apps. Klicke auf +Hinzufügen.",
"noMatches": "Keine Treffer."
"noMatches": "Keine Treffer.",
"dockTip": "Erscheint unten auf jeder Seite.",
"dockEmpty": "Ziehe eine App hierher oder öffne sie und aktiviere „Im Dock anzeigen“."
},
"import": {
"confirm_one": "{count} Eintrag importieren? {added} hinzugefügt, {updated} aktualisiert, {deleted} gelöscht.",
Expand Down
8 changes: 4 additions & 4 deletions ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
"chooseFiles": "Choose files",
"configured": "Configured",
"notSet": "Not set",
"show": "Show",
"hide": "Hide",
"moveUp": "Move up",
"moveDown": "Move down",
"editNamed": "Edit {name}",
Expand Down Expand Up @@ -70,7 +68,7 @@
"logError": "Errors",
"passwordPh": "New password (min 8 chars)",
"showSettingsAria": "Show Settings on dashboard",
"hideSettingsAria": "Hide Settings on dashboard",
"dockNeedsShown": "Turn on Show Settings on dashboard to put it in the dock.",
"importJson": "Import configuration JSON",
"importForeignFiles": "Import Homepage or Dashy YAML config"
},
Expand Down Expand Up @@ -392,7 +390,9 @@
},
"list": {
"empty": "No apps yet. Click +Add.",
"noMatches": "No matches."
"noMatches": "No matches.",
"dockTip": "Shown at the bottom of every page.",
"dockEmpty": "Drag an app here, or open it and turn on Show in Dock."
},
"import": {
"confirm_one": "Import {count} item? {added} added, {updated} updated, {deleted} deleted.",
Expand Down
Loading
Loading