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

387 lines
11 KiB
TypeScript

"use client";
import { useEffect, useLayoutEffect, type ReactNode, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
import { useSubtaskCountState, type SubtaskCountResult } from "@/hooks/use-subtask-count";
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
import { getCleanupSummary, type CleanupSummary } from "./task-cleanup-summary";
import { TaskCleanupConsequences } from "./task-cleanup-consequences";
import { StillWorkingWarning } from "./task-still-working-warning";
import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog";
type ArchiveConfirmValues = { cascade: boolean };
const ARCHIVE_LABEL_KEY = "task:archive";
const ARCHIVE_INLINE_CONFIRMATION_TEST_ID = "task-archive-inline-confirmation";
const DEFAULT_CONFIRM_TEST_ID = "archive-task-confirm";
export type TaskArchiveConfirmationProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
taskTitle?: string;
taskId?: string;
taskIds?: string[];
isBulkOperation?: boolean;
count?: number;
isArchiving?: boolean;
isInFlight?: boolean;
executorType?: string | null;
executorTypes?: Array<string | null | undefined>;
anchorRef: RefObject<HTMLElement | null>;
focusReturnRef?: RefObject<HTMLElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
restoreFocusOnConfirm?: boolean;
onConfirm: (values: ArchiveConfirmValues) => void | Promise<void>;
confirmTestId?: string;
/** Render a simple confirmation inside an existing action surface. */
inline?: boolean;
/** Wrap only simple inline choices; complex confirmations replace the surface. */
renderInline?: (content: ReactNode) => ReactNode;
/** Use the contained dialog even when classification resolves without descendants. */
forceDialog?: boolean;
};
function ArchiveDescription({
taskTitle,
cleanup,
taskIsInFlight,
}: {
taskTitle?: string;
cleanup: CleanupSummary;
taskIsInFlight: boolean;
}) {
const { t } = useTranslation();
return (
<span className="block space-y-2">
<span data-testid="task-confirmation-outcome" className="block">
{t("task:archiveTaskConfirm", { taskTitle })}
</span>
<TaskCleanupConsequences summary={cleanup} compact />
{taskIsInFlight && <StillWorkingWarning />}
</span>
);
}
function ArchiveConfirmCopy({
taskTitle,
executorType,
isInFlight,
isArchiving,
onCancel,
onClose,
onConfirm,
confirmTestId,
}: {
taskTitle?: string;
executorType?: string | null;
isInFlight: boolean;
isArchiving?: boolean;
onCancel: () => void;
onClose?: () => void;
onConfirm: () => void | Promise<void>;
confirmTestId: string;
}) {
const { t } = useTranslation();
const cleanup = getCleanupSummary(executorType);
const description = (
<ArchiveDescription taskTitle={taskTitle} cleanup={cleanup} taskIsInFlight={isInFlight} />
);
return (
<InlineConfirmActions
density="touch"
disabled={isArchiving}
testId={ARCHIVE_INLINE_CONFIRMATION_TEST_ID}
ariaLabel={t("task:archiveTaskTitle")}
description={description}
cancelLabel={t("common:cancel")}
confirmLabel={t(ARCHIVE_LABEL_KEY)}
confirmAriaLabel={t(ARCHIVE_LABEL_KEY)}
confirmTestId={confirmTestId}
onCancel={onCancel}
onClose={onClose}
onConfirm={onConfirm}
/>
);
}
function ArchiveConfirmPopover({
open,
anchorRef,
focusReturnRef,
focusBoundaryRef,
restoreFocusOnConfirm = false,
taskTitle,
executorType,
isInFlight,
isArchiving,
onOpenChange,
onConfirm,
confirmTestId,
}: {
open: boolean;
anchorRef: RefObject<HTMLElement | null>;
focusReturnRef?: RefObject<HTMLElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
restoreFocusOnConfirm?: boolean;
taskTitle?: string;
executorType?: string | null;
isInFlight: boolean;
isArchiving?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void | Promise<void>;
confirmTestId: string;
}) {
const { t } = useTranslation();
const cleanup = getCleanupSummary(executorType);
return (
<ActionConfirmPopover
open={open}
size="wide"
disabled={isArchiving}
anchorRef={anchorRef}
focusReturnRef={focusReturnRef}
focusBoundaryRef={focusBoundaryRef ?? anchorRef}
restoreFocusOnConfirm={restoreFocusOnConfirm}
title={t("task:archiveTaskTitle")}
description={
<ArchiveDescription taskTitle={taskTitle} cleanup={cleanup} taskIsInFlight={isInFlight} />
}
cancelLabel={t("common:cancel")}
confirmLabel={t(ARCHIVE_LABEL_KEY)}
confirmAriaLabel={t(ARCHIVE_LABEL_KEY)}
confirmTestId={confirmTestId}
testId="task-archive-confirm-popover"
onOpenChange={onOpenChange}
onCancel={() => onOpenChange(false)}
onConfirm={onConfirm}
/>
);
}
function PendingArchiveDismissal({
anchorRef,
focusReturnRef,
onOpenChange,
}: {
anchorRef: RefObject<HTMLElement | null>;
focusReturnRef?: RefObject<HTMLElement | null>;
onOpenChange: (open: boolean) => void;
}) {
useLayoutEffect(() => {
if (anchorRef.current?.isConnected) return;
onOpenChange(false);
});
useEffect(() => {
const dismiss = () => onOpenChange(false);
const handlePointerDown = () => dismiss();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
const focusTarget = focusReturnRef?.current ?? anchorRef.current;
dismiss();
if (focusTarget?.isConnected) focusTarget.focus();
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [anchorRef, focusReturnRef, onOpenChange]);
return null;
}
type ArchiveDialogProps = Pick<
TaskArchiveConfirmationProps,
| "onOpenChange"
| "focusReturnRef"
| "taskTitle"
| "isBulkOperation"
| "count"
| "isArchiving"
| "taskId"
| "taskIds"
| "isInFlight"
| "executorType"
| "executorTypes"
| "onConfirm"
| "confirmTestId"
| "restoreFocusOnConfirm"
> & {
subtaskClassification: SubtaskCountResult;
};
function ArchiveDialog({ subtaskClassification, ...props }: ArchiveDialogProps) {
return <TaskArchiveConfirmDialog open {...props} subtaskClassification={subtaskClassification} />;
}
type ArchiveConfirmationContentProps = ArchiveDialogProps & {
confirmTaskArchive: boolean;
forceDialog: boolean;
isFinePointer: boolean;
isMobile: boolean;
taskIsInFlight: boolean;
anchorRef: RefObject<HTMLElement | null>;
focusReturnRef?: RefObject<HTMLElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
inline: boolean;
renderInline?: (content: ReactNode) => ReactNode;
};
function ArchiveConfirmationContent({
confirmTaskArchive,
forceDialog,
isFinePointer,
isMobile,
taskIsInFlight,
anchorRef,
focusReturnRef,
focusBoundaryRef,
inline,
renderInline = (content) => content,
subtaskClassification,
...dialogProps
}: ArchiveConfirmationContentProps) {
const { t } = useTranslation();
const shouldUseDialog =
isMobile ||
forceDialog ||
!confirmTaskArchive ||
dialogProps.isBulkOperation ||
subtaskClassification.status === "error" ||
subtaskClassification.total > 0;
if (shouldUseDialog) {
return (
<ArchiveDialog
{...dialogProps}
focusReturnRef={focusReturnRef}
subtaskClassification={subtaskClassification}
/>
);
}
if (subtaskClassification.status !== "resolved") {
if (inline) {
return renderInline(
<span data-testid="task-archive-classifying" className="text-xs text-muted-foreground">
{t("common:loading")}
</span>,
);
}
if (!isFinePointer) return null;
return (
<PendingArchiveDismissal
anchorRef={anchorRef}
focusReturnRef={focusReturnRef}
onOpenChange={dialogProps.onOpenChange}
/>
);
}
const confirm = () => dialogProps.onConfirm({ cascade: false });
if (inline || !isFinePointer) {
return renderInline(
<ArchiveConfirmCopy
taskTitle={dialogProps.taskTitle}
executorType={dialogProps.executorType}
isInFlight={taskIsInFlight}
isArchiving={dialogProps.isArchiving}
onCancel={() => dialogProps.onOpenChange(false)}
onClose={() => dialogProps.onOpenChange(false)}
onConfirm={confirm}
confirmTestId={dialogProps.confirmTestId ?? DEFAULT_CONFIRM_TEST_ID}
/>,
);
}
return (
<ArchiveConfirmPopover
open
anchorRef={anchorRef}
focusReturnRef={focusReturnRef}
focusBoundaryRef={focusBoundaryRef}
restoreFocusOnConfirm={dialogProps.restoreFocusOnConfirm}
taskTitle={dialogProps.taskTitle}
executorType={dialogProps.executorType}
isInFlight={taskIsInFlight}
isArchiving={dialogProps.isArchiving}
onOpenChange={dialogProps.onOpenChange}
onConfirm={confirm}
confirmTestId={dialogProps.confirmTestId ?? DEFAULT_CONFIRM_TEST_ID}
/>
);
}
export function TaskArchiveConfirmation({
open,
onOpenChange,
taskTitle,
taskId,
taskIds,
isBulkOperation = false,
count,
isArchiving,
isInFlight,
executorType,
executorTypes,
anchorRef,
focusReturnRef,
focusBoundaryRef,
restoreFocusOnConfirm,
onConfirm,
confirmTestId = DEFAULT_CONFIRM_TEST_ID,
inline = false,
renderInline,
forceDialog = false,
}: TaskArchiveConfirmationProps) {
const { isFinePointer, isMobile, changed } = useConfirmationBoundary(
open,
taskId ?? taskIds?.join(",") ?? "archive",
onOpenChange,
);
const confirmTaskArchive = useAppStore((state) => state.userSettings?.confirmTaskArchive ?? true);
const classification = useSubtaskCountState(open && confirmTaskArchive, taskId, taskIds);
const storeInFlight = useTaskInFlight(taskId, taskIds, open && confirmTaskArchive);
const taskIsInFlight = Boolean(isInFlight) || storeInFlight;
if (!open || changed) return null;
return (
<ArchiveConfirmationContent
confirmTaskArchive={confirmTaskArchive}
forceDialog={forceDialog}
isFinePointer={isFinePointer}
isMobile={isMobile}
taskIsInFlight={taskIsInFlight}
anchorRef={anchorRef}
focusReturnRef={focusReturnRef}
restoreFocusOnConfirm={restoreFocusOnConfirm}
focusBoundaryRef={focusBoundaryRef}
inline={inline}
renderInline={renderInline}
onOpenChange={onOpenChange}
taskTitle={taskTitle}
isBulkOperation={isBulkOperation}
count={count}
isArchiving={isArchiving}
taskId={taskId}
taskIds={taskIds}
isInFlight={isInFlight}
executorType={executorType}
executorTypes={executorTypes}
onConfirm={onConfirm}
confirmTestId={confirmTestId}
subtaskClassification={classification}
/>
);
}