Files
kandev/apps/web/components/task/change-workflow-form-sections.tsx
2026-09-25 10:30:33 +01:00

522 lines
17 KiB
TypeScript

import { IconLoader2, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Combobox } from "@/components/combobox";
import type { ComboboxOption } from "@/components/combobox";
import { WorkflowMovePreviewFooter } from "@/components/task/workflow-move-preview-footer";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { useChangeWorkflow } from "@/hooks/domains/kanban/use-change-workflow";
import type { WorkflowStepDTO } from "@/lib/types/http";
export type ChangeWorkflowState = ReturnType<typeof useChangeWorkflow>;
type TouchProps = { isTouchSurface: boolean };
function RetryButton({
onClick,
label,
testId,
}: {
onClick: () => void;
label: string;
testId?: string;
}) {
return (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9 gap-2"
onClick={onClick}
data-testid={testId}
>
<IconRefresh className="size-3.5" aria-hidden="true" />
{label}
</Button>
);
}
export function ChangeWorkflowCurrentTask({ state }: { state: ChangeWorkflowState }) {
const { t } = useTranslation();
return (
<>
{state.taskStatus === "loading" && (
<div
role="status"
className="flex min-h-9 items-center gap-2 text-sm text-muted-foreground"
>
<IconLoader2 className="size-4 animate-spin" aria-hidden="true" />
{t("task:changeWorkflowLoadingTask")}
</div>
)}
{state.taskStatus === "error" && (
<div
role="alert"
className="grid justify-items-start gap-2 rounded-md border border-destructive/40 p-3 text-sm"
>
<span>{t("task:changeWorkflowLoadTaskFailed")}</span>
<RetryButton onClick={state.retryTask} label={t("task:changeWorkflowRetry")} />
</div>
)}
{state.task && (
<section
aria-label={t("task:changeWorkflowCurrentLabel")}
className="grid gap-1 rounded-md bg-muted/50 p-3"
>
<span className="text-xs font-medium text-muted-foreground">
{t("task:changeWorkflowCurrentLabel")}
</span>
<span className="break-words text-sm font-medium">
{t("task:changeWorkflowCurrentAssignment", {
workflow: state.sourceWorkflowName ?? state.task.workflow_id,
step: state.sourceStepName ?? state.task.workflow_step_id,
})}
</span>
<span className="break-words text-xs text-muted-foreground">{state.task.title}</span>
</section>
)}
{state.sourceChanged && (
<div
role="status"
className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm"
>
{t("task:changeWorkflowSourceChanged")}
</div>
)}
</>
);
}
function stepOptions(steps: readonly WorkflowStepDTO[]): ComboboxOption[] {
return [...steps]
.sort((left, right) => left.position - right.position || left.id.localeCompare(right.id))
.map((step) => ({
value: step.id,
label: step.name,
renderLabel: () => (
<span className="flex min-w-0 items-center gap-2">
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: step.color }} />
<span className="truncate">{step.name}</span>
</span>
),
}));
}
function WorkflowPickerSection({
state,
isTouchSurface,
workflowSelectId,
}: {
state: ChangeWorkflowState;
isTouchSurface: boolean;
workflowSelectId: string;
}) {
const { t } = useTranslation();
const rowClass = cn("grid gap-1.5", isTouchSurface && "gap-2");
const controlClass = isTouchSurface ? "h-11 w-full" : "h-7 w-full";
const workflows: ComboboxOption[] = state.destinations.map((workflow) => ({
value: workflow.id,
label: workflow.name,
}));
return (
<>
{state.workflowsStatus === "loading" && (
<LoadingStatus label={t("task:changeWorkflowLoadingWorkflows")} />
)}
{state.workflowsStatus === "error" && (
<LoadError
label={t("task:changeWorkflowLoadWorkflowsFailed")}
onRetry={state.retryWorkflows}
/>
)}
{state.workflowsStatus === "success" && state.destinations.length === 0 && (
<p role="status" className="text-sm text-muted-foreground">
{t("task:changeWorkflowNoDestinations")}
</p>
)}
{state.workflowsStatus === "success" && state.destinations.length > 0 && (
<div className={rowClass}>
<span className="text-sm font-medium">{t("task:changeWorkflowDestinationLabel")}</span>
<Combobox
options={workflows}
value={state.selectedWorkflowId}
onValueChange={state.changeWorkflow}
ariaLabel={t("task:changeWorkflowDestinationLabel")}
triggerId={workflowSelectId}
placeholder={t("task:changeWorkflowDestinationPlaceholder")}
searchPlaceholder={t("task:changeWorkflowSearchWorkflows")}
emptyMessage={t("task:changeWorkflowNoDestinationFound")}
dropdownLabel={t("task:changeWorkflowDestinationLabel")}
disabled={state.workflowsStatus !== "success" || state.isSubmitting}
touchTarget={isTouchSurface}
triggerClassName={controlClass}
testId="change-workflow-destination"
popoverPortal
/>
</div>
)}
</>
);
}
function StepPickerSection({
state,
isTouchSurface,
stepSelectId,
}: {
state: ChangeWorkflowState;
isTouchSurface: boolean;
stepSelectId: string;
}) {
const { t } = useTranslation();
const steps = state.snapshot?.steps ?? [];
const rowClass = cn("grid gap-1.5", isTouchSurface && "gap-2");
const controlClass = isTouchSurface ? "h-11 w-full" : "h-7 w-full";
return (
<>
{state.selectedWorkflowId && state.snapshotStatus === "loading" && (
<LoadingStatus label={t("task:changeWorkflowLoadingSteps")} />
)}
{state.selectedWorkflowId && state.snapshotStatus === "error" && (
<LoadError label={t("task:changeWorkflowLoadStepsFailed")} onRetry={state.retrySnapshot} />
)}
{state.snapshotStatus === "success" && steps.length === 0 && (
<p
role="status"
data-testid="change-workflow-no-steps"
className="text-sm text-muted-foreground"
>
{t("task:changeWorkflowNoSteps")}
</p>
)}
{state.snapshotStatus === "success" && steps.length > 0 && (
<div className={rowClass}>
<span className="text-sm font-medium">{t("task:changeWorkflowStepLabel")}</span>
<Combobox
options={stepOptions(steps)}
value={state.selectedStepId}
onValueChange={state.setSelectedStepId}
ariaLabel={t("task:changeWorkflowStepLabel")}
triggerId={stepSelectId}
placeholder={t("task:changeWorkflowStepPlaceholder")}
searchPlaceholder={t("task:changeWorkflowSearchSteps")}
emptyMessage={t("task:changeWorkflowNoStepFound")}
dropdownLabel={t("task:changeWorkflowStepLabel")}
disabled={state.snapshotStatus !== "success" || state.isSubmitting}
touchTarget={isTouchSurface}
triggerClassName={controlClass}
testId="change-workflow-step"
popoverPortal
/>
</div>
)}
</>
);
}
export function ChangeWorkflowSelectors({
state,
isTouchSurface,
workflowSelectId,
stepSelectId,
}: {
state: ChangeWorkflowState;
isTouchSurface: boolean;
workflowSelectId: string;
stepSelectId: string;
}) {
return (
<>
<WorkflowPickerSection
state={state}
isTouchSurface={isTouchSurface}
workflowSelectId={workflowSelectId}
/>
<StepPickerSection
state={state}
isTouchSurface={isTouchSurface}
stepSelectId={stepSelectId}
/>
</>
);
}
function LoadingStatus({ label }: { label: string }) {
return (
<div role="status" className="flex min-h-9 items-center gap-2 text-sm text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" aria-hidden="true" />
{label}
</div>
);
}
function LoadError({ label, onRetry }: { label: string; onRetry: () => void }) {
const { t } = useTranslation();
return (
<div
role="alert"
className="grid justify-items-start gap-2 rounded-md border border-destructive/40 p-3 text-sm"
>
<span>{label}</span>
<RetryButton onClick={onRetry} label={t("task:changeWorkflowRetry")} />
</div>
);
}
type ConversationRelationship =
| { id: string; stepName: string; kind: "initial" }
| { id: string; stepName: string; kind: "step"; targetName: string };
function conversationRelationships(steps: readonly WorkflowStepDTO[]): ConversationRelationship[] {
return steps.flatMap<ConversationRelationship>((step) => {
const target = step.session_target;
if (target?.kind === "initial") return [{ id: step.id, stepName: step.name, kind: "initial" }];
if (target?.kind !== "step") return [];
const targetStep = steps.find((candidate) => candidate.id === target.step_id);
return [
{
id: step.id,
stepName: step.name,
kind: "step",
targetName: targetStep?.name ?? "",
},
];
});
}
function WorkflowAgentRow({
state,
row,
isTouchSurface,
}: {
state: ChangeWorkflowState;
row: ChangeWorkflowState["rows"][number];
isTouchSurface: boolean;
}) {
const { t } = useTranslation();
const invalid = !row.replacementAvailable || state.sourceProfileErrorId === row.sourceProfileId;
const options: ComboboxOption[] = state.profileOptions.map((option) => ({
...option,
disabled: false,
}));
if (!options.some((option) => option.value === row.replacementProfileId)) {
options.unshift({
value: row.replacementProfileId,
label: row.replacementLabel ?? row.sourceLabel,
disabled: true,
disabledReason: t("task:changeWorkflowProfileUnavailable"),
});
}
return (
<div
className={cn("grid gap-2 rounded-md border p-3", invalid && "border-destructive/50")}
data-testid={`change-workflow-profile-${row.sourceProfileId}`}
>
<div className="grid gap-0.5">
<span className="text-sm font-medium">{row.sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{t("task:changeWorkflowProfileSteps", { steps: row.stepNames.join(", ") })}
</span>
</div>
<div className="flex min-w-0 items-center gap-2">
<Combobox
options={options}
value={row.replacementProfileId}
onValueChange={(value) => state.setOverride(row.sourceProfileId, value)}
ariaLabel={t("task:changeWorkflowReplacementLabel", { profile: row.sourceLabel })}
placeholder={t("task:changeWorkflowProfilePlaceholder")}
searchPlaceholder={t("task:searchAgents")}
emptyMessage={t("task:noAgentFound")}
dropdownLabel={t("task:agentProfile2")}
disabled={state.isSubmitting || state.profilesStatus !== "success"}
touchTarget={isTouchSurface}
triggerClassName={cn(isTouchSurface ? "h-11 w-full" : "h-7 w-full", "min-w-0 flex-1")}
testId={`change-workflow-profile-selector-${row.sourceProfileId}`}
popoverPortal
/>
<Button
type="button"
variant="outline"
className={cn("shrink-0", isTouchSurface ? "min-h-11" : "h-7")}
disabled={state.isSubmitting || row.replacementProfileId === row.sourceProfileId}
onClick={() => state.setOverride(row.sourceProfileId, row.sourceProfileId)}
data-testid={`change-workflow-profile-reset-${row.sourceProfileId}`}
>
{t("task:changeWorkflowResetProfile")}
</Button>
</div>
{invalid && (
<p role="alert" className="text-xs text-destructive">
{t("task:changeWorkflowProfileUnavailable")}
</p>
)}
</div>
);
}
function ConversationSection({ steps }: { steps: readonly WorkflowStepDTO[] }) {
const { t } = useTranslation();
const relationships = conversationRelationships(steps);
if (relationships.length === 0) return null;
return (
<section
className="grid gap-2 rounded-md bg-muted/40 p-3"
aria-labelledby="change-workflow-conversations-heading"
>
<h4 id="change-workflow-conversations-heading" className="text-sm font-medium">
{t("task:changeWorkflowConversationSection")}
</h4>
{relationships.map((relationship) => (
<p key={relationship.id} className="break-words text-xs text-muted-foreground">
{relationship.kind === "initial"
? t("task:changeWorkflowInitialConversation", { step: relationship.stepName })
: t("task:changeWorkflowStepConversation", {
step: relationship.stepName,
target: relationship.targetName,
})}
</p>
))}
</section>
);
}
export function ChangeWorkflowAgentSection({
state,
isTouchSurface,
}: { state: ChangeWorkflowState } & TouchProps) {
const { t } = useTranslation();
if (!state.selectedWorkflowId) return null;
const steps = state.snapshot?.steps ?? [];
return (
<section
className="grid gap-3 border-t border-border/70 pt-3"
aria-labelledby="change-workflow-agents-heading"
>
<div className="grid gap-1">
<h3 id="change-workflow-agents-heading" className="text-sm font-semibold">
{t("task:changeWorkflowAgentSection")}
</h3>
<p className="text-xs text-muted-foreground">{t("task:changeWorkflowAgentScope")}</p>
</div>
{state.profilesStatus === "loading" && (
<LoadingStatus label={t("task:changeWorkflowLoadingProfiles")} />
)}
{state.profilesStatus === "error" && (
<LoadError
label={t("task:changeWorkflowLoadProfilesFailed")}
onRetry={state.retryProfiles}
/>
)}
{state.profilesStatus === "success" && state.rows.length === 0 && (
<p className="text-sm text-muted-foreground">{t("task:changeWorkflowNoFixedProfiles")}</p>
)}
{state.profilesStatus === "success" &&
state.rows.map((row) => (
<WorkflowAgentRow
key={row.sourceProfileId}
state={state}
row={row}
isTouchSurface={isTouchSurface}
/>
))}
{state.snapshotStatus === "success" && <ConversationSection steps={steps} />}
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
{t("task:changeWorkflowReplacePreviousOverrides")}
</p>
</section>
);
}
export function ChangeWorkflowEntryPreview({
state,
isTouchSurface,
}: { state: ChangeWorkflowState } & TouchProps) {
const { t } = useTranslation();
if (
state.snapshotStatus !== "success" ||
!state.selectedStepId ||
!state.task ||
!state.workflowChange
)
return null;
return (
<section
className="grid gap-2 border-t border-border/70 pt-3"
aria-labelledby="change-workflow-preview-heading"
>
<h3 id="change-workflow-preview-heading" className="text-sm font-semibold">
{t("task:changeWorkflowEntryPreview")}
</h3>
<WorkflowMovePreviewFooter
target={{
taskId: state.task.id,
workflowId: state.selectedWorkflowId,
workflowStepId: state.selectedStepId,
}}
workflowChange={state.workflowChange}
isTouchSurface={isTouchSurface}
/>
</section>
);
}
export function ChangeWorkflowSubmitError({ state }: { state: ChangeWorkflowState }) {
const { t } = useTranslation();
const error = state.submitError;
if (!error) return null;
let message = t("task:changeWorkflowFailed");
if (error.code === "workflow_change_conflict") message = t("task:changeWorkflowSourceChanged");
if (error.code === "workflow_change_uncertain") message = t("task:changeWorkflowUncertain");
return (
<div
role="alert"
className="grid justify-items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm"
>
<span>{message}</span>
{error.code === "workflow_change_uncertain" && (
<RetryButton
onClick={state.retryTask}
label={t("task:changeWorkflowRefreshTask")}
testId="change-workflow-refresh-task"
/>
)}
</div>
);
}
export function ChangeWorkflowActionFooter({
state,
isTouchSurface,
onOpenChange,
}: TouchProps & { state: ChangeWorkflowState; onOpenChange: (open: boolean) => void }) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex shrink-0 items-center justify-end gap-2 border-t border-border/70 px-5 py-3",
isTouchSurface &&
"justify-stretch px-4 pb-[max(12px,env(safe-area-inset-bottom,0px))] pt-3",
)}
>
<Button
type="button"
variant="outline"
className={cn(isTouchSurface && "min-h-12 flex-1")}
disabled={state.isSubmitting}
onClick={() => onOpenChange(false)}
data-testid="change-workflow-cancel"
>
{t("task:changeWorkflowCancel")}
</Button>
<Button
type="submit"
className={cn(isTouchSurface && "min-h-12 flex-1")}
disabled={!state.canSubmit}
data-testid="change-workflow-submit"
>
{state.isSubmitting && <IconLoader2 className="size-4 animate-spin" aria-hidden="true" />}
{t("task:changeWorkflowSubmit")}
</Button>
</div>
);
}