Jobber/orchestrator/src/client/lib/jobActionUndo.ts
ilia d3d9130bbf feat: add undo toast for skip, apply, and status moves
Accidental skips and status changes can be reversed within a few seconds via toast Undo, including bulk actions and keyboard shortcuts.
2026-07-15 12:55:47 -04:00

147 lines
3.9 KiB
TypeScript

import * as api from "@client/api";
import type { JobActionResult, JobStatus, JobStatusRestore } from "@shared/types";
import { toast } from "sonner";
const DEFAULT_UNDO_MS = 8_000;
export type StatusRestore = JobStatusRestore;
/** Collect restore snapshots from skip action results (includes auto-skipped dupes). */
export function restoresFromSkipResults(
results: JobActionResult[],
): StatusRestore[] {
const byId = new Map<string, StatusRestore>();
for (const result of results) {
if (!result.ok || !result.undo) continue;
byId.set(result.jobId, {
jobId: result.jobId,
status: result.undo.previousStatus,
});
for (const also of result.undo.alsoSkipped ?? []) {
if (!byId.has(also.jobId)) {
byId.set(also.jobId, also);
}
}
}
return Array.from(byId.values());
}
export async function restoreJobStatuses(
restores: StatusRestore[],
): Promise<void> {
if (restores.length === 0) return;
await api.restoreJobStatuses(restores);
}
/**
* Show a success/message toast with an Undo action that restores prior statuses.
*/
export function toastWithUndo(input: {
title: string;
description?: string;
restores: StatusRestore[];
variant?: "success" | "message";
durationMs?: number;
onRestored?: () => void | Promise<void>;
successMessage?: string;
}): void {
const {
title,
description,
restores,
variant = "message",
durationMs = DEFAULT_UNDO_MS,
onRestored,
successMessage = restores.length === 1
? "Undone"
: `Restored ${restores.length} jobs`,
} = input;
if (restores.length === 0) {
if (variant === "success") {
toast.success(title, { description, duration: durationMs });
} else {
toast.message(title, { description, duration: durationMs });
}
return;
}
const action = {
label: "Undo",
onClick: () => {
void (async () => {
try {
await restoreJobStatuses(restores);
toast.success(successMessage);
await onRestored?.();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
}
})();
},
};
if (variant === "success") {
toast.success(title, { description, action, duration: durationMs });
} else {
toast.message(title, { description, action, duration: durationMs });
}
}
/** Convenience for a single skip with known previous status (when action result undo is unavailable). */
export function toastSkipUndo(input: {
jobId: string;
previousStatus: JobStatus;
title?: string;
description?: string;
alsoSkipped?: StatusRestore[];
onRestored?: () => void | Promise<void>;
}): void {
const restores: StatusRestore[] = [
{ jobId: input.jobId, status: input.previousStatus },
...(input.alsoSkipped ?? []),
];
toastWithUndo({
title: input.title ?? "Job skipped",
description: input.description,
restores,
variant: "message",
onRestored: input.onRestored,
successMessage: "Skip undone",
});
}
export function toastAppliedUndo(input: {
jobId: string;
title?: string;
description?: string;
onRestored?: () => void | Promise<void>;
}): void {
toastWithUndo({
title: input.title ?? "Marked as applied",
description: input.description,
restores: [{ jobId: input.jobId, status: "ready" }],
variant: "success",
onRestored: input.onRestored,
successMessage: "Reverted to Ready",
});
}
export function toastMoveToReadyUndo(input: {
jobId: string;
title?: string;
description?: string;
onRestored?: () => void | Promise<void>;
}): void {
toastWithUndo({
title: input.title ?? "Job moved to Ready",
description: input.description,
restores: [{ jobId: input.jobId, status: "discovered" }],
variant: "success",
onRestored: input.onRestored,
successMessage: "Moved back to Discovered",
});
}