387 lines
11 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|