From 48d4c60af769e1888e2840f51cfeb8d61fa13637 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 16 Jul 2026 12:03:35 +0200 Subject: [PATCH] Trash: checkbox multi-select with bulk restore and purge (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each trash row gets a checkbox, a toolbar above the list offers "select all" (native indeterminate for partial selections) and the two bulk actions; bulk purge confirms with the selection count (pluralized). Processing is sequential on purpose — purge promotes leftover children (#107), so concurrent tree mutations would race. Failures don't strand the rest: the loop keeps going, failed pages stay selected for a retry, and an alert banner reports the count. Single-row actions run through the same path, which also fixes their previously unhandled rejection. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn --- apps/web/e2e/trash.spec.ts | 53 +++++++++ apps/web/src/pages/TrashPage.tsx | 164 ++++++++++++++++++++++++---- apps/web/src/styles/base.css | 22 ++++ docs/de/features.md | 3 +- docs/de/manual/user-guide.md | 4 +- docs/features.md | 3 +- docs/manual/user-guide.md | 4 +- packages/shared/i18n/de/editor.json | 10 ++ packages/shared/i18n/en/editor.json | 10 ++ 9 files changed, 246 insertions(+), 27 deletions(-) diff --git a/apps/web/e2e/trash.spec.ts b/apps/web/e2e/trash.spec.ts index ab230c2..31a3fa2 100644 --- a/apps/web/e2e/trash.spec.ts +++ b/apps/web/e2e/trash.spec.ts @@ -82,6 +82,59 @@ test('restoring a page from the trash brings it back', async ({ browser }) => { await context.close(); }); +test('multi-select: bulk restore, select all, and bulk purge (#128)', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const stamp = Date.now(); + const titles = [0, 1, 2].map((i) => `E2E Trash Bulk ${stamp} ${i}`); + + // Provision three pages and trash them via the API. + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + for (const title of titles) { + const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title }, + }); + const { id } = (await created.json()) as { id: string }; + await context.request.delete(`/api/v1/pages/${id}`); + } + + const page = await context.newPage(); + acceptDialogs(page); + await page.goto(`/p/${pond.slug}/trash`); + + // Nothing selected → both bulk actions are disabled. + const restoreSelected = page.locator('.trash-page__restore-selected'); + const purgeSelected = page.locator('.trash-page__purge-selected'); + await expect(restoreSelected).toBeDisabled(); + await expect(purgeSelected).toBeDisabled(); + + // Check two of the three rows and restore them with one click. + await trashItem(page, titles[0]!).getByRole('checkbox').check(); + await trashItem(page, titles[1]!).getByRole('checkbox').check(); + await expect(restoreSelected).toBeEnabled(); + await restoreSelected.click(); + await expect(trashItem(page, titles[0]!)).toHaveCount(0); + await expect(trashItem(page, titles[1]!)).toHaveCount(0); + await expect(trashItem(page, titles[2]!)).toBeVisible(); + + // Both restored pages are back in the pages list. + for (const title of titles.slice(0, 2)) { + const res = await context.request.get(`/api/v1/ponds/${pond.id}/pages`); + const pages = (await res.json()) as { title: string }[]; + expect(pages.some((p) => p.title === title)).toBe(true); + } + + // "Select all" marks every remaining entry; bulk purge (confirm accepted) + // empties the trash — including this test's third page. + await page.locator('.trash-page__select-all').getByRole('checkbox').check(); + await expect(purgeSelected).toBeEnabled(); + await purgeSelected.click(); + await expect(trashItem(page, titles[2]!)).toHaveCount(0); + await expect(page.locator('.trash-page__item')).toHaveCount(0); + + await context.close(); +}); + test('purging a page from the trash removes it for good', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const title = `E2E Trash Purge ${Date.now()}`; diff --git a/apps/web/src/pages/TrashPage.tsx b/apps/web/src/pages/TrashPage.tsx index f91550a..4e6b636 100644 --- a/apps/web/src/pages/TrashPage.tsx +++ b/apps/web/src/pages/TrashPage.tsx @@ -1,16 +1,21 @@ import type { PageView, PondView } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { FormError } from '../components/forms'; import { apiDelete, apiGet, apiPost } from '../lib/api'; -/** Pond trash view (issue #31): list, restore, and permanently delete. */ +/** Pond trash view (issue #31): list, restore, and permanently delete — + * single pages or a checkbox multi-selection at once (#128). */ export function TrashPage(): React.JSX.Element { const { t } = useTranslation('editor'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const queryClient = useQueryClient(); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [failedCount, setFailedCount] = useState(0); const pond = useQuery({ queryKey: ['pond', pondSlug], @@ -22,20 +27,80 @@ export function TrashPage(): React.JSX.Element { enabled: Boolean(pond.data), }); + // Selection pruned to pages still in the trash — restored/purged entries + // drop out on refresh, failed ones stay selected for a retry. + const pages = trash.data ?? []; + const selectedIds = pages.filter((page) => selected.has(page.id)).map((page) => page.id); + const allSelected = pages.length > 0 && selectedIds.length === pages.length; + + // Native indeterminate state for a partial selection — only settable via + // the DOM property, not an attribute. + const selectAllRef = useRef(null); + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = selectedIds.length > 0 && !allSelected; + } + }, [selectedIds.length, allSelected]); + async function refresh(): Promise { await queryClient.invalidateQueries({ queryKey: ['trash', pond.data?.id] }); await queryClient.invalidateQueries({ queryKey: ['pages', pond.data?.id] }); } - async function restore(id: string): Promise { - await apiPost(`/pages/${id}/restore`); + function toggle(id: string): void { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function toggleAll(): void { + setSelected(allSelected ? new Set() : new Set(pages.map((page) => page.id))); + } + + /** Run one action over the ids, keep going past failures (#128): a page + * that stopped being restorable must not strand the rest of the selection. + * Sequential on purpose — purge promotes leftover children (#107), so + * concurrent tree mutations would race each other. */ + async function runBulk(ids: string[], action: (id: string) => Promise): Promise { + setBusy(true); + setFailedCount(0); + let failed = 0; + for (const id of ids) { + try { + await action(id); + setSelected((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } catch { + failed += 1; + } + } + setFailedCount(failed); + setBusy(false); await refresh(); } + async function restore(id: string): Promise { + await runBulk([id], (pageId) => apiPost(`/pages/${pageId}/restore`)); + } + async function purge(id: string): Promise { if (!window.confirm(t('trash.purgeConfirm'))) return; - await apiDelete(`/pages/${id}/purge`); - await refresh(); + await runBulk([id], (pageId) => apiDelete(`/pages/${pageId}/purge`)); + } + + async function restoreSelected(): Promise { + await runBulk(selectedIds, (pageId) => apiPost(`/pages/${pageId}/restore`)); + } + + async function purgeSelected(): Promise { + if (!window.confirm(t('trash.purgeConfirmMany', { count: selectedIds.length }))) return; + await runBulk(selectedIds, (pageId) => apiDelete(`/pages/${pageId}/purge`)); } if (pond.error || trash.error) return ; @@ -44,26 +109,79 @@ export function TrashPage(): React.JSX.Element { return (

{t('trash.title', { pond: pond.data.name })}

- {trash.data.length === 0 ? ( + {failedCount > 0 && ( +

+ {t('trash.bulkFailed', { count: failedCount })} +

+ )} + {pages.length === 0 ? (

{t('trash.empty')}

) : ( -
    - {trash.data.map((page) => ( -
  • - {page.title} - - {page.deletedAt && - t('trash.deletedAt', { date: new Date(page.deletedAt).toLocaleDateString() })} - - - -
  • - ))} -
+ <> +
+ + + +
+
    + {pages.map((page) => ( +
  • + toggle(page.id)} + /> + {page.title} + + {page.deletedAt && + t('trash.deletedAt', { date: new Date(page.deletedAt).toLocaleDateString() })} + + + +
  • + ))} +
+ )}
); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index de97e92..bc5e0f8 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1666,6 +1666,28 @@ button { margin: 0 auto; } +/* Multi-select toolbar above the list (#128). */ +.trash-page__bulk { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-4); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--color-border); +} + +.trash-page__select-all { + display: inline-flex; + align-items: center; + gap: var(--space-2); + margin-right: auto; + cursor: pointer; +} + +.trash-page__bulk-error { + color: var(--color-danger); +} + .trash-page__list { list-style: none; margin: var(--space-4) 0 0; diff --git a/docs/de/features.md b/docs/de/features.md index c30d1b6..f586361 100644 --- a/docs/de/features.md +++ b/docs/de/features.md @@ -37,7 +37,8 @@ Familien oder Projekte. Wiederherstellungen erscheinen live in jedem offenen Editor. - **Papierkorb mit Schonfrist.** Gelöschte Seiten liegen in einem Papierkorb pro Teich und lassen sich wochenlang wiederherstellen, - bevor sie endgültig entfernt werden. + bevor sie endgültig entfernt werden — einzeln oder als + Checkbox-Mehrfachauswahl mit Sammel-Wiederherstellen/-Löschen. - **Echte Backups.** Nächtliche Datenbank- und Datei-Backups, optional externe Kopien auf eine **Nextcloud** deiner Wahl und eine getestete Ein-Klick-Wiederherstellung — inklusive dokumentiertem Weg, eine diff --git a/docs/de/manual/user-guide.md b/docs/de/manual/user-guide.md index e687bf8..4931c7a 100644 --- a/docs/de/manual/user-guide.md +++ b/docs/de/manual/user-guide.md @@ -57,7 +57,9 @@ Instanz das [Site-Admin-Handbuch](site-admin-guide.md). Gelöschte Seiten lassen sich dort wiederherstellen, bis die Aufbewahrungsfrist endet. Eine wiederhergestellte Seite hängt sich an den nächsten noch vorhandenen Elternknoten, oder an die oberste - Ebene, wenn der ganze Zweig fehlt. + Ebene, wenn der ganze Zweig fehlt. Über Checkboxen wählst du mehrere + Seiten aus (oder **alle auf einmal**) und stellst sie mit einem Klick + wieder her bzw. löschst sie endgültig. ## Der Editor diff --git a/docs/features.md b/docs/features.md index fc7e67c..647b9f8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -32,7 +32,8 @@ projects. versions you save yourself. Compare, see who contributed, and restore any earlier state — restores are visible live in every open editor. - **Trash with a grace period.** Deleted pages sit in a per-pond trash - and can be restored for weeks before they are purged. + and can be restored for weeks before they are purged — one by one or + as a checkbox multi-selection with bulk restore/delete. - **Real backups.** Nightly database + file backups, optional off-host copies to any **Nextcloud** you control, and a tested one-click restore — including a documented path to rebuild an instance from nothing. diff --git a/docs/manual/user-guide.md b/docs/manual/user-guide.md index ac37ebb..7fb2c3f 100644 --- a/docs/manual/user-guide.md +++ b/docs/manual/user-guide.md @@ -48,7 +48,9 @@ instance administration the [site-admin guide](site-admin-guide.md). - The **trash** link sits at the very bottom of the sidebar: deleted pages can be restored from there until the retention period ends. A restored page re-attaches to its nearest surviving parent, or to - the top level when the whole branch is gone. + the top level when the whole branch is gone. Checkboxes let you + select several pages (or **select all**) and restore or permanently + delete them with one click. ## The editor diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 955dc6b..8b7e864 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -151,6 +151,16 @@ "restore": "Wiederherstellen", "purge": "Endgültig löschen", "purgeConfirm": "Diese Seite und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", + "select": "Seite „{{title}}“ auswählen", + "selectAll": "Alle auswählen", + "restoreSelected_one": "Auswahl wiederherstellen ({{count}})", + "restoreSelected_other": "Auswahl wiederherstellen ({{count}})", + "purgeSelected_one": "Auswahl endgültig löschen ({{count}})", + "purgeSelected_other": "Auswahl endgültig löschen ({{count}})", + "purgeConfirmMany_one": "Die ausgewählte Seite und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", + "purgeConfirmMany_other": "{{count}} ausgewählte Seiten und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", + "bulkFailed_one": "Eine Seite konnte nicht verarbeitet werden. Sie bleibt ausgewählt — bitte erneut versuchen.", + "bulkFailed_other": "{{count}} Seiten konnten nicht verarbeitet werden. Sie bleiben ausgewählt — bitte erneut versuchen.", "pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.", "restoreLink": "Im Papierkorb ansehen" }, diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 661487e..7648a26 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -149,6 +149,16 @@ "empty": "The trash is empty.", "deletedAt": "Deleted {{date}}", "restore": "Restore", + "select": "Select page “{{title}}”", + "selectAll": "Select all", + "restoreSelected_one": "Restore selection ({{count}})", + "restoreSelected_other": "Restore selection ({{count}})", + "purgeSelected_one": "Delete selection permanently ({{count}})", + "purgeSelected_other": "Delete selection permanently ({{count}})", + "purgeConfirmMany_one": "Permanently delete the selected page and its files? This cannot be undone.", + "purgeConfirmMany_other": "Permanently delete {{count}} selected pages and their files? This cannot be undone.", + "bulkFailed_one": "One page could not be processed. It stays selected — please retry.", + "bulkFailed_other": "{{count}} pages could not be processed. They stay selected — please retry.", "purge": "Delete forever", "purgeConfirm": "Permanently delete this page and its files? This cannot be undone.", "pageTrashedHint": "This page has been moved to the trash.",