Files
kandev/apps/web/components/task/task-move-context-menu.tsx
T
2026-09-25 10:30:33 +01:00

620 lines
18 KiB
TypeScript

"use client";
import { useRef, useState, type ReactNode } from "react";
import { IconAdjustments, IconArrowRight, IconLogicBuffer } from "@tabler/icons-react";
import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
} from "@kandev/ui/context-menu";
import { TaskContextMenuSubContent as ContextMenuSubContent } from "./task-context-menu-sub-content";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { useWorkflowMove } from "@/hooks/domains/kanban/use-workflow-move";
import { useToast } from "@/components/toast-provider";
import {
WorkflowMoveDialog,
WorkflowMoveOptions,
WorkflowMoveOptionsForm,
type WorkflowMoveOptionsSubmit,
} from "./workflow-move-options";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import type { WorkflowMoveEntryOptions, WorkflowMoveResponse } from "@/lib/api/domains/kanban-api";
import type { WorkflowStepProgress } from "@/hooks/domains/kanban/use-workflow-step-progress";
import { StepProgressDetails } from "./workflow-step-progress-details";
export type TaskMoveStep = {
id: string;
title: string;
task_id?: string | null;
color?: string | null;
workflow_id?: string | null;
agent_profile_id?: string | null;
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
};
export type TaskMoveWorkflow = {
id: string;
name: string;
hidden?: boolean;
};
export function useTaskMoveOptions({
taskId,
workflowId,
steps,
closeMenu,
onMoveCommitted,
}: {
taskId: string;
workflowId?: string | null;
steps?: TaskMoveStep[];
closeMenu?: () => void;
onMoveCommitted?: (response: WorkflowMoveResponse) => void;
}) {
const [moveOptionsStep, setMoveOptionsStep] = useState<TaskMoveStep | null>(null);
const { move, isMoving } = useWorkflowMove();
const { toast } = useToast();
const { t } = useTranslation();
const openMoveOptionsStep = (targetStep: TaskMoveStep) => {
setMoveOptionsStep({ ...targetStep, task_id: targetStep.task_id ?? taskId });
};
const openMoveOptions = (targetStepId: string) => {
const targetStep = steps?.find((step) => step.id === targetStepId);
if (!targetStep) return;
openMoveOptionsStep({ ...targetStep, workflow_id: workflowId ?? undefined });
};
const runMove = async (
targetStepId: string,
entryOptions: WorkflowMoveEntryOptions | undefined,
targetWorkflowId = workflowId,
) => {
if (!targetWorkflowId) return false;
const result = await move(taskId, {
workflow_id: targetWorkflowId,
workflow_step_id: targetStepId,
position: 0,
entry_options: entryOptions,
});
if (result.disposition === "failed") {
const error = result.error;
toast({
title: t("task:failedToMoveTask"),
description: error instanceof Error ? error.message : t("task:failedToMoveTask"),
variant: "error",
});
return false;
}
onMoveCommitted?.(result.response);
closeMenu?.();
return true;
};
const submitMoveOptions = async (entryOptions: WorkflowMoveEntryOptions | undefined) => {
if (!moveOptionsStep) return false;
const ok = await runMove(
moveOptionsStep.id,
entryOptions,
moveOptionsStep.workflow_id ?? workflowId,
);
if (ok) setMoveOptionsStep(null);
return ok;
};
// Move a specific step directly, used by the fine-pointer inline options form
// rendered in the "Move to" submenu (no intermediate dialog/drawer surface).
const submitMoveOptionsForStep = (
targetStepId: string,
entryOptions: WorkflowMoveEntryOptions | undefined,
) => runMove(targetStepId, entryOptions);
return {
moveOptionsStep,
isMoving,
openMoveOptions,
openMoveOptionsStep,
submitMoveOptions,
submitMoveOptionsForStep,
moveImmediately: (step: TaskMoveStep) =>
runMove(step.id, undefined, step.workflow_id ?? workflowId),
closeMoveOptions: () => {
setMoveOptionsStep(null);
},
};
}
export function TaskMoveOptionsSurface({
step,
isMoving,
onClose,
onSubmit,
autoFocusInstructions,
}: {
autoFocusInstructions?: boolean;
step: TaskMoveStep | null;
isMoving: boolean;
onClose: () => void;
onSubmit: WorkflowMoveOptionsSubmit;
}) {
const usesTouchDrawer = useTouchDrawer();
if (!step) return null;
const optionsProps = {
open: true,
onOpenChange: (open: boolean) => {
if (!open) onClose();
},
targetStepName: step.title,
isMoving,
onSubmit,
autoFocusInstructions,
};
const Options = usesTouchDrawer ? WorkflowMoveOptions : WorkflowMoveDialog;
return <Options {...optionsProps} />;
}
type TaskMoveContextMenuItemsProps = {
currentWorkflowId?: string | null;
currentStepId?: string | null;
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
disabled?: boolean;
showSeparator?: boolean;
onMoveToStep?: (stepId: string) => void;
onMoveToStepWithOptions?: (stepId: string) => void;
onSubmitWithOptions?: (
stepId: string,
entryOptions: WorkflowMoveEntryOptions | undefined,
) => Promise<boolean>;
isMoving?: boolean;
isBulkSelection?: boolean;
progressByStepId?: Readonly<Record<string, WorkflowStepProgress>>;
agentLabelsByProfileId?: Readonly<Record<string, string>>;
onChangeWorkflow?: () => void;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
};
export function stepHasAutoStart(step: TaskMoveStep) {
return step.events?.on_enter?.some((action) => action.type === "auto_start_agent") ?? false;
}
export function taskMoveOptions(
currentWorkflowId: string | null | undefined,
workflows: TaskMoveWorkflow[],
stepsByWorkflowId: Record<string, TaskMoveStep[]>,
) {
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
const targets = currentWorkflowId
? workflows.filter((workflow) => !workflow.hidden && workflow.id !== currentWorkflowId)
: [];
return { currentSteps, targets, canMove: currentSteps.length > 1, canSend: targets.length > 0 };
}
function StepMenuLabel({
step,
isCurrent,
testIdPrefix,
}: {
step: TaskMoveStep;
isCurrent: boolean;
testIdPrefix: string;
}) {
const { t } = useTranslation();
const hasAutoStart = stepHasAutoStart(step);
return (
<>
<span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} />
<span className="flex-1 truncate">{step.title}</span>
{(isCurrent || hasAutoStart) && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
{isCurrent && (
<span data-testid={`${testIdPrefix}-current-${step.id}`}>{t("task:current2")}</span>
)}
{hasAutoStart && (
<span data-testid={`${testIdPrefix}-autostart-${step.id}`}>{t("task:autoStart")}</span>
)}
</span>
)}
</>
);
}
function StepMenuItem({
step,
currentStepId,
onSelect,
onSelectWithOptions,
onSubmitWithOptions,
isMoving,
progress,
agentLabelsByProfileId,
testIdPrefix = "task-context-step",
}: {
step: TaskMoveStep;
currentStepId?: string | null;
onSelect: (stepId: string) => void;
onSelectWithOptions?: (stepId: string) => void;
onSubmitWithOptions?: (
stepId: string,
entryOptions: WorkflowMoveEntryOptions | undefined,
) => Promise<boolean>;
isMoving?: boolean;
progress?: WorkflowStepProgress;
agentLabelsByProfileId?: Readonly<Record<string, string>>;
testIdPrefix?: string;
}) {
const isCurrent = step.id === currentStepId;
const content = (
<StepMenuContent
step={step}
isCurrent={isCurrent}
progress={progress}
agentLabelsByProfileId={agentLabelsByProfileId}
testIdPrefix={testIdPrefix}
/>
);
if (!onSelectWithOptions && !onSubmitWithOptions) {
return (
<ContextMenuItem
className="[@media(pointer:coarse)]:min-h-11"
data-testid={`${testIdPrefix}-${step.id}`}
disabled={isCurrent}
onSelect={(event) => {
event.preventDefault();
if (!isCurrent) onSelect(step.id);
}}
>
{content}
</ContextMenuItem>
);
}
return (
<StepMenuSubItem
step={step}
isCurrent={isCurrent}
onSelect={onSelect}
onSelectWithOptions={onSelectWithOptions}
onSubmitWithOptions={onSubmitWithOptions}
isMoving={isMoving}
content={content}
testIdPrefix={testIdPrefix}
/>
);
}
function StepMenuSubItem({
step,
isCurrent,
onSelect,
onSelectWithOptions,
onSubmitWithOptions,
isMoving,
content,
testIdPrefix,
}: {
step: TaskMoveStep;
isCurrent: boolean;
onSelect: (stepId: string) => void;
onSelectWithOptions?: (stepId: string) => void;
onSubmitWithOptions?: (
stepId: string,
entryOptions: WorkflowMoveEntryOptions | undefined,
) => Promise<boolean>;
isMoving?: boolean;
content: ReactNode;
testIdPrefix: string;
}) {
const { t } = useTranslation();
const pointerTypeRef = useRef<string | null>(null);
return (
<ContextMenuSub>
<ContextMenuSubTrigger
className="[@media(pointer:coarse)]:min-h-11"
data-testid={`${testIdPrefix}-${step.id}`}
disabled={isCurrent}
onPointerDown={(event) => {
pointerTypeRef.current = event.pointerType;
}}
onPointerCancel={() => {
pointerTypeRef.current = null;
}}
onClick={(event) => {
const pointerType = pointerTypeRef.current;
pointerTypeRef.current = null;
const rect = event.currentTarget.getBoundingClientRect();
const tappedChevron =
(pointerType === "touch" || pointerType === "pen") &&
rect.width > 0 &&
event.clientX >= rect.right - 32;
if (!tappedChevron) {
event.preventDefault();
if (!isCurrent) onSelect(step.id);
}
}}
onKeyDown={(event) => {
if (!isCurrent && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect(step.id);
}
}}
>
{content}
</ContextMenuSubTrigger>
<ContextMenuSubContent className={onSubmitWithOptions ? "w-72" : "w-48"}>
{onSubmitWithOptions ? (
// Interactive fields live inside a Radix menu popup. Stop key events at
// this wrapper (bubble phase, after the field handled them) so the
// menu's typeahead never yanks focus off the instructions field or the
// agent-profile combobox search while the user is typing.
<div className="p-2" onKeyDown={(event) => event.stopPropagation()}>
<WorkflowMoveOptionsForm
isMoving={isMoving ?? false}
isTouchSurface={false}
instructionsRows={3}
onSubmit={(entryOptions) => onSubmitWithOptions(step.id, entryOptions)}
/>
</div>
) : (
<ContextMenuItem
className="[@media(pointer:coarse)]:min-h-11"
data-testid={`task-context-step-options-${step.id}`}
onSelect={(event) => {
event.preventDefault();
onSelectWithOptions?.(step.id);
}}
>
<IconAdjustments className="mr-2 h-4 w-4" />
{t("task:moveWithOptions")}
</ContextMenuItem>
)}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function StepMenuContent({
step,
isCurrent,
progress,
agentLabelsByProfileId,
testIdPrefix,
}: {
step: TaskMoveStep;
isCurrent: boolean;
progress?: WorkflowStepProgress;
agentLabelsByProfileId?: Readonly<Record<string, string>>;
testIdPrefix: string;
}) {
return (
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-2">
<StepMenuLabel step={step} isCurrent={isCurrent} testIdPrefix={testIdPrefix} />
</span>
{progress && (
<StepProgressDetails
progress={progress}
agentProfileId={step.agent_profile_id ?? undefined}
agentLabelsByProfileId={agentLabelsByProfileId}
testId={`${testIdPrefix}-progress-${step.id}`}
/>
)}
</span>
);
}
function MoveToCurrentWorkflowSubmenu({
steps,
currentStepId,
disabled,
onMoveToStep,
onMoveToStepWithOptions,
onSubmitWithOptions,
isMoving,
progressByStepId,
agentLabelsByProfileId,
}: {
steps: TaskMoveStep[];
currentStepId?: string | null;
disabled?: boolean;
onMoveToStep?: (stepId: string) => void;
onMoveToStepWithOptions?: (stepId: string) => void;
onSubmitWithOptions?: (
stepId: string,
entryOptions: WorkflowMoveEntryOptions | undefined,
) => Promise<boolean>;
isMoving?: boolean;
progressByStepId?: Readonly<Record<string, WorkflowStepProgress>>;
agentLabelsByProfileId?: Readonly<Record<string, string>>;
}) {
const { t } = useTranslation();
if (!onMoveToStep || steps.length <= 1) return null;
return (
<ContextMenuSub>
<ContextMenuSubTrigger
className="[@media(pointer:coarse)]:min-h-11"
data-testid="task-context-move-to"
disabled={disabled}
>
<IconArrowRight className="mr-2 h-4 w-4" />
{t("task:moveTo")}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
{steps.map((step) => (
<StepMenuItem
key={step.id}
step={step}
currentStepId={currentStepId}
onSelect={onMoveToStep}
onSelectWithOptions={onMoveToStepWithOptions}
onSubmitWithOptions={onSubmitWithOptions}
isMoving={isMoving}
progress={progressByStepId?.[step.id]}
agentLabelsByProfileId={agentLabelsByProfileId}
/>
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function WorkflowTargetItem({
workflow,
steps,
disabled,
onSendToWorkflow,
}: {
workflow: TaskMoveWorkflow;
steps: TaskMoveStep[];
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}) {
const { t } = useTranslation();
if (steps.length === 0 || !onSendToWorkflow) {
return (
<ContextMenuItem
data-testid={`task-context-workflow-${workflow.id}`}
disabled
aria-disabled="true"
>
<span className="flex-1 truncate">{workflow.name}</span>
<span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]">
{t("task:noSteps")}
</span>
</ContextMenuItem>
);
}
return (
<ContextMenuSub>
<ContextMenuSubTrigger
className="[@media(pointer:coarse)]:min-h-11"
data-testid={`task-context-workflow-${workflow.id}`}
disabled={disabled}
>
<span className="truncate">{workflow.name}</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
{steps.map((step) => (
<StepMenuItem
key={step.id}
step={step}
onSelect={(stepId) => onSendToWorkflow(workflow.id, stepId)}
/>
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function SendToWorkflowSubmenu({
workflows,
stepsByWorkflowId,
disabled,
onSendToWorkflow,
}: {
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}) {
const { t } = useTranslation();
if (!onSendToWorkflow || workflows.length === 0) return null;
return (
<ContextMenuSub>
<ContextMenuSubTrigger
className="[@media(pointer:coarse)]:min-h-11"
data-testid="task-context-change-workflow-selection"
disabled={disabled}
>
<IconLogicBuffer className="mr-2 h-4 w-4" />
{t("task:changeWorkflowForSelectedTasks")}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-56">
{workflows.map((workflow) => (
<WorkflowTargetItem
key={workflow.id}
workflow={workflow}
steps={stepsByWorkflowId[workflow.id] ?? []}
disabled={disabled}
onSendToWorkflow={onSendToWorkflow}
/>
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
export function TaskMoveContextMenuItems({
currentWorkflowId,
currentStepId,
workflows,
stepsByWorkflowId,
disabled,
showSeparator = true,
onMoveToStep,
onMoveToStepWithOptions,
onSubmitWithOptions,
isMoving,
isBulkSelection = false,
progressByStepId,
agentLabelsByProfileId,
onChangeWorkflow,
onSendToWorkflow,
}: TaskMoveContextMenuItemsProps) {
const { t } = useTranslation();
const { currentSteps, targets, canMove, canSend } = taskMoveOptions(
currentWorkflowId,
workflows,
stepsByWorkflowId,
);
const hasSameWorkflowMove = Boolean(
(onMoveToStep || onMoveToStepWithOptions || onSubmitWithOptions) && canMove,
);
const hasCrossWorkflowMove = Boolean(
canSend && (isBulkSelection ? onSendToWorkflow : onChangeWorkflow),
);
if (!hasSameWorkflowMove && !hasCrossWorkflowMove) return null;
return (
<>
{showSeparator && <ContextMenuSeparator />}
<MoveToCurrentWorkflowSubmenu
steps={currentSteps}
currentStepId={currentStepId}
disabled={disabled}
onMoveToStep={onMoveToStep}
onMoveToStepWithOptions={onMoveToStepWithOptions}
onSubmitWithOptions={onSubmitWithOptions}
isMoving={isMoving}
progressByStepId={progressByStepId}
agentLabelsByProfileId={agentLabelsByProfileId}
/>
{hasCrossWorkflowMove &&
(isBulkSelection ? (
<SendToWorkflowSubmenu
workflows={targets}
stepsByWorkflowId={stepsByWorkflowId}
disabled={disabled}
onSendToWorkflow={onSendToWorkflow}
/>
) : (
<ContextMenuItem
data-testid="task-context-change-workflow"
className="[@media(pointer:coarse)]:min-h-11"
disabled={disabled}
onSelect={onChangeWorkflow}
>
<IconLogicBuffer className="mr-2 h-4 w-4" />
{t("task:changeWorkflow")}
</ContextMenuItem>
))}
</>
);
}