Files
kandev/apps/web/components/task/task-archive-confirm-dialog.tsx
T
2026-09-12 14:58:49 +02:00

311 lines
9.9 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { IconLoader } from "@tabler/icons-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { Checkbox } from "@kandev/ui/checkbox";
import { useAppStore } from "@/components/state-provider";
import { useSubtaskCountState, type SubtaskCountResult } from "@/hooks/use-subtask-count";
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary";
import { TaskCleanupConsequences } from "./task-cleanup-consequences";
import { StillWorkingWarning } from "./task-still-working-warning";
import {
TASK_CONFIRM_ACTION_CLASS,
TASK_CONFIRM_BODY_CLASS,
TASK_CONFIRM_CLASS,
TASK_CONFIRM_FOOTER_CLASS,
TASK_CONFIRM_HEADER_CLASS,
stopDialogPropagation,
} from "./task-confirm-dialog-shared";
import { useTranslation } from "react-i18next";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
type TaskArchiveConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Element to return keyboard focus to on close, confirmed or cancelled
* (AC-TASKS-TASK-ACTIONS-MENU-001.12). Omitted callers keep Radix's
* default restore-to-previously-focused-element behavior. */
focusReturnRef?: RefObject<HTMLElement | null>;
/** Restore focus after confirmation when the task surface remains mounted. */
restoreFocusOnConfirm?: boolean;
taskTitle?: string;
isBulkOperation?: boolean;
count?: number;
isArchiving?: boolean;
taskId?: string;
taskIds?: string[];
isInFlight?: boolean;
/** Executor type of the task being archived (single). */
executorType?: string | null;
/** Executor types of the tasks being archived (bulk). */
executorTypes?: Array<string | null | undefined>;
onConfirm: (opts: { cascade: boolean }) => void | Promise<void>;
confirmTestId?: string;
/** Preflight result supplied by the local confirmation adapter. */
subtaskClassification?: SubtaskCountResult;
};
type ArchiveOpenMode = "pending" | "confirm" | "bypass";
function shouldRestoreFocus(confirmed: boolean, restoreFocusOnConfirm?: boolean): boolean {
return !confirmed || restoreFocusOnConfirm === true;
}
function useArchiveConfirmationMode(
open: boolean,
confirmTaskArchive: boolean,
onConfirm: TaskArchiveConfirmDialogProps["onConfirm"],
onOpenChange: TaskArchiveConfirmDialogProps["onOpenChange"],
) {
const wasOpenRef = useRef(false);
const [archiveOpenMode, setArchiveOpenMode] = useState<ArchiveOpenMode>("pending");
useEffect(() => {
const openedNow = open && !wasOpenRef.current;
wasOpenRef.current = open;
if (!open) {
setArchiveOpenMode("pending");
return;
}
if (!openedNow) return;
if (confirmTaskArchive) {
setArchiveOpenMode("confirm");
return;
}
setArchiveOpenMode("bypass");
onConfirm({ cascade: false });
onOpenChange(false);
}, [confirmTaskArchive, onConfirm, onOpenChange, open]);
return archiveOpenMode === "confirm" || (archiveOpenMode === "pending" && confirmTaskArchive);
}
function shouldCheckTaskInFlight(open: boolean, requiresConfirmation: boolean): boolean {
return open && requiresConfirmation;
}
function computeTaskIsInFlight(isInFlight: boolean | undefined, storeInFlight: boolean): boolean {
return Boolean(isInFlight) || storeInFlight;
}
function isArchiveActionDisabled(
isArchiving: boolean | undefined,
classification: SubtaskCountResult,
): boolean {
return (
Boolean(isArchiving) || classification.status === "idle" || classification.status === "loading"
);
}
function archiveCleanup({
isBulkOperation,
executorTypes,
executorType,
}: Pick<TaskArchiveConfirmDialogProps, "isBulkOperation" | "executorTypes" | "executorType">) {
return isBulkOperation
? getBulkCleanupSummary(executorTypes ?? [])
: getCleanupSummary(executorType);
}
function ArchiveOptions({
inFlight,
bulkCount,
subtaskCount,
cascade,
setCascade,
disabled,
}: {
inFlight: boolean;
bulkCount?: number;
subtaskCount: number;
cascade: boolean;
setCascade: (value: boolean) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<>
{inFlight && <StillWorkingWarning count={bulkCount} />}
{subtaskCount > 0 && (
<label className="flex min-h-11 cursor-pointer items-start gap-2 text-sm md:min-h-0">
<Checkbox
checked={cascade}
onCheckedChange={(v) => setCascade(v === true)}
disabled={disabled}
data-testid="archive-cascade-checkbox"
/>
<span>
{t("task:alsoArchiveSubtasks", { count: subtaskCount })}
<span className="block text-sm text-muted-foreground">
{t("task:subtasksStayActiveUnlessYouTick")}
</span>
</span>
</label>
)}
</>
);
}
// The legacy cascade dialog intentionally keeps its state, preference bypass,
// and cleanup copy in one boundary.
// eslint-disable-next-line max-lines-per-function
export function TaskArchiveConfirmDialog({
open,
onOpenChange,
focusReturnRef,
restoreFocusOnConfirm,
taskTitle,
isBulkOperation,
count,
isArchiving,
taskId,
taskIds,
isInFlight,
executorType,
executorTypes,
onConfirm,
confirmTestId,
subtaskClassification,
}: TaskArchiveConfirmDialogProps) {
const { t } = useTranslation();
const confirmTaskArchive = useAppStore((state) => state.userSettings?.confirmTaskArchive ?? true);
const safeCount = count ?? 0;
const title = isBulkOperation
? t("task:archiveTasksTitle", { count: safeCount })
: t("task:archiveTaskTitle");
const firstLine = isBulkOperation
? t("task:archiveTasksConfirm", { count: safeCount })
: t("task:archiveTaskConfirm", { taskTitle });
const cleanup = archiveCleanup({ isBulkOperation, executorTypes, executorType });
const [cascade, setCascade] = useState(false);
const confirmedRef = useRef(false);
const restoreFocus = () => {
if (!shouldRestoreFocus(confirmedRef.current, restoreFocusOnConfirm)) return;
const focusReturnTarget = focusReturnRef?.current;
if (focusReturnTarget?.isConnected) focusReturnTarget.focus();
};
const requiresConfirmation = useArchiveConfirmationMode(
open,
confirmTaskArchive,
onConfirm,
onOpenChange,
);
const fetchedSubtaskClassification = useSubtaskCountState(
open && requiresConfirmation && !subtaskClassification,
taskId,
taskIds,
);
const classification = subtaskClassification ?? fetchedSubtaskClassification;
const archiveDisabled = isArchiveActionDisabled(isArchiving, classification);
const subtaskCount = classification.status === "resolved" ? classification.total : 0;
const shouldCheckInFlight = shouldCheckTaskInFlight(open, requiresConfirmation);
const storeInFlight = useTaskInFlight(taskId, taskIds, shouldCheckInFlight);
const taskIsInFlight = computeTaskIsInFlight(isInFlight, storeInFlight);
const handleOpenChange = (next: boolean) => {
if (!next) {
setCascade(false);
restoreFocus();
}
onOpenChange(next);
};
if (!requiresConfirmation) return null;
const description = (
<div className="space-y-3">
<p data-testid="task-confirmation-outcome">{firstLine}</p>
<TaskCleanupConsequences summary={cleanup} />
</div>
);
const options = (
<ArchiveOptions
inFlight={taskIsInFlight}
bulkCount={isBulkOperation ? safeCount : undefined}
subtaskCount={subtaskCount}
cascade={cascade}
setCascade={setCascade}
disabled={isArchiving}
/>
);
const desktop = (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent
size="lg"
className={TASK_CONFIRM_CLASS}
onClick={stopDialogPropagation}
onCloseAutoFocus={(event) => {
event.preventDefault();
restoreFocus();
confirmedRef.current = false;
}}
>
<AlertDialogHeader className={TASK_CONFIRM_HEADER_CLASS}>
<AlertDialogTitle className="text-base font-semibold">{title}</AlertDialogTitle>
</AlertDialogHeader>
<div data-testid="task-confirmation-body" className={TASK_CONFIRM_BODY_CLASS}>
<AlertDialogDescription asChild className="text-left text-sm leading-6">
{description}
</AlertDialogDescription>
{options}
</div>
<AlertDialogFooter className={TASK_CONFIRM_FOOTER_CLASS}>
<AlertDialogCancel className={TASK_CONFIRM_ACTION_CLASS}>
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="default"
disabled={archiveDisabled}
className={TASK_CONFIRM_ACTION_CLASS}
data-testid={confirmTestId}
onClick={() => {
if (archiveDisabled) return;
confirmedRef.current = true;
onConfirm({ cascade });
handleOpenChange(false);
}}
>
{isArchiving ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("task:archive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
return (
<MobileActionConfirmation
open={open}
targetKey={taskId ?? taskIds?.join(",") ?? "archive"}
onOpenChange={handleOpenChange}
focusReturnRef={focusReturnRef}
title={title}
description={description}
variant="default"
cancelLabel={t("common:cancel")}
confirmLabel={t("task:archive")}
confirmTestId={confirmTestId}
confirmDisabled={archiveDisabled}
onConfirm={() => onConfirm({ cascade })}
fallback={desktop}
>
{classification.status === "loading" && <p role="status">{t("common:loading")}</p>}
{options}
</MobileActionConfirmation>
);
}