Files
kandev/apps/web/components/watches/reset-watch-dialog.tsx
T
2026-06-15 18:54:16 +01:00

214 lines
7.7 KiB
TypeScript

"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
// previewLoader fetches the count of tasks that would be deleted. The
// dialog calls it once when it opens; the result is cached for the
// remainder of the open session so the user doesn't see the number jump.
type PreviewLoader = () => Promise<{ taskCount: number }>;
// onConfirm executes the destructive reset. Errors are surfaced via the
// parent's toast — the dialog just stays open until onConfirm resolves so
// the user can't double-fire it.
type ConfirmHandler = () => Promise<void>;
type ResetWatchDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
// integrationLabel is shown in the title (e.g. "Jira watcher").
integrationLabel: string;
previewLoader: PreviewLoader;
onConfirm: ConfirmHandler;
};
// ResetWatchDialog is the shared confirmation dialog for the destructive
// "Reset" action on every integration's watch row. It:
// - Loads the preview count on open so the body reads "delete N task(s)"
// - Falls back to a generic description if the preview fails
// - Disables the confirm button while either the preview or the reset
// is in flight, so double-clicks can't fire two resets
//
// All integrations (GitHub issue/review, Jira, Linear, Sentry) reuse this
// component to keep the confirmation copy and behaviour consistent.
export function ResetWatchDialog({
open,
onOpenChange,
integrationLabel,
previewLoader,
onConfirm,
}: ResetWatchDialogProps) {
const [count, setCount] = useState<number | null>(null);
const [previewError, setPreviewError] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!open) {
// Reset state between opens so a previous error or count doesn't
// bleed into the next confirmation.
setCount(null);
setPreviewError(false);
setPreviewLoading(false);
setConfirming(false);
return;
}
let ignore = false;
setPreviewLoading(true);
previewLoader()
.then((res) => {
if (ignore) return;
setCount(res.taskCount);
setPreviewError(false);
})
.catch(() => {
if (ignore) return;
setPreviewError(true);
})
.finally(() => {
if (!ignore) setPreviewLoading(false);
});
return () => {
ignore = true;
};
}, [open, previewLoader]);
const description = renderDescription({ previewLoading, previewError, count });
const confirmDisabled = previewLoading || confirming;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent data-testid="reset-watch-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Reset {integrationLabel}?</AlertDialogTitle>
<AlertDialogDescription data-testid="reset-watch-dialog-description">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer" disabled={confirming}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
data-testid="reset-watch-dialog-confirm"
disabled={confirmDisabled}
onClick={async (e) => {
// Prevent the AlertDialog from auto-closing before onConfirm
// resolves — the parent closes the dialog itself after the
// toast fires so an error keeps the dialog open.
e.preventDefault();
setConfirming(true);
try {
await onConfirm();
} finally {
setConfirming(false);
}
}}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{confirming ? "Resetting…" : "Reset"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// useWatchResetController encapsulates the reset confirmation flow shared
// by every integration's settings section:
// - tracks which watch is awaiting confirmation
// - exposes a stable previewLoader / confirmReset pair for ResetWatchDialog
// - closes the dialog on success and keeps it open on failure so the user
// can retry after seeing the parent's toast
//
// Caller supplies preview(watch) and reset(watch). resetting carries the
// whole watch row so per-row workspaceId stays available even if the
// underlying list re-renders mid-dialog.
//
// opts is captured by ref, NOT included in the callbacks' deps. Call sites
// pass `useWatchResetController({ preview: ..., reset: ... })` with an
// inline object literal, so opts is a fresh reference on every parent
// render. Including it in the deps would re-create previewLoader each
// render, re-firing ResetWatchDialog's `useEffect([open, previewLoader])`
// and re-issuing the preview API call mid-dialog. The ref keeps the
// closure stable while still seeing the latest callbacks.
export function useWatchResetController<TWatch extends { id: string }>(opts: {
preview: (watch: TWatch) => Promise<{ taskCount: number }>;
reset: (watch: TWatch) => Promise<void>;
}) {
const [resetting, setResetting] = useState<TWatch | null>(null);
const optsRef = useRef(opts);
// Sync the ref in a layout effect rather than during render: a render-
// time write trips react-x/no-access-state-in-setstate (React 19 docs)
// as a stale-closure footgun, and a regular useEffect would let the
// confirm-click handler (which fires synchronously after a commit) read
// a previous render's reset() callback. useLayoutEffect runs before the
// browser paints, so optsRef.current is always current for the next
// user interaction.
useLayoutEffect(() => {
optsRef.current = opts;
}, [opts]);
const previewLoader = useCallback(() => {
if (!resetting) {
// useEffect inside ResetWatchDialog only runs when open=true, which
// only happens when resetting != null, so this branch is unreachable
// in practice. The check keeps the closure typed cleanly.
return Promise.resolve({ taskCount: 0 });
}
return optsRef.current.preview(resetting);
}, [resetting]);
const confirmReset = useCallback(async () => {
if (!resetting) return;
try {
await optsRef.current.reset(resetting);
setResetting(null);
} catch {
// Keep dialog open on error so the user sees the toast and can retry.
}
}, [resetting]);
const onOpenChange = useCallback((open: boolean) => {
if (!open) setResetting(null);
}, []);
return { resetting, setResetting, previewLoader, confirmReset, onOpenChange };
}
function renderDescription({
previewLoading,
previewError,
count,
}: {
previewLoading: boolean;
previewError: boolean;
count: number | null;
}): string {
const tail =
" The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.";
if (previewLoading) return "Checking how many tasks would be deleted…";
if (previewError) {
return (
"This will delete every task previously created by the watch, including archived tasks." +
tail
);
}
if (count === 0) {
return (
"No tasks were created by this watch yet — only the polling state will be cleared." + tail
);
}
const plural = count === 1 ? "task" : "tasks";
return `This will delete ${count} ${plural} previously created by the watch, including archived ${plural}.${tail}`;
}