Files
kandev/apps/web/components/settings/workflow-card.tsx
T
2026-04-16 10:14:45 +01:00

498 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { IconDownload, IconTrash } from "@tabler/icons-react";
import { Card, CardContent } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import type { Workflow, WorkflowStep } from "@/lib/types/http";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { WorkflowExportDialog } from "@/components/settings/workflow-export-dialog";
import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator";
import { WorkflowPipelineEditor } from "@/components/settings/workflow-pipeline-editor";
import { listWorkflowStepsAction } from "@/app/actions/workspaces";
import { HelpTip } from "./workflow-pipeline-editor-helpers";
import { WorkflowDeleteDialog, StepDeleteDialog } from "./workflow-card-dialogs";
import {
useWorkflowStepActions,
useWorkflowDeleteHandlers,
useStepDeleteHandlers,
useWorkflowSaveActions,
handleExportWorkflow,
} from "./workflow-card-actions";
type WorkflowCardProps = {
workflow: Workflow;
isWorkflowDirty: boolean;
initialWorkflowSteps?: WorkflowStep[];
templateStepCount?: number;
otherWorkflows?: Workflow[];
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
agent_profile_id?: string;
}) => void;
onDeleteWorkflow: () => Promise<unknown>;
onSaveWorkflow: () => Promise<unknown>;
onWorkflowCreated?: (created: Workflow) => void;
};
function useWorkflowSteps(
workflowId: string,
initialSteps: WorkflowStep[] | undefined,
isNewWorkflow: boolean,
toast: ReturnType<typeof useToast>["toast"],
) {
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>(initialSteps ?? []);
const [workflowLoading, setWorkflowLoading] = useState(false);
useEffect(() => {
if (isNewWorkflow) {
setWorkflowSteps(initialSteps ?? []);
setWorkflowLoading(false);
return;
}
let cancelled = false;
const load = async () => {
setWorkflowLoading(true);
try {
const res = await listWorkflowStepsAction(workflowId);
if (!cancelled) setWorkflowSteps(res.steps ?? []);
} catch {
if (!cancelled) toast({ title: "Failed to load workflow steps", variant: "error" });
} finally {
if (!cancelled) setWorkflowLoading(false);
}
};
load();
return () => {
cancelled = true;
};
}, [workflowId, initialSteps, isNewWorkflow, toast]);
const refreshWorkflowSteps = async () => {
try {
const res = await listWorkflowStepsAction(workflowId);
setWorkflowSteps(res.steps ?? []);
} catch {
/* ignore */
}
};
return { workflowSteps, setWorkflowSteps, workflowLoading, refreshWorkflowSteps };
}
type WorkflowDeleteState = {
deleteOpen: boolean;
setDeleteOpen: (v: boolean) => void;
workflowTaskCount: number | null;
setWorkflowTaskCount: (v: number | null) => void;
workflowDeleteLoading: boolean;
setWorkflowDeleteLoading: (v: boolean) => void;
targetWorkflowId: string;
setTargetWorkflowId: (v: string) => void;
targetWorkflowSteps: WorkflowStep[];
setTargetWorkflowSteps: (v: WorkflowStep[]) => void;
targetStepId: string;
setTargetStepId: (v: string) => void;
migrateLoading: boolean;
setMigrateLoading: (v: boolean) => void;
};
function useWorkflowDeleteState(): WorkflowDeleteState {
const [deleteOpen, setDeleteOpen] = useState(false);
const [workflowTaskCount, setWorkflowTaskCount] = useState<number | null>(null);
const [workflowDeleteLoading, setWorkflowDeleteLoading] = useState(false);
const [targetWorkflowId, setTargetWorkflowId] = useState<string>("");
const [targetWorkflowSteps, setTargetWorkflowSteps] = useState<WorkflowStep[]>([]);
const [targetStepId, setTargetStepId] = useState<string>("");
const [migrateLoading, setMigrateLoading] = useState(false);
return {
deleteOpen,
setDeleteOpen,
workflowTaskCount,
setWorkflowTaskCount,
workflowDeleteLoading,
setWorkflowDeleteLoading,
targetWorkflowId,
setTargetWorkflowId,
targetWorkflowSteps,
setTargetWorkflowSteps,
targetStepId,
setTargetStepId,
migrateLoading,
setMigrateLoading,
};
}
type StepDeleteState = {
stepDeleteOpen: boolean;
setStepDeleteOpen: (v: boolean) => void;
stepToDelete: string | null;
setStepToDelete: (v: string | null) => void;
stepTaskCount: number | null;
setStepTaskCount: (v: number | null) => void;
targetStepForMigration: string;
setTargetStepForMigration: (v: string) => void;
stepMigrateLoading: boolean;
setStepMigrateLoading: (v: boolean) => void;
};
function useStepDeleteState(): StepDeleteState {
const [stepDeleteOpen, setStepDeleteOpen] = useState(false);
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
const [stepTaskCount, setStepTaskCount] = useState<number | null>(null);
const [targetStepForMigration, setTargetStepForMigration] = useState<string>("");
const [stepMigrateLoading, setStepMigrateLoading] = useState(false);
return {
stepDeleteOpen,
setStepDeleteOpen,
stepToDelete,
setStepToDelete,
stepTaskCount,
setStepTaskCount,
targetStepForMigration,
setTargetStepForMigration,
stepMigrateLoading,
setStepMigrateLoading,
};
}
type WorkflowCardActionsProps = {
isNewWorkflow: boolean;
workflowId: string;
setExportYaml: (json: string) => void;
setExportOpen: (open: boolean) => void;
toast: ReturnType<typeof useToast>["toast"];
onDeleteClick: () => Promise<void>;
deleteDisabled: boolean;
};
function WorkflowCardActions({
isNewWorkflow,
workflowId,
setExportYaml,
setExportOpen,
toast,
onDeleteClick,
deleteDisabled,
}: WorkflowCardActionsProps) {
return (
<div className="flex justify-end gap-2">
{!isNewWorkflow && (
<Button
type="button"
variant="outline"
onClick={() => handleExportWorkflow({ workflowId, setExportYaml, setExportOpen, toast })}
className="cursor-pointer"
>
<IconDownload className="h-4 w-4 mr-2" />
Export
</Button>
)}
<Button
type="button"
variant="destructive"
onClick={onDeleteClick}
disabled={deleteDisabled}
className="cursor-pointer"
data-testid="delete-workflow-button"
>
<IconTrash className="h-4 w-4 mr-2" />
Delete Workflow
</Button>
</div>
);
}
type WorkflowCardDialogsProps = {
wfDel: WorkflowDeleteState;
otherWorkflows: Workflow[];
deleteWorkflowLoading: boolean;
wfDeleteHandlers: {
handleDeleteWorkflow: () => Promise<void>;
handleMigrateAndDeleteWorkflow: () => Promise<void>;
};
exportOpen: boolean;
setExportOpen: (open: boolean) => void;
exportYaml: string;
stepDel: StepDeleteState;
stepsForStepMigration: WorkflowStep[];
stepDeleteHandlers: {
handleMigrateAndDeleteStep: () => Promise<void>;
handleDeleteStepAndTasks: () => Promise<void>;
};
};
type WorkflowCardBodyProps = {
workflow: Workflow;
isWorkflowDirty: boolean;
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
agent_profile_id?: string;
}) => void;
activeSaveRequest: { isLoading: boolean; status: "idle" | "loading" | "success" | "error" };
handleSaveWorkflow: () => Promise<void>;
workflowLoading: boolean;
workflowSteps: WorkflowStep[];
stepActions: {
handleUpdateWorkflowStep: (id: string, updates: Partial<WorkflowStep>) => Promise<void>;
handleAddWorkflowStep: () => Promise<void>;
handleRemoveWorkflowStep: (id: string) => Promise<void>;
handleReorderWorkflowSteps: (steps: WorkflowStep[]) => Promise<void>;
};
};
function WorkflowCardBody({
workflow,
isWorkflowDirty,
onUpdateWorkflow,
activeSaveRequest,
handleSaveWorkflow,
workflowLoading,
workflowSteps,
stepActions,
}: WorkflowCardBodyProps) {
const healthyProfiles = useHealthyAgentProfiles(workflow.agent_profile_id);
return (
<>
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label className="flex items-center gap-2">
<span>Workflow Name</span>
{isWorkflowDirty && <UnsavedChangesBadge />}
</Label>
<Input
value={workflow.name}
onChange={(e) => onUpdateWorkflow({ name: e.target.value })}
/>
</div>
<div className="w-[240px] shrink-0 space-y-1.5">
<Label className="flex items-center gap-1">
<span>Agent Profile</span>
<HelpTip text="Default agent profile for tasks in this workflow. When set, the agent selector is locked in the task creation dialog." />
</Label>
<Select
value={workflow.agent_profile_id || "none"}
onValueChange={(value) =>
onUpdateWorkflow({ agent_profile_id: value === "none" ? "" : value })
}
>
<SelectTrigger
className="w-full cursor-pointer"
data-testid="workflow-agent-profile-select"
>
<SelectValue placeholder="None (use task default)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="cursor-pointer">
None (use task default)
</SelectItem>
{healthyProfiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="cursor-pointer">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<UnsavedSaveButton
isDirty={isWorkflowDirty}
isLoading={activeSaveRequest.isLoading}
status={activeSaveRequest.status}
onClick={handleSaveWorkflow}
/>
</div>
<div className="space-y-2">
<Label>Workflow Steps</Label>
{workflowLoading ? (
<div className="text-sm text-muted-foreground">Loading workflow steps...</div>
) : (
<WorkflowPipelineEditor
steps={workflowSteps}
onUpdateStep={stepActions.handleUpdateWorkflowStep}
onAddStep={stepActions.handleAddWorkflowStep}
onRemoveStep={stepActions.handleRemoveWorkflowStep}
onReorderSteps={stepActions.handleReorderWorkflowSteps}
/>
)}
</div>
</>
);
}
function WorkflowCardDialogs({
wfDel,
otherWorkflows,
deleteWorkflowLoading,
wfDeleteHandlers,
exportOpen,
setExportOpen,
exportYaml,
stepDel,
stepsForStepMigration,
stepDeleteHandlers,
}: WorkflowCardDialogsProps) {
return (
<>
<WorkflowDeleteDialog
open={wfDel.deleteOpen}
onOpenChange={wfDel.setDeleteOpen}
workflowTaskCount={wfDel.workflowTaskCount}
otherWorkflows={otherWorkflows}
targetWorkflowId={wfDel.targetWorkflowId}
setTargetWorkflowId={wfDel.setTargetWorkflowId}
targetWorkflowSteps={wfDel.targetWorkflowSteps}
targetStepId={wfDel.targetStepId}
setTargetStepId={wfDel.setTargetStepId}
migrateLoading={wfDel.migrateLoading}
deleteLoading={deleteWorkflowLoading}
onDelete={wfDeleteHandlers.handleDeleteWorkflow}
onMigrateAndDelete={wfDeleteHandlers.handleMigrateAndDeleteWorkflow}
/>
<WorkflowExportDialog
open={exportOpen}
onOpenChange={setExportOpen}
title="Export Workflow"
content={exportYaml}
/>
<StepDeleteDialog
open={stepDel.stepDeleteOpen}
onOpenChange={stepDel.setStepDeleteOpen}
stepTaskCount={stepDel.stepTaskCount}
stepsForMigration={stepsForStepMigration}
targetStep={stepDel.targetStepForMigration}
setTargetStep={stepDel.setTargetStepForMigration}
loading={stepDel.stepMigrateLoading}
onMigrateAndDelete={stepDeleteHandlers.handleMigrateAndDeleteStep}
onDeleteAndTasks={stepDeleteHandlers.handleDeleteStepAndTasks}
/>
</>
);
}
function useWorkflowCardState(props: WorkflowCardProps) {
const { workflow, initialWorkflowSteps, templateStepCount = 0, otherWorkflows = [] } = props;
const { onDeleteWorkflow, onSaveWorkflow, onWorkflowCreated } = props;
const { toast } = useToast();
const [exportOpen, setExportOpen] = useState(false);
const [exportYaml, setExportYaml] = useState("");
const wfDel = useWorkflowDeleteState();
const stepDel = useStepDeleteState();
const isNewWorkflow = workflow.id.startsWith("temp-");
const deleteWorkflowRequest = useRequest(onDeleteWorkflow);
const { workflowSteps, setWorkflowSteps, workflowLoading, refreshWorkflowSteps } =
useWorkflowSteps(workflow.id, initialWorkflowSteps, isNewWorkflow, toast);
const stepActions = useWorkflowStepActions({
workflow,
isNewWorkflow,
workflowSteps,
setWorkflowSteps,
refreshWorkflowSteps,
setStepToDelete: stepDel.setStepToDelete,
setStepTaskCount: stepDel.setStepTaskCount,
setTargetStepForMigration: stepDel.setTargetStepForMigration,
setStepDeleteOpen: stepDel.setStepDeleteOpen,
toast,
});
const { activeSaveRequest, handleSaveWorkflow } = useWorkflowSaveActions({
workflow,
isNewWorkflow,
workflowSteps,
templateStepCount,
onSaveWorkflow,
onWorkflowCreated,
toast,
});
const wfDeleteHandlers = useWorkflowDeleteHandlers({
workflow,
isNewWorkflow,
otherWorkflows,
wfDel,
deleteWorkflowRun: deleteWorkflowRequest.run,
toast,
});
const stepDeleteHandlers = useStepDeleteHandlers({
workflow,
stepDel,
refreshWorkflowSteps,
toast,
});
const stepsForStepMigration = stepDel.stepToDelete
? workflowSteps.filter((s) => s.id !== stepDel.stepToDelete)
: [];
return {
toast,
exportOpen,
setExportOpen,
exportYaml,
setExportYaml,
wfDel,
stepDel,
isNewWorkflow,
deleteWorkflowRequest,
workflowSteps,
workflowLoading,
stepActions,
activeSaveRequest,
handleSaveWorkflow,
wfDeleteHandlers,
stepDeleteHandlers,
stepsForStepMigration,
};
}
export function WorkflowCard(props: WorkflowCardProps) {
const { workflow, isWorkflowDirty, otherWorkflows = [], onUpdateWorkflow } = props;
const s = useWorkflowCardState(props);
return (
<Card
data-testid={`workflow-card-${workflow.id}`}
className={isWorkflowDirty ? "ring-yellow-500/50" : undefined}
>
<CardContent className="pt-6">
<div className="space-y-4">
<WorkflowCardBody
workflow={workflow}
isWorkflowDirty={isWorkflowDirty}
onUpdateWorkflow={onUpdateWorkflow}
activeSaveRequest={s.activeSaveRequest}
handleSaveWorkflow={s.handleSaveWorkflow}
workflowLoading={s.workflowLoading}
workflowSteps={s.workflowSteps}
stepActions={s.stepActions}
/>
<WorkflowCardActions
isNewWorkflow={s.isNewWorkflow}
workflowId={workflow.id}
setExportYaml={s.setExportYaml}
setExportOpen={s.setExportOpen}
toast={s.toast}
onDeleteClick={s.wfDeleteHandlers.handleDeleteWorkflowClick}
deleteDisabled={s.deleteWorkflowRequest.isLoading || s.wfDel.workflowDeleteLoading}
/>
</div>
</CardContent>
<WorkflowCardDialogs
wfDel={s.wfDel}
otherWorkflows={otherWorkflows}
deleteWorkflowLoading={s.deleteWorkflowRequest.isLoading}
wfDeleteHandlers={s.wfDeleteHandlers}
exportOpen={s.exportOpen}
setExportOpen={s.setExportOpen}
exportYaml={s.exportYaml}
stepDel={s.stepDel}
stepsForStepMigration={s.stepsForStepMigration}
stepDeleteHandlers={s.stepDeleteHandlers}
/>
</Card>
);
}