"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; 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(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 ( Reset {integrationLabel}? {description} Cancel { // 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"} ); } // 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(opts: { preview: (watch: TWatch) => Promise<{ taskCount: number }>; reset: (watch: TWatch) => Promise; }) { const [resetting, setResetting] = useState(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}`; }