Accidental skips and status changes can be reversed within a few seconds via toast Undo, including bulk actions and keyboard shortcuts.
147 lines
3.9 KiB
TypeScript
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",
|
|
});
|
|
}
|