diff --git a/src/components/common/undoableDelete.tsx b/src/components/common/undoableDelete.tsx new file mode 100644 index 0000000..d6e0503 --- /dev/null +++ b/src/components/common/undoableDelete.tsx @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Delete without "Are you sure?" (admin UX roadmap, item 9). The + * objects vanish at once, a toast offers Undo, and the delete is sent when + * the toast's time is up. A delete the server refuses brings the objects + * back with the reason. + */ + +import i18n from '@/i18n'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/hooks/use-toast'; +import { friendlySetError } from '@/lib/jmapErrors'; +import { + flushPendingDeletes, + heldCount, + scheduleDelete, + UNDO_MS, + type DeleteResult, + type Scheduled, +} from '@/lib/pendingDeletes'; +import { getAccountId, jmapSet } from '@/services/jmap/client'; +import { useAuthStore } from '@/stores/authStore'; +import type { JmapSetError, JmapSetResponse } from '@/types/jmap'; + +/** Sends the delete in batches the server accepts. */ +async function commit(objectName: string, ids: string[]): Promise { + const accountId = getAccountId(objectName); + const batchSize = Math.max(1, useAuthStore.getState().maxObjectsInSet || ids.length); + const destroyed: string[] = []; + const errors: Record = {}; + for (let i = 0; i < ids.length; i += batchSize) { + const batch = ids.slice(i, i + batchSize); + const responses = await jmapSet(objectName, accountId, { destroy: batch }); + const entry = responses.find(([name]) => name === `${objectName}/set`); + if (!entry) { + const r = responses[0]?.[1] as { type?: string; description?: string } | undefined; + const error: JmapSetError = { type: r?.type ?? 'serverFail', description: r?.description }; + for (const id of batch) errors[id] = error; + continue; + } + const resp = entry[1] as unknown as JmapSetResponse; + destroyed.push(...(resp.destroyed ?? [])); + Object.assign(errors, resp.notDestroyed ?? {}); + for (const id of batch) { + if (!destroyed.includes(id) && !errors[id]) { + errors[id] = { + type: 'notConfirmed', + description: i18n.t( + 'list.deleteNotConfirmed', + 'Delete failed: item was not confirmed as destroyed by the server.', + ), + }; + } + } + } + return { destroyed, errors }; +} + +function reportFailures(errors: Record) { + const list = Object.values(errors); + if (list.length === 0) return; + const reasons = [...new Set(list.map((e) => friendlySetError(e)))]; + toast({ + variant: 'destructive', + title: i18n.t('undoDelete.failed', { + count: list.length, + defaultValue_one: 'Not deleted', + defaultValue_other: '{{count}} not deleted', + }), + description: reasons.join(' '), + }); +} + +export interface UndoableDelete { + objectName: string; + ids: string[]; + /** What was deleted, in words: "the domain example.org", "3 accounts". */ + what: string; + /** Runs once the server has deleted at least one of them. */ + onDeleted?: (result: DeleteResult) => void; +} + +/** Hides the objects, offers Undo for UNDO_MS, then deletes them. */ +export function deleteWithUndo({ objectName, ids, what, onDeleted }: UndoableDelete): Scheduled { + const scheduled = scheduleDelete(objectName, ids, commit); + const { dismiss } = toast({ + title: i18n.t('undoDelete.deleted', 'Deleted {{what}}', { what }), + duration: UNDO_MS, + description: ( + + ), + }); + void scheduled.settled.then((result) => { + if (!result) return; + reportFailures(result.errors); + if (result.destroyed.length > 0) onDeleted?.(result); + }); + return scheduled; +} + +// Leaving the page sends whatever is still waiting, and asks the browser to +// hold on while it goes, so a delete is neither lost nor done silently later. +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', (e) => { + if (heldCount() === 0) return; + void flushPendingDeletes(); + e.preventDefault(); + }); +} diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index b2c1879..0c0a83d 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -53,6 +53,7 @@ import { resolveSchema, resolveList, resolveForm, + getDisplayProperty, resolveVariantForm, buildCreateDefaults, buildEmbeddedDefaults, @@ -63,6 +64,7 @@ import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors'; import { coerceLabel } from '@/lib/objectOptions'; import { SECRET_MASK } from '@/lib/jmapUtils'; import { toast } from '@/hooks/use-toast'; +import { deleteWithUndo } from '@/components/common/undoableDelete'; import { logFormChange } from '@/lib/debug'; import { FieldWidget } from '@/components/forms/FieldWidget'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; @@ -115,7 +117,6 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic const [serverCreatedProps, setServerCreatedProps] = useState | null>(null); const [createdObjectId, setCreatedObjectId] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [resetOpen, setResetOpen] = useState(false); const currentFields = useMemo((): Fields | null => { @@ -680,41 +681,22 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic t, ]); - const handleDelete = useCallback(async () => { + // inbuxa: no "Are you sure?"; the delete waits behind an Undo toast (admin UX roadmap, item 9). + const handleDelete = useCallback(() => { if (!resolved || !objectId) return; - - setSaving(true); - setGeneralError(null); - - try { - const { obj } = resolved; - const accountId = getAccountId(obj.objectName); - const responses = await jmapSet(obj.objectName, accountId, { destroy: [objectId] }); - const setResponse = responses[responses.length - 1]; - const setResult = setResponse[1] as unknown as JmapSetResponse; - - if (setResult.destroyed && setResult.destroyed.includes(objectId)) { - const list = resolveList(schema!, viewName, obj.objectName); - const label = list?.singularName ?? obj.objectType.description; - toast({ - title: t('form.deletedSuccess', '{{name}} deleted successfully', { - name: label.charAt(0).toUpperCase() + label.slice(1), - }), - variant: 'success', - }); - setOriginalData({ ...formData }); - const section = viewToSection[viewName] ?? ''; - navigate(`/${section}/${viewName}`); - } else if (setResult.notDestroyed && setResult.notDestroyed[objectId]) { - const error = setResult.notDestroyed[objectId]; - setGeneralError(friendlySetError(error)); - } - } catch (err) { - setGeneralError(err instanceof Error ? err.message : t('form.failedToDelete', 'Failed to delete.')); - } finally { - setSaving(false); - } - }, [resolved, viewName, objectId, viewToSection, navigate, schema, formData, t]); + const { obj } = resolved; + const list = resolveList(schema!, viewName, obj.objectName); + const label = (list?.singularName ?? obj.objectType.description).toLowerCase(); + const shown = formData[getDisplayProperty(schema!, obj.objectName)]; + deleteWithUndo({ + objectName: obj.objectName, + ids: [objectId], + what: typeof shown === 'string' && shown ? shown : label, + }); + setOriginalData({ ...formData }); + const section = viewToSection[viewName] ?? ''; + navigate(`/${section}/${viewName}`); + }, [resolved, viewName, objectId, viewToSection, navigate, schema, formData]); const canEdit = isCreate || canUpdateObject(viewName); const canDelete = !isCreate && !isSingleton && canDestroyObject(viewName); @@ -948,7 +930,7 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic
{canDelete && ( - @@ -1047,51 +1029,6 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic - { - if (!open) setDeleteConfirmOpen(false); - }} - > - - - {(() => { - const list = resolved ? resolveList(schema, viewName, resolved.obj.objectName) : null; - const label = ( - list?.singularName ?? - resolved?.obj.objectType.description ?? - t('form.item', 'item') - ).toLowerCase(); - const titleLabel = label.charAt(0).toUpperCase() + label.slice(1); - return ( - <> - {t('form.deleteTitle', 'Delete {{name}}?', { name: titleLabel })} - - {t( - 'form.deleteDescription', - 'This action cannot be undone. This will permanently delete this {{name}}.', - { name: label }, - )} - - - ); - })()} - - - {t('common.cancel', 'Cancel')} - { - setDeleteConfirmOpen(false); - handleDelete(); - }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {t('common.delete', 'Delete')} - - - - - { diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 27492a0..e9143f2 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -59,6 +59,8 @@ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/component import { ObjectPicker } from '@/components/common/ObjectPicker'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { toast } from '@/hooks/use-toast'; +import { deleteWithUndo } from '@/components/common/undoableDelete'; +import { isPendingDelete, usePendingDeletes } from '@/lib/pendingDeletes'; import { friendlySetError } from '@/lib/jmapErrors'; import { coerceLabel } from '@/lib/objectOptions'; import { buildJmapFilter } from '@/lib/listFilter'; @@ -501,9 +503,12 @@ export function DynamicList({ viewName }: DynamicListProps) { // inbuxa: a server that can't count (logs past the first page) leaves // total out; the last page's total must not stand in for it - setTotal(queryData.total ?? null); + // inbuxa: objects waiting behind an Undo toast are already gone here (item 9) + const listed = getData.list ?? []; + const shown = listed.filter((item) => !isPendingDelete(obj.objectName, item.id as string)); + setTotal(queryData.total == null ? null : queryData.total - (listed.length - shown.length)); - setItems(getData.list ?? []); + setItems(shown); setSelectedIds(new Set()); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -514,6 +519,15 @@ export function DynamicList({ viewName }: DynamicListProps) { [resolved, schema, buildFilter, buildSort, t], ); + // inbuxa: reload when a delete of this kind is held, undone or sent (item 9) + const pendingVersion = usePendingDeletes((s) => (resolved ? (s.versions[resolved.obj.objectName] ?? 0) : 0)); + useEffect(() => { + if (pendingVersion === 0) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchData(currentAnchor, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingVersion]); + useEffect(() => { if (!resolved?.list) return; // eslint-disable-next-line react-hooks/set-state-in-effect @@ -711,15 +725,21 @@ export function DynamicList({ viewName }: DynamicListProps) { const allErrors: Record = {}; if (action.type === 'delete') { - for (let i = 0; i < targetIds.length; i += batchSize) { - const batch = targetIds.slice(i, i + batchSize); - const raw = await jmapSet(obj.objectName, accountId, { destroy: batch }); - const resp = parseSetResponse(raw); - if (resp) { - totalSuccess += resp.destroyed?.length ?? 0; - if (resp.notDestroyed) Object.assign(allErrors, resp.notDestroyed); - } - } + // inbuxa: held behind an Undo toast, which reports what the server refused (item 9) + const names = resolved.list; + deleteWithUndo({ + objectName: obj.objectName, + ids: targetIds, + what: t('undoDelete.count', { + count: targetIds.length, + name: targetIds.length === 1 ? names?.singularName : names?.pluralName, + defaultValue_one: '{{count}} {{name}}', + defaultValue_other: '{{count}} {{name}}', + }), + }); + setSelectedIds(new Set()); + setSelectAllMode(false); + return; } else if (action.type === 'setProperty') { for (let i = 0; i < targetIds.length; i += batchSize) { const batch = targetIds.slice(i, i + batchSize); @@ -739,7 +759,7 @@ export function DynamicList({ viewName }: DynamicListProps) { const summary = bulkResultSummary( totalSuccess, Object.keys(allErrors).length > 0 ? allErrors : null, - action.type === 'delete' ? 'delete' : 'update', + 'update', t, ); if (summary) { @@ -802,34 +822,23 @@ export function DynamicList({ viewName }: DynamicListProps) { break; } case 'delete': { - try { - setLoading(true); - const accountId = getAccountId(obj.objectName); - const raw = await jmapSet(obj.objectName, accountId, { - destroy: [itemId], - }); - const resp = parseSetResponse(raw); - if (resp?.notDestroyed?.[itemId]) { - setError(friendlySetError(resp.notDestroyed[itemId])); - } else if (resp?.destroyed?.includes(itemId)) { - fetchData(currentAnchor, 0); - } else { - setError( - t('list.deleteNotConfirmed', 'Delete failed: item was not confirmed as destroyed by the server.'), - ); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + // inbuxa: no "Are you sure?"; held behind an Undo toast (item 9) + const shown = schema ? item[getDisplayProperty(schema, obj.objectName)] : undefined; + deleteWithUndo({ + objectName: obj.objectName, + ids: [itemId], + what: + typeof shown === 'string' && shown + ? shown + : (resolved.list?.singularName ?? obj.objectType.description).toLowerCase(), + }); break; } case 'separator': break; } }, - [resolved, viewName, viewToSection, navigate, fetchData, currentAnchor, t], + [resolved, schema, viewName, viewToSection, navigate, fetchData, currentAnchor], ); const handleRowClick = useCallback( @@ -1116,7 +1125,8 @@ export function DynamicList({ viewName }: DynamicListProps) { } const isDestructive = action.type === 'delete'; - const needsConfirmation = action.type === 'delete' || action.type === 'setProperty'; + // inbuxa: a delete offers Undo instead (item 9); changing a property still asks + const needsConfirmation = action.type === 'setProperty'; return ( { + // inbuxa: deleting the rows you ticked offers Undo instead of asking (item 9); + // "all matching" can reach far past the page, so it still asks first + if (isDestructive && !selectAllMode) { + void executeMassAction(action); + return; + } setConfirmAction({ label: t('list.actionWithCount', '{{action}} ({{count}} {{name}})', { action: action.label, diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index 29aba80..1bf2c31 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -159,7 +159,8 @@ function toast({ ...props }: Toast) { }, }); - setTimeout(dismiss, TOAST_AUTO_DISMISS_MS); + // inbuxa: honor the toast's own duration (an Undo toast stays up longer). + setTimeout(dismiss, props.duration ?? TOAST_AUTO_DISMISS_MS); return { id: id, diff --git a/src/i18n/en.json b/src/i18n/en.json index 1e84aba..cdeb8cd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2141,5 +2141,13 @@ "sessions": "{{succeeded}} secure deliveries, {{failed}} failed", "failedSessions_one": "({{count}} connection", "failedSessions_other": "({{count}} connections" + }, + "undoDelete": { + "failed_one": "Not deleted", + "failed_other": "{{count}} not deleted", + "deleted": "Deleted {{what}}", + "undo": "Undo", + "count_one": "{{count}} {{name}}", + "count_other": "{{count}} {{name}}" } } diff --git a/src/lib/pendingDeletes.test.ts b/src/lib/pendingDeletes.test.ts new file mode 100644 index 0000000..efb9b8d --- /dev/null +++ b/src/lib/pendingDeletes.test.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + flushPendingDeletes, + heldCount, + isPendingDelete, + scheduleDelete, + usePendingDeletes, + type Commit, +} from './pendingDeletes'; + +const ok: Commit = async (_object, ids) => ({ destroyed: ids, errors: {} }); + +describe('scheduleDelete', () => { + beforeEach(() => { + vi.useFakeTimers(); + usePendingDeletes.setState({ pending: {}, versions: {} }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('hides the objects at once and sends nothing until the time is up', async () => { + const commit = vi.fn(ok); + const s = scheduleDelete('x:Domain', ['a', 'b'], commit, 1000); + expect(isPendingDelete('x:Domain', 'a')).toBe(true); + expect(isPendingDelete('x:Account', 'a')).toBe(false); + await vi.advanceTimersByTimeAsync(999); + expect(commit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(commit).toHaveBeenCalledWith('x:Domain', ['a', 'b']); + expect(await s.settled).toEqual({ destroyed: ['a', 'b'], errors: {} }); + expect(isPendingDelete('x:Domain', 'a')).toBe(false); + expect(heldCount()).toBe(0); + }); + + it('undo in time forgets the delete and shows the objects again', async () => { + const commit = vi.fn(ok); + const s = scheduleDelete('x:Domain', ['a'], commit, 1000); + expect(s.undo()).toBe(true); + await vi.advanceTimersByTimeAsync(2000); + expect(commit).not.toHaveBeenCalled(); + expect(await s.settled).toBeNull(); + expect(isPendingDelete('x:Domain', 'a')).toBe(false); + }); + + it('undo after the delete was sent does nothing', async () => { + const s = scheduleDelete('x:Domain', ['a'], ok, 1000); + await vi.advanceTimersByTimeAsync(1000); + await s.settled; + expect(s.undo()).toBe(false); + }); + + it('bumps the version for its kind of object, so lists reload', async () => { + scheduleDelete('x:Domain', ['a'], ok, 1000); + expect(usePendingDeletes.getState().versions['x:Domain']).toBe(1); + await vi.advanceTimersByTimeAsync(1000); + expect(usePendingDeletes.getState().versions['x:Domain']).toBe(2); + expect(usePendingDeletes.getState().versions['x:Account']).toBeUndefined(); + }); + + it('reports a refused or failed delete, and shows the objects again', async () => { + const refused = scheduleDelete( + 'x:Domain', + ['a'], + async () => ({ destroyed: [], errors: { a: { type: 'objectIsLinked' } } }), + 1000, + ); + const thrown = scheduleDelete( + 'x:Account', + ['b'], + async () => { + throw new Error('offline'); + }, + 1000, + ); + await vi.advanceTimersByTimeAsync(1000); + expect((await refused.settled)?.errors.a.type).toBe('objectIsLinked'); + expect((await thrown.settled)?.errors.b).toEqual({ type: 'serverFail', description: 'offline' }); + expect(isPendingDelete('x:Domain', 'a')).toBe(false); + expect(isPendingDelete('x:Account', 'b')).toBe(false); + }); + + it('flush sends every waiting delete now', async () => { + const commit = vi.fn(ok); + scheduleDelete('x:Domain', ['a'], commit, 60_000); + scheduleDelete('x:Account', ['b'], commit, 60_000); + expect(heldCount()).toBe(2); + await flushPendingDeletes(); + expect(commit).toHaveBeenCalledTimes(2); + expect(heldCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(commit).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/pendingDeletes.ts b/src/lib/pendingDeletes.ts new file mode 100644 index 0000000..852c010 --- /dev/null +++ b/src/lib/pendingDeletes.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Undo instead of "Are you sure?" (admin UX roadmap, item 9). A + * delete is held here for UNDO_MS before it reaches the server, so Undo + * only has to forget it: nothing is re-created, and nothing is lost that a + * re-create couldn't bring back (secrets, ids, history). While a delete is + * held its objects are hidden from lists. + */ + +import { create } from 'zustand'; +import type { JmapSetError } from '@/types/jmap'; + +/** How long Undo is offered before the delete is sent. */ +export const UNDO_MS = 8_000; + +export interface DeleteResult { + destroyed: string[]; + errors: Record; +} + +/** Sends the delete. Injected so the hold-and-release logic can be tested without a server. */ +export type Commit = (objectName: string, ids: string[]) => Promise; + +interface State { + /** objectName → ids waiting to be deleted. */ + pending: Record; + /** objectName → bumped whenever its pending set changes or a delete lands, so lists reload. */ + versions: Record; +} + +export const usePendingDeletes = create(() => ({ pending: {}, versions: {} })); + +function change(objectName: string, update: (ids: string[]) => string[]) { + usePendingDeletes.setState((s) => ({ + pending: { ...s.pending, [objectName]: update(s.pending[objectName] ?? []) }, + versions: { ...s.versions, [objectName]: (s.versions[objectName] ?? 0) + 1 }, + })); +} + +/** Whether this object is waiting to be deleted, so a list should leave it out. */ +export function isPendingDelete(objectName: string, id: string): boolean { + return usePendingDeletes.getState().pending[objectName]?.includes(id) ?? false; +} + +interface Held { + objectName: string; + ids: string[]; + timer: ReturnType; + run: () => Promise; +} + +const held = new Set(); + +export interface Scheduled { + /** Forgets the delete, if it hasn't been sent yet. Returns whether it was in time. */ + undo: () => boolean; + /** Resolves once the delete was sent (with what happened) or undone (null). */ + settled: Promise; +} + +/** Holds a delete for `delayMs`, then sends it with `commit`. */ +export function scheduleDelete( + objectName: string, + ids: string[], + commit: Commit, + delayMs: number = UNDO_MS, +): Scheduled { + let resolve!: (r: DeleteResult | null) => void; + const settled = new Promise((r) => (resolve = r)); + change(objectName, (pending) => [...pending, ...ids.filter((id) => !pending.includes(id))]); + + const release = () => change(objectName, (pending) => pending.filter((id) => !ids.includes(id))); + + const entry: Held = { + objectName, + ids, + timer: setTimeout(() => void entry.run(), delayMs), + run: async () => { + held.delete(entry); + clearTimeout(entry.timer); + let result: DeleteResult; + try { + result = await commit(objectName, ids); + } catch (e) { + const error: JmapSetError = { type: 'serverFail', description: e instanceof Error ? e.message : String(e) }; + result = { destroyed: [], errors: Object.fromEntries(ids.map((id) => [id, error])) }; + } + release(); + resolve(result); + return result; + }, + }; + held.add(entry); + + return { + undo: () => { + if (!held.has(entry)) return false; + held.delete(entry); + clearTimeout(entry.timer); + release(); + resolve(null); + return true; + }, + settled, + }; +} + +/** How many deletes are waiting. */ +export function heldCount(): number { + return held.size; +} + +/** Sends every waiting delete now (leaving the page, signing out). */ +export function flushPendingDeletes(): Promise { + return Promise.all([...held].map((h) => h.run())); +}