Delete without "Are you sure?": Undo instead #90

Merged
jcoffey-dev merged 1 commits from feature/undo-deletes into main 2026-10-06 16:46:14 +00:00
7 changed files with 425 additions and 117 deletions

No files matched your search

+125
View File
@@ -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<DeleteResult> {
const accountId = getAccountId(objectName);
const batchSize = Math.max(1, useAuthStore.getState().maxObjectsInSet || ids.length);
const destroyed: string[] = [];
const errors: Record<string, JmapSetError> = {};
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<string, JmapSetError>) {
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: (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => {
scheduled.undo();
dismiss();
}}
>
{i18n.t('undoDelete.undo', 'Undo')}
</Button>
),
});
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();
});
}
+18 -81
View File
@@ -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<Record<string, unknown> | null>(null);
const [createdObjectId, setCreatedObjectId] = useState<string | null>(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
<div className="flex items-center justify-between pt-2 pb-8">
<div className="flex items-center gap-3">
{canDelete && (
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
<Button type="button" variant="destructive" disabled={saving} onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete', 'Delete')}
</Button>
@@ -1047,51 +1029,6 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
if (!open) setDeleteConfirmOpen(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
{(() => {
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 (
<>
<AlertDialogTitle>{t('form.deleteTitle', 'Delete {{name}}?', { name: titleLabel })}</AlertDialogTitle>
<AlertDialogDescription>
{t(
'form.deleteDescription',
'This action cannot be undone. This will permanently delete this {{name}}.',
{ name: label },
)}
</AlertDialogDescription>
</>
);
})()}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDeleteConfirmOpen(false);
handleDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('common.delete', 'Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={serverCreatedProps !== null && createdObjectId !== null}
onOpenChange={(open) => {
+51 -35
View File
@@ -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<string, JmapSetError> = {};
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 (
<DropdownMenuItem
@@ -1181,6 +1191,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
key={`mass-${idx}`}
className={isDestructive ? 'text-destructive' : undefined}
onClick={() => {
// 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,
+2 -1
View File
@@ -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,
+8
View File
@@ -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}}"
}
}
+100
View File
@@ -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);
});
});
+121
View File
@@ -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<string, JmapSetError>;
}
/** Sends the delete. Injected so the hold-and-release logic can be tested without a server. */
export type Commit = (objectName: string, ids: string[]) => Promise<DeleteResult>;
interface State {
/** objectName → ids waiting to be deleted. */
pending: Record<string, string[]>;
/** objectName → bumped whenever its pending set changes or a delete lands, so lists reload. */
versions: Record<string, number>;
}
export const usePendingDeletes = create<State>(() => ({ 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<typeof setTimeout>;
run: () => Promise<DeleteResult>;
}
const held = new Set<Held>();
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<DeleteResult | null>;
}
/** 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<DeleteResult | null>((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<DeleteResult[]> {
return Promise.all([...held].map((h) => h.run()));
}