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 @@ -
+
@@ -302,6 +302,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index af29dc6a..b1fd9f0c 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -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)} diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 864451ce..ced56f44 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -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", @@ -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" }, @@ -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.", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 55a82535..380ef50b 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -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}", @@ -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" }, @@ -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.", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index b752d08d..b8ab7391 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -31,8 +31,6 @@ "chooseFiles": "Elegir archivos", "configured": "Configurado", "notSet": "Sin definir", - "show": "Mostrar", - "hide": "Ocultar", "moveUp": "Subir", "moveDown": "Bajar", "editNamed": "Editar {name}", @@ -70,7 +68,7 @@ "logError": "Errores", "passwordPh": "Nueva contraseña (mín. 8 caracteres)", "showSettingsAria": "Mostrar Ajustes en el panel", - "hideSettingsAria": "Ocultar Ajustes en el panel", + "dockNeedsShown": "Activa «Mostrar Ajustes en el panel» para añadir Ajustes al Dock.", "importJson": "Importar JSON de configuración", "importForeignFiles": "Importar configuración YAML de Homepage o Dashy" }, @@ -398,7 +396,9 @@ }, "list": { "empty": "Aún no hay aplicaciones. Haz clic en +Añadir.", - "noMatches": "Sin coincidencias." + "noMatches": "Sin coincidencias.", + "dockTip": "Aparece en la parte inferior de cada página.", + "dockEmpty": "Arrastra una aplicación aquí, o ábrela y activa «Mostrar en el Dock»." }, "import": { "confirm_one": "¿Importar {count} elemento? Añadidos: {added}, actualizados: {updated}, eliminados: {deleted}.", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 5447effa..e61d6955 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -31,8 +31,6 @@ "chooseFiles": "انتخاب فایل‌ها", "configured": "تنظیم‌شده", "notSet": "تنظیم نشده", - "show": "نمایش", - "hide": "پنهان کردن", "moveUp": "انتقال به بالا", "moveDown": "انتقال به پایین", "editNamed": "ویرایش {name}", @@ -70,7 +68,7 @@ "logError": "خطاها", "passwordPh": "رمز عبور جدید (حداقل ۸ کاراکتر)", "showSettingsAria": "نمایش تنظیمات در داشبورد", - "hideSettingsAria": "پنهان کردن تنظیمات در داشبورد", + "dockNeedsShown": "برای قرار دادن آن در داک، «نمایش تنظیمات در داشبورد» را روشن کنید.", "importJson": "وارد کردن JSON پیکربندی", "importForeignFiles": "وارد کردن پیکربندی YAML مربوط به Homepage یا Dashy" }, @@ -392,7 +390,9 @@ }, "list": { "empty": "هنوز برنامه‌ای وجود ندارد. روی «+ افزودن» کلیک کنید.", - "noMatches": "نتیجه‌ای یافت نشد." + "noMatches": "نتیجه‌ای یافت نشد.", + "dockTip": "در پایین همه صفحه‌ها نمایش داده می‌شود.", + "dockEmpty": "یک برنامه را به اینجا بکشید، یا آن را باز کنید و «نمایش در داک» را روشن کنید." }, "import": { "confirm_one": "{count} مورد وارد شود؟ {added} مورد اضافه، {updated} مورد به‌روزرسانی و {deleted} مورد حذف می‌شود.", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 552dedb0..e50bc459 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -31,8 +31,6 @@ "chooseFiles": "Choisir des fichiers", "configured": "Défini", "notSet": "Non défini", - "show": "Afficher", - "hide": "Masquer", "moveUp": "Monter", "moveDown": "Descendre", "editNamed": "Modifier {name}", @@ -70,7 +68,7 @@ "logError": "Erreurs", "passwordPh": "Nouveau mot de passe (8 caractères minimum)", "showSettingsAria": "Afficher les Réglages sur le tableau de bord", - "hideSettingsAria": "Masquer les Réglages sur le tableau de bord", + "dockNeedsShown": "Activez « Afficher les Réglages sur le tableau de bord » pour les placer dans le Dock.", "importJson": "Importer le JSON de configuration", "importForeignFiles": "Importer une configuration YAML Homepage ou Dashy" }, @@ -398,7 +396,9 @@ }, "list": { "empty": "Aucune application pour le moment. Cliquez sur +Ajouter.", - "noMatches": "Aucune correspondance." + "noMatches": "Aucune correspondance.", + "dockTip": "Affiché en bas de chaque page.", + "dockEmpty": "Faites glisser une application ici, ou ouvrez-la et activez « Afficher dans le Dock »." }, "import": { "confirm_one": "Importer {count} élément ? Ajoutés : {added}, mis à jour : {updated}, supprimés : {deleted}.", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index d6c8ce98..8d2ed40f 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -31,8 +31,6 @@ "chooseFiles": "选择文件", "configured": "已设置", "notSet": "未设置", - "show": "显示", - "hide": "隐藏", "moveUp": "上移", "moveDown": "下移", "editNamed": "编辑{name}", @@ -70,7 +68,7 @@ "logError": "错误", "passwordPh": "新密码(至少 8 个字符)", "showSettingsAria": "在仪表板上显示设置", - "hideSettingsAria": "在仪表板上隐藏设置", + "dockNeedsShown": "开启“在仪表板上显示设置”后,才能将其放入程序坞。", "importJson": "导入配置 JSON", "importForeignFiles": "导入 Homepage 或 Dashy 的 YAML 配置" }, @@ -386,7 +384,9 @@ }, "list": { "empty": "还没有应用。点击“+ 添加”。", - "noMatches": "没有匹配项。" + "noMatches": "没有匹配项。", + "dockTip": "显示在每页底部。", + "dockEmpty": "将应用拖到这里,或打开应用并开启“在程序坞中显示”。" }, "import": { "confirm_other": "导入 {count} 个项目?新增 {added} 个,更新 {updated} 个,删除 {deleted} 个。", diff --git a/ui/index.html b/ui/index.html index 37c48821..b797aa34 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index b1f0bcca..2ea1fa2a 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -18,7 +18,7 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=1cca36f5'; +} from '/js/admin-logic.js?v=546d9ed7'; import { t } from '/js/i18n.js?v=ed7c5565'; import { toast, @@ -31,10 +31,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=1c01be9a'; -import { createListbox } from '/js/listbox.js?v=f814170f'; +} from '/js/admin-shared.js?v=d479609c'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=4083ea3b'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=43cc1b76'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -90,6 +90,37 @@ function _wireFolderApps(apps, children) { row.appendChild(box.el); } +export function buildSettingsTileForm(body, item) { + const dockBlocked = isDockBlocked(state.items, item, DOCK_MAX); + const tog = (id, on, name) => + html``; + setHtml( + body, + html` +
+
${t('general.showSettingsAria')}${tog('f-shown', !item?.hidden, t('general.showSettingsAria'))}
+
${t('app.showInDock')}${tog('f-dock', !!item?.dock, t('app.showInDock'))}
+
+

`, + ); + const shown = inpById('f-shown'); + const dock = inpById('f-dock'); + const tip = el('dock-tip'); + const sync = () => { + const reason = !shown.checked + ? t('general.dockNeedsShown') + : dockBlocked + ? t('app.dockFull', { max: formatNumber(DOCK_MAX) }) + : ''; + if (!shown.checked) dock.checked = false; + tip.textContent = reason; + tip.classList.toggle('d-none', !reason); + setTogDisabled(dock, !!reason, 'dock-tip'); + }; + shown.addEventListener('change', sync); + sync(); +} + export function buildAppForm(body, item) { const dockBlocked = isDockBlocked(state.items, item, DOCK_MAX); const globalHealthOn = !!inpById('srv-docker-en')?.checked; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index cc91ba9e..6efa1451 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=1c01be9a'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=d479609c'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index cd548108..800ee20d 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=1c01be9a'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d479609c'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; -import { createListbox } from '/js/listbox.js?v=f814170f'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-drag-logic.js b/ui/js/admin-drag-logic.js index 28cdb36d..421d16a7 100644 --- a/ui/js/admin-drag-logic.js +++ b/ui/js/admin-drag-logic.js @@ -20,21 +20,47 @@ export function folderRowZone(y, rect) { return 'into'; } +/** The list group a top-level row is drawn in. + @param {any} item @returns {'dock' | 'home'} */ +export function listGroup(item) { + return item?.type === 'app' && item.dock ? 'dock' : 'home'; +} + +/** Whether the dragged item may land in a group. Only apps join the dock, and + only while it has room. + @param {any[]} items @param {any} src @param {'dock' | 'home'} group @param {number} max + @returns {boolean} */ +export function canDropInGroup(items, src, group, max) { + if (group !== 'dock') return true; + if (src?.type !== 'app') return false; + if (src.dock) return true; + return items.filter(i => listGroup(i) === 'dock').length < max; +} + export function dropTargetKind({ srcType, targetIsFolder = false, indent = false }) { if (canJoinFolder(srcType) && (targetIsFolder || indent)) return 'into-folder'; return 'reorder'; } +/* An app belongs to one folder, or the dashboard draws it twice. */ +function leaveFolders(items, id) { + for (const f of items) if (f.type === 'folder') f.children = (f.children || []).filter(c => c !== id); +} + /* Applies a drag move in place. `d` describes the dragged row and the target: srcId, srcFolderId the dragged row, and its folder or null targetId, targetFolderId the row dropped on, and its folder targetIsFolder, indent the target is a folder, or sits inside one childIdx the target's index within its folder dropAbove insert before rather than after (reorder only) - The child object stays in `items` throughout. Folders reference it by id. */ + targetGroup, dockMax the group dropped in, and the dock's size + The child object stays in `items` throughout. Folders reference it by id. + With no targetId the item goes to the end of the group. */ export function applyDrop(items, d) { const src = items.find(i => i.id === d.srcId); if (!src || d.srcId === d.targetId) return false; + if (d.targetGroup && !canDropInGroup(items, src, d.targetGroup, d.dockMax ?? Infinity)) return false; + if (d.targetGroup && src.type === 'app') src.dock = d.targetGroup === 'dock'; if (d.srcFolderId) { const sf = items.find(i => i.id === d.srcFolderId); @@ -52,22 +78,18 @@ export function applyDrop(items, d) { items.push(src); return true; } - tf.children = (tf.children || []).filter(id => id !== src.id); + leaveFolders(items, src.id); if (!items.find(i => i.id === src.id)) items.push(src); tf.children.splice(d.childIdx, 0, src.id); } else if (kind === 'into-folder') { if (!items.find(i => i.id === src.id)) items.push(src); const tf = items.find(i => i.id === d.targetId); if (tf) { - tf.children = (tf.children || []).filter(id => id !== src.id); + leaveFolders(items, src.id); tf.children.push(src.id); } } else { - items - .filter(f => f.type === 'folder') - .forEach(f => { - f.children = (f.children || []).filter(id => id !== src.id); - }); + leaveFolders(items, src.id); if (!items.find(i => i.id === src.id)) items.push(src); const si2 = items.indexOf(src); if (si2 >= 0) items.splice(si2, 1); diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index cd139f43..a554c72a 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -9,28 +9,53 @@ directly. This module is the DOM half: what the pointer is doing, which edge it is near, and which classes say so. */ -import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; +import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=1d1d553e'; +import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; import { qa } from '/js/utils.js?v=705ca896'; /** @type {(before: unknown) => void} */ let _save = () => {}; +/** @type {() => void} */ +let _dockFull = () => {}; -/** The list writes the config, not this module. Call once at start-up. - @param {(before: unknown) => void} save @returns {void} */ -export function initDrag(save) { +/** The list writes the config and shows notices, not this module. Call once + at start-up. + @param {(before: unknown) => void} save @param {() => void} dockFull + @returns {void} */ +export function initDrag(save, dockFull) { _save = save; + _dockFull = dockFull; + /* Capture runs before the row handlers, which set it again over a refusing + row. So it describes the element under the pointer now. */ + document.addEventListener('dragover', () => (_refused = false), true); + document.addEventListener('drop', () => (_dropped = true), true); } /* Applies the arrangement and saves it, or leaves the list untouched. */ function commit(opts) { const before = snapshotItems(state.items); - if (applyDrop(state.items, opts)) _save(before); + if (applyDrop(state.items, { ...opts, dockMax: DOCK_MAX })) _save(before); } /* dataTransfer is not readable during dragover. */ let _dragType = null; +let _dragId = null; +/* The last target under the pointer refused the dragged row. */ +let _refused = false; +let _dropped = false; + +/** @param {string|null} srcId @param {string|undefined} group @returns {boolean} */ +function accepts(srcId, group) { + const src = state.items.find(i => i.id === srcId); + return canDropInGroup(state.items, src, group === 'dock' ? 'dock' : 'home', DOCK_MAX); +} + +/* Only an app is refused for a full dock. Other types never join it. */ +function isApp(srcId) { + return state.items.find(i => i.id === srcId)?.type === 'app'; +} function clearDragClasses(target) { const rows = target ? [target] : qa('.row'); @@ -110,13 +135,25 @@ function wireTouchDrag(row, handle, { indent, folderId }) { const place = (x, y) => { ghost.style.top = y - offY + 'px'; const under = /** @type {HTMLElement} */ (document.elementFromPoint(x, y)); - const tr = /** @type {HTMLElement} */ (under && under.closest('.drow')); + const tr = /** @type {HTMLElement} */ (under && under.closest('.drow, .al-dock-empty')); if (!tr || tr === row || tr === ghost) { unmark(); hovered = null; + _refused = false; return; } + if (!accepts(srcId, tr.dataset.group)) { + unmark(); + hovered = null; + _refused = isApp(srcId); + return; + } + _refused = false; hovered = tr; + if (tr.classList.contains('al-dock-empty')) { + mark(tr, 'drag-into'); + return; + } const r = tr.getBoundingClientRect(); if (tr.dataset.isFolder && canJoinFolder(itemType(srcId))) { const zone = folderRowZone(y, r); @@ -165,13 +202,22 @@ function wireTouchDrag(row, handle, { indent, folderId }) { if (scrollTimer) cancelAnimationFrame(scrollTimer); ghost.remove(); row.classList.remove('dragging'); + _refused = false; _marked = null; clearDragClasses(); }; const up = () => { const tr = hovered; + const refused = _refused; end(); - if (!tr) return; + if (!tr) { + if (refused) _dockFull(); + return; + } + if (tr.classList.contains('al-dock-empty')) { + commit({ srcId, srcFolderId: indent ? folderId : null, targetId: null, targetGroup: 'dock' }); + return; + } const into = !!tr.dataset.isFolder && canJoinFolder(itemType(srcId)) && dropInto; commit({ srcId, @@ -182,6 +228,7 @@ function wireTouchDrag(row, handle, { indent, folderId }) { indent: !!tr.dataset.indent, childIdx: tr.dataset.childIdx != null ? Number(tr.dataset.childIdx) : null, dropAbove: into ? false : dropAbove, + targetGroup: tr.dataset.group, }); }; const cancel = () => end(); @@ -200,19 +247,32 @@ function wireTouchDrag(row, handle, { indent, folderId }) { export function wireRowDrag(row, handle, { item, indent, folderId, childIdx }) { const dragData = indent ? 'child:' + folderId + ':' + item.id : 'top:' + item.id; + let frame = 0; row.addEventListener('dragstart', e => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragData); _dragType = item.type; - requestAnimationFrame(() => row.classList.add('dragging')); + _dragId = item.id; + _refused = false; + _dropped = false; + frame = requestAnimationFrame(() => row.classList.add('dragging')); }); row.addEventListener('dragend', () => { + cancelAnimationFrame(frame); row.classList.remove('dragging'); + if (_refused && !_dropped) _dockFull(); + _refused = false; + _dragId = null; _dragType = null; _marked = null; clearDragClasses(); }); row.addEventListener('dragover', e => { + if (!accepts(_dragId, row.dataset.group)) { + _refused = isApp(_dragId); + unmark(); + return; + } e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = row.getBoundingClientRect(); @@ -244,8 +304,34 @@ export function wireRowDrag(row, handle, { item, indent, folderId, childIdx }) { indent, childIdx, dropAbove, + targetGroup: row.dataset.group, }); }); wireTouchDrag(row, handle, { indent, folderId }); } + +/** The drop target shown in place of rows while the dock is empty. + @param {HTMLElement} zone @returns {void} */ +export function wireDockZone(zone) { + zone.dataset.group = 'dock'; + zone.addEventListener('dragover', e => { + if (!accepts(_dragId, 'dock')) { + _refused = isApp(_dragId); + unmark(); + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + mark(zone, 'drag-into'); + }); + zone.addEventListener('dragleave', e => { + if (!e.relatedTarget || !zone.contains(/** @type {Node} */ (e.relatedTarget))) clearDragClasses(zone); + }); + zone.addEventListener('drop', e => { + e.preventDefault(); + clearDragClasses(); + const drop = parseDragData(e.dataTransfer.getData('text/plain') || ''); + if (drop) commit({ ...drop, targetId: null, targetGroup: 'dock' }); + }); +} diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 00aff25f..e02738ed 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -13,13 +13,16 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; -import { reorderItems } from '/js/admin-logic.js?v=1cca36f5'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=8fa6fbae'; -import { paintIcon } from '/js/admin-shared.js?v=1c01be9a'; +import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=546d9ed7'; +import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; +import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=73c3c8da'; +import { DOCK_MAX } from '/js/limits.js?v=31048a24'; +import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; +import { paintIcon, toast } from '/js/admin-shared.js?v=d479609c'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=1cb745cf'; +import { sizeLabel } from '/js/admin-widget-form.js?v=29567d90'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -36,7 +39,7 @@ let _page = { @param {typeof _page} page @returns {void} */ export function initList(page) { _page = page; - initDrag(page.save); + initDrag(page.save, () => toast(t('app.dockFull', { max: formatNumber(DOCK_MAX) }))); } /* Constant markup only. No user data reaches these. */ @@ -64,6 +67,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const _filtering = !!(filter.q || filter.type !== 'all'); row.draggable = !_filtering; row.dataset.itemId = item.id; + row.dataset.group = indent ? 'home' : listGroup(item); if (item.type === 'folder') row.dataset.isFolder = '1'; if (indent) { row.dataset.indent = '1'; @@ -73,13 +77,15 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n let canUp = false, canDown = false; if (folderId != null) { - const cf = state.items.find(i => i.id === folderId); - const n = (cf?.children || []).length; - canUp = childIdx > 0; - canDown = childIdx < n - 1; + const shown = shownChildren( + state.items, + state.items.find(i => i.id === folderId), + ); + const p = shown.indexOf(item); + canUp = p > 0; + canDown = p < shown.length - 1; } else { - const inF = new Set(state.items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); - const top = state.items.filter(it => it.type === 'folder' || !inF.has(it.id)); + const top = topLevelRows(state.items, listGroup(item) === 'dock'); const p = top.indexOf(item); canUp = p > 0; canDown = p < top.length - 1; @@ -137,14 +143,15 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const wt = item.widgetType || 'custom'; const wtLabel = state._widgetReg?.[wt]?.label || t('widgetCfg.typeCustom'); mt.textContent = t('widgetCfg.meta', { type: wtLabel, size: sizeLabel(item.widgetSize || 'medium') }); - } else if (item.type === 'folder') mt.textContent = t('folder.appsCount', { count: (item.children || []).length }); + } else if (item.type === 'folder') + mt.textContent = t('folder.appsCount', { count: shownChildren(state.items, item).length }); else if (item.system === 'settings') mt.textContent = t('home.opensSettings'); else mt.textContent = item.href || ''; inf.append(nm, mt); const pb = document.createElement('div'); pb.className = 'rpills'; const pills = []; - if (item.dock) pills.push(html`${t('app.dockPill')}`); + if (item.dock && _filtering) pills.push(html`${t('app.dockPill')}`); if (item.type === 'widget') pills.push(html`${t('type.widget')}`); if (item.type === 'folder') pills.push(html`${t('type.folder')}`); if (item.monitoring?.healthcheck?.enabled || item.container) @@ -169,28 +176,12 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n return b; }; if (!_filtering) ac.append(mkMove(-1, canUp), mkMove(1, canDown)); - if (item.system === 'settings') { - const hb = document.createElement('button'); - hb.className = 'btn bg sm'; - hb.textContent = t(item.hidden ? 'common.show' : 'common.hide'); - hb.dataset.act = 'hide'; - const lbl = t(item.hidden ? 'general.showSettingsAria' : 'general.hideSettingsAria'); - hb.title = lbl; - hb.setAttribute('aria-label', lbl); - hb.onclick = () => { - const before = snapshotItems(state.items); - item.hidden = !item.hidden; - _page.save(before); - }; - ac.append(hb); - } else { - const ed = document.createElement('button'); - ed.className = 'btn bg sm'; - ed.textContent = t('common.edit'); - ed.dataset.act = 'edit'; - ed.onclick = () => _page.openModal(idx); - ac.append(ed); - } + const ed = document.createElement('button'); + ed.className = 'btn bg sm'; + ed.textContent = t('common.edit'); + ed.dataset.act = 'edit'; + ed.onclick = () => _page.openModal(idx); + ac.append(ed); row.append(handle, ico, inf, pb, ac); wireRowDrag(row, handle, { item, indent, folderId, childIdx }); return row; @@ -202,7 +193,7 @@ const FALLBACK = { up: ['up', 'down'], down: ['down', 'up'] }; export function focusRow(itemId, act = 'edit') { const row = qa('.drow', el('al')).find(r => r.dataset.itemId === itemId); if (!row) return false; - const order = [...(FALLBACK[act] || [act]), 'edit', 'hide', 'toggle']; + const order = [...(FALLBACK[act] || [act]), 'edit', 'toggle']; return focusFirst(...order.map(a => row.querySelector(`[data-act="${a}"]`))); } @@ -218,6 +209,34 @@ export function render() { else if (act) focusRow(id, act); } +/** A heading and an empty group under it, added to the list. + @param {HTMLElement} l @param {'dock'|'home'} group @param {string} title @param {string} [count] + @returns {HTMLElement} the group */ +function listSection(l, group, title, count) { + const hdr = document.createElement('div'); + hdr.className = 'grp-hdr-row al-hdr'; + const h = document.createElement('p'); + h.className = 'grp-hdr'; + h.id = 'al-h-' + group; + h.setAttribute('role', 'heading'); + h.setAttribute('aria-level', '2'); + h.textContent = title; + hdr.appendChild(h); + if (count) { + const c = document.createElement('span'); + c.className = 'al-count'; + c.textContent = count; + hdr.appendChild(c); + } + const g = document.createElement('div'); + g.className = 'grp'; + g.dataset.group = group; + g.setAttribute('role', 'group'); + g.setAttribute('aria-labelledby', h.id); + l.append(hdr, g); + return g; +} + function draw(l) { const bar = el('al-filter'); const grp = el('al-grp'); @@ -252,18 +271,34 @@ function draw(l) { setHtml(l, html`

${t('list.noMatches')}

`); return; } - matches.forEach(item => l.appendChild(mkRow(item, state.items.indexOf(item)))); + const g = document.createElement('div'); + g.className = 'grp'; + matches.forEach(item => g.appendChild(mkRow(item, state.items.indexOf(item)))); + l.appendChild(g); return; } - const inFolder = new Set(state.items.filter(i => i.type === 'folder').flatMap(f => f.children || [])); - state.items.forEach((item, idx) => { - if (item.type !== 'folder' && inFolder.has(item.id)) return; - l.appendChild(mkRow(item, idx)); + const docked = topLevelRows(state.items, true); + const dockGrp = listSection(l, 'dock', t('home.dock'), `${formatNumber(docked.length)}/${formatNumber(DOCK_MAX)}`); + docked.forEach(item => dockGrp.appendChild(mkRow(item, state.items.indexOf(item)))); + if (!docked.length) { + const zone = document.createElement('div'); + zone.className = 'row al-dock-empty'; + zone.textContent = t('list.dockEmpty'); + dockGrp.appendChild(zone); + wireDockZone(zone); + } + const tip = document.createElement('p'); + tip.className = 'grp-tip'; + tip.textContent = t('list.dockTip'); + l.appendChild(tip); + const homeGrp = listSection(l, 'home', t('home.homeScreen')); + topLevelRows(state.items, false).forEach(item => { + homeGrp.appendChild(mkRow(item, state.items.indexOf(item))); if (item.type === 'folder' && !collapsedFolders.has(item.id)) { (item.children || []).forEach((childId, ci) => { + if (!isShownChild(state.items, childId)) return; const childItem = state.items.find(i => i.id === childId); - if (!childItem) return; - l.appendChild( + homeGrp.appendChild( mkRow(childItem, state.items.indexOf(childItem), { indent: true, childIdx: ci, folderId: item.id }), ); }); @@ -274,7 +309,7 @@ function draw(l) { addRow.dataset.folderId = item.id; setHtml(addRow, html`+ ${t('folder.addAppToFolder')}`); addRow.onclick = () => _page.openFolderPicker(null, item.id); - l.appendChild(addRow); + homeGrp.appendChild(addRow); } }); } diff --git a/ui/js/admin-logic.js b/ui/js/admin-logic.js index 50569c2c..fd343699 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -229,19 +229,62 @@ export function groupBounds(field, size) { return { min, max }; } -/* Mutates `items` in place. */ +/** The top-level rows of one list group, in order. A docked app is drawn in + the dock group even when a folder lists it. + @param {any[]} items @param {boolean} docked @returns {any[]} */ +export function topLevelRows(items, docked) { + const inF = new Set(items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); + return items.filter(it => { + const isDocked = it.type === 'app' && !!it.dock; + if (isDocked !== docked) return false; + return docked || it.type === 'folder' || !inF.has(it.id); + }); +} + +/** Whether a folder child id is drawn under its folder. A docked app is drawn + in the dock instead. + @param {any[]} items @param {string} id @returns {boolean} */ +export function isShownChild(items, id) { + const it = items.find(i => i.id === id); + return !!it && !(it.type === 'app' && it.dock); +} + +/** The folder's children drawn under it, in order. + @param {any[]} items @param {any} folder @returns {any[]} */ +export function shownChildren(items, folder) { + return (folder?.children || []).filter(id => isShownChild(items, id)).map(id => items.find(i => i.id === id)); +} + +/** Mutates `items` in place. A hidden app leaves the dock, and a docked app + leaves its folders. + @param {any[]} items @returns {void} */ +export function settleDock(items) { + const docked = new Set(); + for (const it of items) { + if (it.type !== 'app' || !it.dock) continue; + if (it.hidden) it.dock = false; + else docked.add(it.id); + } + for (const f of items) { + if (f.type === 'folder' && (f.children || []).some(id => docked.has(id))) { + f.children = f.children.filter(id => !docked.has(id)); + } + } +} + +/* Mutates `items` in place. A top-level row moves only within its group. */ export function reorderItems(items, item, dir, { folderId = null, childIdx = null } = {}) { if (folderId != null) { const f = items.find(i => i.id === folderId); if (!f) return false; const ch = f.children || []; - const j = childIdx + dir; + let j = childIdx + dir; + while (j >= 0 && j < ch.length && !isShownChild(items, ch[j])) j += dir; if (j < 0 || j >= ch.length) return false; [ch[childIdx], ch[j]] = [ch[j], ch[childIdx]]; return true; } - const inF = new Set(items.filter(i => i.type === 'folder').flatMap(ff => ff.children || [])); - const top = items.filter(it => it.type === 'folder' || !inF.has(it.id)); + const top = topLevelRows(items, item.type === 'app' && !!item.dock); const p = top.indexOf(item); const nb = top[p + dir]; if (!nb) return false; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 7fbea010..054343b6 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=1c01be9a'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=d479609c'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -9,12 +9,12 @@ import { needsCurrentPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=1cca36f5'; +} from '/js/admin-logic.js?v=546d9ed7'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; +import { renderColorControl } from '/js/admin-color-control.js?v=43cc1b76'; import { BACKDROP } from '/js/background.js?v=25428bf2'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 4fd10a1f..5d9d68c9 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,5 +1,5 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=1cca36f5'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=546d9ed7'; import { el, isolate, q } from '/js/utils.js?v=705ca896'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index e1d45f90..bf7d2e04 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,9 +1,9 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=1c01be9a'; -import { createListbox } from '/js/listbox.js?v=f814170f'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1807c371'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d479609c'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=901eeaee'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=1cca36f5'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=546d9ed7'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin.js b/ui/js/admin.js index e2664fee..dac7905e 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,8 +1,14 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=f0da79cf'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5246ff39'; +import { + buildAppForm, + buildFolderForm, + buildSettingsTileForm, + captureActLabels, + serializeKvRows, +} from '/js/admin-app-form.js?v=adc67cb0'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=6adaedef'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=411d6f37'; -import { resolveAdminSection } from '/js/admin-logic.js?v=1cca36f5'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=0371c091'; +import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=546d9ed7'; import { afterImport, buildAppItem, @@ -23,7 +29,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=4b374391'; +} from '/js/admin-settings.js?v=b1897b60'; import { apiGet, apiPost, @@ -37,13 +43,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=1c01be9a'; +} from '/js/admin-shared.js?v=d479609c'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=1cb745cf'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=29567d90'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=f814170f'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; @@ -166,6 +172,7 @@ async function writeItems(asked) { if (asked !== _replaced || JSON.stringify(full.items || []) !== _serverItems) { throw Object.assign(new Error('stale'), { status: 409 }); } + settleDock(state.items); const sent = JSON.stringify(state.items); full.items = JSON.parse(sent); const r = await apiPost('/api/config', full); @@ -344,6 +351,7 @@ function _renderEditBody(focusKind) { body.replaceChildren(); if (state.ctype === 'widget') buildWidgetForm(body, state._evItem); else if (state.ctype === 'folder') buildFolderForm(body, state._evItem); + else if (state.ctype === 'settings') buildSettingsTileForm(body, state._evItem); else buildAppForm(body, state._evItem); if (!state._evIsEdit) body.insertBefore(buildAddNewCard(), body.firstChild); setTimeout(() => { @@ -357,7 +365,7 @@ function openModal(idx) { const editing = idx != null ? state.items[idx] : null; state.eid = editing?.id ?? null; const item = editing ? structuredClone(editing) : null; - state.ctype = item?.type || 'app'; + state.ctype = item?.system === 'settings' ? 'settings' : item?.type || 'app'; state.siurl = item?.iconUrl || ''; state.scol = item ? item.color || 'dark' : 'auto'; state._customUrl = item?.url || ''; @@ -412,7 +420,7 @@ function openModal(idx) { const delBtn = el('ev-delete'); const saveBtn = el('ev-save'); if (delBtn) { - delBtn.classList.toggle('d-none', !isEdit); + delBtn.classList.toggle('d-none', !isEdit || state.ctype === 'settings'); delBtn.onclick = () => _evDelete(item, idx); } if (saveBtn) { @@ -660,6 +668,11 @@ async function doSave(orig) { }; if (Object.keys(ifo).length) item.iframe = ifo; } + } else if (state.ctype === 'settings') { + const shown = !!inp('f-shown')?.checked; + item = { ...orig, dock: shown && !!inp('f-dock')?.checked }; + if (shown) delete item.hidden; + else item.hidden = true; } else if (state.ctype === 'folder') { const label = inp('f-fname')?.value?.trim(); if (!label) { diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 75a1d939..5c1b102b 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -46,7 +46,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=049178d3'; +} from '/js/ui.js?v=c66ab377'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=5c72bd4c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 67bc726a..9eb4af69 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -6,7 +6,7 @@ Do not use popover. The top layer makes iOS collapse its browser toolbar, which exposes a strip of page canvas and misplaces the menu. */ -import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=1cca36f5'; +import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=546d9ed7'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; diff --git a/ui/js/ui.js b/ui/js/ui.js index 8acfe974..f1d3ae93 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -797,7 +797,10 @@ export function buildMobile() { dk.replaceChildren(); observeGlass(dk, Math.round(38 * sc), 0.6); dock.forEach(item => { - const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' }); + const a = + item.system === 'settings' + ? mk('a', { href: '/admin/' }) + : mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' }); a.className = 'dyn-dock-icon'; const nm = item.label || item.id; a.setAttribute('aria-label', nm); diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index f405f060..599e1a6e 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=1c01be9a'; -import { createListbox } from '/js/listbox.js?v=f814170f'; -import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d479609c'; +import { createListbox } from '/js/listbox.js?v=58e59bd9'; +import { renderColorControl } from '/js/admin-color-control.js?v=43cc1b76'; import { seedCarried, applyOptionSet, @@ -13,7 +13,7 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=1cca36f5'; +} from '/js/admin-logic.js?v=546d9ed7'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { qi } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; diff --git a/ui/test/admin-drag-logic.test.mjs b/ui/test/admin-drag-logic.test.mjs index 8d6504e5..0f6464a8 100644 --- a/ui/test/admin-drag-logic.test.mjs +++ b/ui/test/admin-drag-logic.test.mjs @@ -1,6 +1,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { canJoinFolder, dropTargetKind, applyDrop, folderRowZone } from '../js/admin-drag-logic.js'; +import { + canJoinFolder, + canDropInGroup, + dropTargetKind, + applyDrop, + folderRowZone, + listGroup, +} from '../js/admin-drag-logic.js'; /* a, b, w top level; f is a folder holding c. */ const model = () => [ @@ -198,3 +205,117 @@ test('a drop on a folder edge reorders instead of joining the folder', () => { assert.deepEqual(folder(items, 'f'), ['c']); assert.deepEqual(order(items), ['b', 'a', 'f', 'c', 'w']); }); + +/* d1, d2 docked; a, w and folder f (holding c) on the home screen. */ +const dockModel = () => [ + { id: 'd1', type: 'app', dock: true }, + { id: 'a', type: 'app' }, + { id: 'd2', type: 'app', dock: true }, + { id: 'f', type: 'folder', children: ['c'] }, + { id: 'c', type: 'app' }, + { id: 'w', type: 'widget' }, +]; +const dockOrder = items => items.filter(i => i.dock).map(i => i.id); +const drop = (items, d) => + applyDrop(items, { srcFolderId: null, targetFolderId: null, indent: false, childIdx: null, dockMax: 4, ...d }); + +test('only a docked app is drawn in the dock group', () => { + assert.equal(listGroup({ type: 'app', dock: true }), 'dock'); + assert.equal(listGroup({ type: 'app' }), 'home'); + assert.equal(listGroup({ type: 'widget', dock: true }), 'home'); + assert.equal(listGroup(undefined), 'home'); +}); + +test('the dock takes apps only, and only while it has room', () => { + const items = dockModel(); + const a = items[1]; + assert.equal(canDropInGroup(items, a, 'dock', 4), true); + assert.equal(canDropInGroup(items, a, 'dock', 2), false); + assert.equal(canDropInGroup(items, items[0], 'dock', 2), true); + assert.equal(canDropInGroup(items, items[5], 'dock', 4), false); + assert.equal(canDropInGroup(items, items[3], 'dock', 4), false); + assert.equal(canDropInGroup(items, items[5], 'home', 0), true); +}); + +test('dropping an app on a dock row docks it at that place', () => { + const items = dockModel(); + assert.equal(drop(items, { srcId: 'a', targetId: 'd2', dropAbove: true, targetGroup: 'dock' }), true); + assert.equal(items.find(i => i.id === 'a').dock, true); + assert.deepEqual(dockOrder(items), ['d1', 'a', 'd2']); +}); + +test('dropping a docked app on a home row undocks it', () => { + const items = dockModel(); + assert.equal(drop(items, { srcId: 'd1', targetId: 'w', dropAbove: false, targetGroup: 'home' }), true); + assert.equal(items.find(i => i.id === 'd1').dock, false); + assert.deepEqual(order(items).slice(-1), ['d1']); + assert.deepEqual(dockOrder(items), ['d2']); +}); + +test('reordering inside the dock keeps every app docked', () => { + const items = dockModel(); + drop(items, { srcId: 'd2', targetId: 'd1', dropAbove: true, targetGroup: 'dock' }); + assert.deepEqual(dockOrder(items), ['d2', 'd1']); +}); + +test('a full dock refuses a new app and leaves the list as it was', () => { + const items = dockModel(); + const before = JSON.stringify(items); + assert.equal(drop(items, { srcId: 'a', targetId: 'd1', dropAbove: true, targetGroup: 'dock', dockMax: 2 }), false); + assert.equal(JSON.stringify(items), before); +}); + +test('a widget or folder dropped on the dock is refused', () => { + for (const srcId of ['w', 'f']) { + const items = dockModel(); + const before = JSON.stringify(items); + assert.equal(drop(items, { srcId, targetId: 'd1', dropAbove: true, targetGroup: 'dock' }), false); + assert.equal(JSON.stringify(items), before); + } +}); + +test('the empty dock target docks the app at the end of the dock', () => { + const items = dockModel(); + assert.equal(drop(items, { srcId: 'a', targetId: null, targetGroup: 'dock' }), true); + assert.deepEqual(dockOrder(items), ['d1', 'd2', 'a']); +}); + +test('an app dragged from a folder into the dock leaves the folder', () => { + const items = dockModel(); + assert.equal( + drop(items, { srcId: 'c', srcFolderId: 'f', targetId: 'd1', dropAbove: false, targetGroup: 'dock' }), + true, + ); + assert.deepEqual(folder(items, 'f'), []); + assert.deepEqual(dockOrder(items), ['d1', 'c', 'd2']); +}); + +test('a docked app dropped into a folder undocks and joins it', () => { + const items = dockModel(); + assert.equal( + drop(items, { srcId: 'd1', targetId: 'f', targetIsFolder: true, dropAbove: false, targetGroup: 'home' }), + true, + ); + assert.equal(items.find(i => i.id === 'd1').dock, false); + assert.deepEqual(folder(items, 'f'), ['c', 'd1']); +}); + +test('a docked app still listed in a folder leaves it when dropped into another folder', () => { + const twoFolders = () => [ + { id: 'x', type: 'app', dock: true }, + { id: 'f1', type: 'folder', children: ['x'] }, + { id: 'f2', type: 'folder', children: ['y'] }, + { id: 'y', type: 'app' }, + ]; + let items = twoFolders(); + assert.equal(drop(items, { srcId: 'x', targetId: 'f2', targetIsFolder: true, targetGroup: 'home' }), true); + assert.deepEqual(folder(items, 'f1'), []); + assert.deepEqual(folder(items, 'f2'), ['y', 'x']); + items = twoFolders(); + assert.equal( + drop(items, { srcId: 'x', targetId: 'y', targetFolderId: 'f2', indent: true, childIdx: 0, targetGroup: 'home' }), + true, + ); + assert.deepEqual(folder(items, 'f1'), []); + assert.deepEqual(folder(items, 'f2'), ['x', 'y']); +}); diff --git a/ui/test/admin-logic.test.mjs b/ui/test/admin-logic.test.mjs index 922819fe..09fd1288 100644 --- a/ui/test/admin-logic.test.mjs +++ b/ui/test/admin-logic.test.mjs @@ -3,6 +3,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { reorderItems, + topLevelRows, + shownChildren, + settleDock, isDockBlocked, nextActiveIndex, sameIconName, @@ -73,8 +76,94 @@ test('reorderItems skips items nested inside folders when ordering the top level ); }); +test('topLevelRows splits the list into the dock and the home screen', () => { + const items = [ + { id: 'd1', type: 'app', dock: true }, + { id: 'a', type: 'app' }, + { id: 'f', type: 'folder', children: ['c', 'd2'] }, + { id: 'c', type: 'app' }, + { id: 'd2', type: 'app', dock: true }, + { id: 'w', type: 'widget', dock: true }, + ]; + assert.deepEqual( + topLevelRows(items, true).map(i => i.id), + ['d1', 'd2'], + ); + assert.deepEqual( + topLevelRows(items, false).map(i => i.id), + ['a', 'f', 'w'], + ); +}); + +test('shownChildren leaves out a docked app and a missing id', () => { + const items = [ + { id: 'f', type: 'folder', children: ['c', 'd', 'gone', 'e'] }, + { id: 'c', type: 'app' }, + { id: 'd', type: 'app', dock: true }, + { id: 'e', type: 'app' }, + ]; + assert.deepEqual( + shownChildren(items, items[0]).map(i => i.id), + ['c', 'e'], + ); +}); + +test('a folder arrow steps over a docked child the list does not draw', () => { + const items = [ + { id: 'f', type: 'folder', children: ['c', 'd', 'e'] }, + { id: 'c', type: 'app' }, + { id: 'd', type: 'app', dock: true }, + { id: 'e', type: 'app' }, + ]; + assert.equal(reorderItems(items, items[1], 1, { folderId: 'f', childIdx: 0 }), true); + assert.deepEqual(items[0].children, ['e', 'd', 'c']); + items[0].children = ['c', 'd']; + assert.equal(reorderItems(items, items[1], 1, { folderId: 'f', childIdx: 0 }), false); + assert.deepEqual(items[0].children, ['c', 'd']); +}); + +test('settleDock takes docked apps out of folders and hidden apps out of the dock', () => { + const items = [ + { id: 'f', type: 'folder', children: ['c', 'd'] }, + { id: 'g', type: 'folder', children: ['d'] }, + { id: 'c', type: 'app' }, + { id: 'd', type: 'app', dock: true }, + { id: 'settings', type: 'app', system: 'settings', hidden: true, dock: true }, + ]; + settleDock(items); + assert.deepEqual(items[0].children, ['c']); + assert.deepEqual(items[1].children, []); + assert.equal(items[3].dock, true); + assert.equal(items[4].dock, false); + assert.equal(items[4].hidden, true); +}); + +test('reorderItems moves a row only within its group', () => { + const items = [ + { id: 'd1', type: 'app', dock: true }, + { id: 'a', type: 'app' }, + { id: 'd2', type: 'app', dock: true }, + { id: 'b', type: 'app' }, + ]; + assert.equal(reorderItems(items, items[0], 1), true); + assert.deepEqual( + items.map(i => i.id), + ['d2', 'a', 'd1', 'b'], + ); + assert.equal(reorderItems(items, items[2], 1), false); + assert.equal(reorderItems(items, items[1], -1), false); + assert.equal(reorderItems(items, items[1], 1), true); + assert.deepEqual( + items.map(i => i.id), + ['d2', 'b', 'd1', 'a'], + ); +}); + test('reorderItems reorders a child within its folder', () => { - const items = [{ id: 'f', type: 'folder', children: ['x', 'y', 'z'] }]; + const items = [ + { id: 'f', type: 'folder', children: ['x', 'y', 'z'] }, + ...['x', 'y', 'z'].map(id => ({ id, type: 'app' })), + ]; assert.equal(reorderItems(items, null, 1, { folderId: 'f', childIdx: 0 }), true); assert.deepEqual(items[0].children, ['y', 'x', 'z']); assert.equal(reorderItems(items, null, -1, { folderId: 'f', childIdx: 0 }), false); // out of bounds