Delete without "Are you sure?": Undo instead #90
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
Reference in new issue
Block a user