Trash: checkbox multi-select with bulk restore and purge (#128)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
This commit is contained in:
Claude Fable 5 2026-07-16 12:03:35 +02:00
parent 36cdd4fbca
commit 48d4c60af7
9 changed files with 246 additions and 27 deletions

View File

@ -82,6 +82,59 @@ test('restoring a page from the trash brings it back', async ({ browser }) => {
await context.close(); 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 }) => { test('purging a page from the trash removes it for good', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const title = `E2E Trash Purge ${Date.now()}`; const title = `E2E Trash Purge ${Date.now()}`;

View File

@ -1,16 +1,21 @@
import type { PageView, PondView } from '@dorfteich/shared'; import type { PageView, PondView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPost } from '../lib/api'; 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 { export function TrashPage(): React.JSX.Element {
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [failedCount, setFailedCount] = useState(0);
const pond = useQuery({ const pond = useQuery({
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
@ -22,20 +27,80 @@ export function TrashPage(): React.JSX.Element {
enabled: Boolean(pond.data), 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<HTMLInputElement>(null);
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedIds.length > 0 && !allSelected;
}
}, [selectedIds.length, allSelected]);
async function refresh(): Promise<void> { async function refresh(): Promise<void> {
await queryClient.invalidateQueries({ queryKey: ['trash', pond.data?.id] }); await queryClient.invalidateQueries({ queryKey: ['trash', pond.data?.id] });
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data?.id] }); await queryClient.invalidateQueries({ queryKey: ['pages', pond.data?.id] });
} }
async function restore(id: string): Promise<void> { function toggle(id: string): void {
await apiPost(`/pages/${id}/restore`); 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<unknown>): Promise<void> {
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(); await refresh();
} }
async function restore(id: string): Promise<void> {
await runBulk([id], (pageId) => apiPost(`/pages/${pageId}/restore`));
}
async function purge(id: string): Promise<void> { async function purge(id: string): Promise<void> {
if (!window.confirm(t('trash.purgeConfirm'))) return; if (!window.confirm(t('trash.purgeConfirm'))) return;
await apiDelete(`/pages/${id}/purge`); await runBulk([id], (pageId) => apiDelete(`/pages/${pageId}/purge`));
await refresh(); }
async function restoreSelected(): Promise<void> {
await runBulk(selectedIds, (pageId) => apiPost(`/pages/${pageId}/restore`));
}
async function purgeSelected(): Promise<void> {
if (!window.confirm(t('trash.purgeConfirmMany', { count: selectedIds.length }))) return;
await runBulk(selectedIds, (pageId) => apiDelete(`/pages/${pageId}/purge`));
} }
if (pond.error || trash.error) return <FormError error={pond.error ?? trash.error} />; if (pond.error || trash.error) return <FormError error={pond.error ?? trash.error} />;
@ -44,26 +109,79 @@ export function TrashPage(): React.JSX.Element {
return ( return (
<div className="trash-page"> <div className="trash-page">
<h1>{t('trash.title', { pond: pond.data.name })}</h1> <h1>{t('trash.title', { pond: pond.data.name })}</h1>
{trash.data.length === 0 ? ( {failedCount > 0 && (
<p className="trash-page__bulk-error" role="alert">
{t('trash.bulkFailed', { count: failedCount })}
</p>
)}
{pages.length === 0 ? (
<p>{t('trash.empty')}</p> <p>{t('trash.empty')}</p>
) : ( ) : (
<ul className="trash-page__list"> <>
{trash.data.map((page) => ( <div className="trash-page__bulk">
<li key={page.id} className="trash-page__item"> <label className="trash-page__select-all">
<span className="trash-page__title">{page.title}</span> <input
<span className="trash-page__deleted-at"> ref={selectAllRef}
{page.deletedAt && type="checkbox"
t('trash.deletedAt', { date: new Date(page.deletedAt).toLocaleDateString() })} checked={allSelected}
</span> disabled={busy}
<button type="button" className="button" onClick={() => void restore(page.id)}> onChange={toggleAll}
{t('trash.restore')} />
</button> {t('trash.selectAll')}
<button type="button" className="button" onClick={() => void purge(page.id)}> </label>
{t('trash.purge')} <button
</button> type="button"
</li> className="button trash-page__restore-selected"
))} disabled={busy || selectedIds.length === 0}
</ul> onClick={() => void restoreSelected()}
>
{t('trash.restoreSelected', { count: selectedIds.length })}
</button>
<button
type="button"
className="button button--danger trash-page__purge-selected"
disabled={busy || selectedIds.length === 0}
onClick={() => void purgeSelected()}
>
{t('trash.purgeSelected', { count: selectedIds.length })}
</button>
</div>
<ul className="trash-page__list">
{pages.map((page) => (
<li key={page.id} className="trash-page__item">
<input
type="checkbox"
className="trash-page__select"
checked={selected.has(page.id)}
disabled={busy}
aria-label={t('trash.select', { title: page.title })}
onChange={() => toggle(page.id)}
/>
<span className="trash-page__title">{page.title}</span>
<span className="trash-page__deleted-at">
{page.deletedAt &&
t('trash.deletedAt', { date: new Date(page.deletedAt).toLocaleDateString() })}
</span>
<button
type="button"
className="button"
disabled={busy}
onClick={() => void restore(page.id)}
>
{t('trash.restore')}
</button>
<button
type="button"
className="button"
disabled={busy}
onClick={() => void purge(page.id)}
>
{t('trash.purge')}
</button>
</li>
))}
</ul>
</>
)} )}
</div> </div>
); );

View File

@ -1666,6 +1666,28 @@ button {
margin: 0 auto; 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 { .trash-page__list {
list-style: none; list-style: none;
margin: var(--space-4) 0 0; margin: var(--space-4) 0 0;

View File

@ -37,7 +37,8 @@ Familien oder Projekte.
Wiederherstellungen erscheinen live in jedem offenen Editor. Wiederherstellungen erscheinen live in jedem offenen Editor.
- **Papierkorb mit Schonfrist.** Gelöschte Seiten liegen in einem - **Papierkorb mit Schonfrist.** Gelöschte Seiten liegen in einem
Papierkorb pro Teich und lassen sich wochenlang wiederherstellen, 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 - **Echte Backups.** Nächtliche Datenbank- und Datei-Backups, optional
externe Kopien auf eine **Nextcloud** deiner Wahl und eine getestete externe Kopien auf eine **Nextcloud** deiner Wahl und eine getestete
Ein-Klick-Wiederherstellung — inklusive dokumentiertem Weg, eine Ein-Klick-Wiederherstellung — inklusive dokumentiertem Weg, eine

View File

@ -57,7 +57,9 @@ Instanz das [Site-Admin-Handbuch](site-admin-guide.md).
Gelöschte Seiten lassen sich dort wiederherstellen, bis die Gelöschte Seiten lassen sich dort wiederherstellen, bis die
Aufbewahrungsfrist endet. Eine wiederhergestellte Seite hängt sich an Aufbewahrungsfrist endet. Eine wiederhergestellte Seite hängt sich an
den nächsten noch vorhandenen Elternknoten, oder an die oberste 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 ## Der Editor

View File

@ -32,7 +32,8 @@ projects.
versions you save yourself. Compare, see who contributed, and restore versions you save yourself. Compare, see who contributed, and restore
any earlier state — restores are visible live in every open editor. any earlier state — restores are visible live in every open editor.
- **Trash with a grace period.** Deleted pages sit in a per-pond trash - **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 - **Real backups.** Nightly database + file backups, optional off-host
copies to any **Nextcloud** you control, and a tested one-click restore copies to any **Nextcloud** you control, and a tested one-click restore
— including a documented path to rebuild an instance from nothing. — including a documented path to rebuild an instance from nothing.

View File

@ -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 - The **trash** link sits at the very bottom of the sidebar: deleted
pages can be restored from there until the retention period ends. pages can be restored from there until the retention period ends.
A restored page re-attaches to its nearest surviving parent, or to 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 ## The editor

View File

@ -151,6 +151,16 @@
"restore": "Wiederherstellen", "restore": "Wiederherstellen",
"purge": "Endgültig löschen", "purge": "Endgültig löschen",
"purgeConfirm": "Diese Seite und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", "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.", "pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.",
"restoreLink": "Im Papierkorb ansehen" "restoreLink": "Im Papierkorb ansehen"
}, },

View File

@ -149,6 +149,16 @@
"empty": "The trash is empty.", "empty": "The trash is empty.",
"deletedAt": "Deleted {{date}}", "deletedAt": "Deleted {{date}}",
"restore": "Restore", "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", "purge": "Delete forever",
"purgeConfirm": "Permanently delete this page and its files? This cannot be undone.", "purgeConfirm": "Permanently delete this page and its files? This cannot be undone.",
"pageTrashedHint": "This page has been moved to the trash.", "pageTrashedHint": "This page has been moved to the trash.",