Files
kandev/apps/web/app/settings/workspace/workspace-workflows-client.tsx
T
2026-09-18 00:34:50 +01:00

599 lines
19 KiB
TypeScript

"use client";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { t as translate } from "@/lib/i18n";
import { useRouter } from "@/lib/routing/client-router";
import { IconGripVertical, IconArrowsShuffle } from "@tabler/icons-react";
import {
DndContext,
closestCenter,
type DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { SettingsSection } from "@/components/settings/settings-section";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { WorkflowCard } from "@/components/settings/workflow-card";
import { WorkflowSectionActions } from "@/components/settings/workflow-section-actions";
import { WorkflowSyncSection } from "@/components/settings/workflow-sync-section";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useToast } from "@/components/toast-provider";
import { useWorkflowSettings } from "@/hooks/domains/settings/use-workflow-settings";
import {
deleteWorkflowAction,
exportAllWorkflowsAction,
reorderWorkflowsAction,
} from "@/app/actions/workspaces";
import {
agentProfileId as toAgentProfileId,
type Workflow,
type WorkflowStep,
type Workspace,
type WorkflowTemplate,
} from "@/lib/types/http";
import { WorkflowDialogs } from "@/app/settings/workspace/workspace-workflows-dialogs";
import { useWorkflowCreation } from "@/app/settings/workspace/use-workflow-creation";
import { useWorkflowImport } from "@/app/settings/workspace/use-workflow-import";
import { WorkspaceNotFoundCard } from "@/app/settings/workspace/workspace-not-found-card";
type WorkspaceWorkflowsClientProps = {
workspace: Workspace | null;
workflows: Workflow[];
workflowTemplates: WorkflowTemplate[];
/** The dedicated Improve Kandev workspace is configuration-immutable. */
isImproveWorkspace?: boolean;
};
const TEMP_WORKFLOW_PREFIX = "temp-workflow-";
type WorkflowActionsArgs = {
workspace: Workspace | null;
workflowItems: Workflow[];
savedWorkflowItems: Workflow[];
workflowTemplates: WorkflowTemplate[];
setWorkflowItems: React.Dispatch<React.SetStateAction<Workflow[]>>;
setSavedWorkflowItems: React.Dispatch<React.SetStateAction<Workflow[]>>;
};
type WorkflowSavedParams = {
clientWorkflow: Workflow;
submittedWorkflow: Workflow;
savedWorkflow: Workflow;
currentSteps: WorkflowStep[];
savedSteps: WorkflowStep[];
finalizeIdentity: boolean;
};
function useWorkflowImportExport(
workspace: Workspace | null,
workflowItems: Workflow[],
router: ReturnType<typeof useRouter>,
toast: ReturnType<typeof useToast>["toast"],
) {
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [exportYaml, setExportYaml] = useState("");
const handleExportAll = async () => {
if (!workspace) return;
try {
// Export only the workflows shown in this settings view (kanban-only —
// office workflows are filtered out upstream).
// Workflow import/export is kanban-only by design (ADR-0004).
const exportIds = workflowItems.map((wf) => wf.id);
const yamlText = await exportAllWorkflowsAction(workspace.id, exportIds);
setExportYaml(yamlText);
setIsExportDialogOpen(true);
} catch (error) {
toast({
title: translate("workflows:failedToExportWorkflows"),
description: error instanceof Error ? error.message : translate("common:requestFailed"),
variant: "error",
});
}
};
return {
isExportDialogOpen,
setIsExportDialogOpen,
exportYaml,
handleExportAll,
};
}
export function hasNewerWorkflowMetadata(current: Workflow, savedFrom: Workflow) {
return (
current.name !== savedFrom.name ||
(current.description ?? "") !== (savedFrom.description ?? "") ||
(current.prompt ?? "") !== (savedFrom.prompt ?? "") ||
(current.agent_profile_id ?? "") !== (savedFrom.agent_profile_id ?? "")
);
}
function mergeSavedWorkflow(current: Workflow, submitted: Workflow, saved: Workflow): Workflow {
if (!hasNewerWorkflowMetadata(current, submitted)) return saved;
return { ...current, id: saved.id, workflow_template_id: saved.workflow_template_id };
}
function useWorkflowActions({
workspace,
workflowItems,
savedWorkflowItems,
workflowTemplates,
setWorkflowItems,
setSavedWorkflowItems,
}: WorkflowActionsArgs) {
const finalizedWorkflowIdsRef = useRef(new Map<string, string>());
const creation = useWorkflowCreation({
workspace,
workflowItems,
workflowTemplates,
setWorkflowItems,
});
const handleUpdateWorkflow = (
workflowId: string,
updates: {
name?: string;
description?: string;
prompt?: string;
agent_profile_id?: string;
},
) => {
setWorkflowItems((prev) =>
prev.map((wf) =>
wf.id === workflowId
? {
...wf,
...updates,
agent_profile_id:
updates.agent_profile_id !== undefined
? toAgentProfileId(updates.agent_profile_id)
: wf.agent_profile_id,
}
: wf,
),
);
};
const handleDeleteWorkflow = async (workflowId: string) => {
if (!workflowId.startsWith(TEMP_WORKFLOW_PREFIX)) await deleteWorkflowAction(workflowId);
creation.forgetInitialSteps(workflowId);
finalizedWorkflowIdsRef.current.delete(workflowId);
setWorkflowItems((prev) => prev.filter((wf) => wf.id !== workflowId));
setSavedWorkflowItems((prev) => prev.filter((wf) => wf.id !== workflowId));
};
const handleWorkflowSaved = ({
clientWorkflow,
submittedWorkflow,
savedWorkflow,
currentSteps,
finalizeIdentity,
}: WorkflowSavedParams) => {
const isNew = clientWorkflow.id.startsWith(TEMP_WORKFLOW_PREFIX);
if (isNew && !finalizeIdentity) return;
if (isNew) finalizedWorkflowIdsRef.current.set(clientWorkflow.id, savedWorkflow.id);
setWorkflowItems((prev) =>
prev.map((item) =>
item.id === clientWorkflow.id
? mergeSavedWorkflow(item, submittedWorkflow, savedWorkflow)
: item,
),
);
setSavedWorkflowItems((previous) =>
alignSavedWorkflowsToDraftOrder(
workflowItems,
[...previous.filter((item) => item.id !== savedWorkflow.id), savedWorkflow],
finalizedWorkflowIdsRef.current,
),
);
if (isNew) {
creation.remapInitialSteps(clientWorkflow.id, savedWorkflow.id, currentSteps);
}
};
const handleDiscardWorkflow = (workflowId: string) => {
creation.forgetInitialSteps(workflowId);
finalizedWorkflowIdsRef.current.delete(workflowId);
if (workflowId.startsWith(TEMP_WORKFLOW_PREFIX)) {
setWorkflowItems((previous) => previous.filter((item) => item.id !== workflowId));
return;
}
const saved = savedWorkflowItems.find((item) => item.id === workflowId);
if (saved) {
setWorkflowItems((previous) =>
previous.map((item) => (item.id === workflowId ? saved : item)),
);
}
};
return {
...creation,
handleUpdateWorkflow,
handleDeleteWorkflow,
handleWorkflowSaved,
handleDiscardWorkflow,
};
}
export function alignSavedWorkflowsToDraftOrder(
drafts: Workflow[],
saved: Workflow[],
finalizedWorkflowIds: ReadonlyMap<string, string>,
): Workflow[] {
const savedById = new Map<string, Workflow>(saved.map((workflow) => [workflow.id, workflow]));
return drafts.flatMap((draft) => {
const persistedId = finalizedWorkflowIds.get(draft.id) ?? draft.id;
const baseline = savedById.get(persistedId);
return baseline ? [baseline] : [];
});
}
type WorkflowListProps = {
workflowItems: Workflow[];
savedWorkflowItems: Workflow[];
orderDirtyIds: ReadonlySet<string>;
initialStepsByWorkflowId: Map<string, WorkflowStep[]>;
isWorkflowDirty: (wf: Workflow) => boolean;
/** The dedicated Improve Kandev workspace renders workflows read-only. */
isImproveWorkspace?: boolean;
onUpdate: (
id: string,
u: {
name?: string;
description?: string;
prompt?: string;
agent_profile_id?: string;
},
) => void;
onDelete: (id: string) => void;
onDuplicate: (workflow: Workflow, steps: WorkflowStep[]) => void;
onWorkflowSaved: (params: WorkflowSavedParams) => void;
onDiscard: (id: string) => void;
onReorder: (items: Workflow[]) => void;
};
function SortableWorkflowItem({
workflow,
isDirty,
readOnly,
children,
}: {
workflow: Workflow;
isDirty: boolean;
readOnly?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: workflow.id, disabled: readOnly });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="relative min-w-0"
data-settings-dirty={isDirty}
data-settings-dirty-level="container"
data-testid={`workflow-order-item-${workflow.id}`}
>
{!readOnly && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={setActivatorNodeRef}
type="button"
className="absolute right-3 top-4 z-10 flex h-8 w-8 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground transition-[color,background-color,transform] hover:bg-muted hover:text-foreground active:scale-95 active:cursor-grabbing [@media(pointer:coarse)]:top-3 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
data-testid={`workflow-drag-handle-${workflow.id}`}
{...attributes}
{...listeners}
aria-label={t("workflows:reorderWorkflow", { name: workflow.name })}
>
<IconGripVertical className="h-5 w-5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("workflows:reorderWorkflow", { name: workflow.name })}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{children}
</div>
);
}
function WorkflowList({
workflowItems,
savedWorkflowItems,
orderDirtyIds,
initialStepsByWorkflowId,
isWorkflowDirty,
isImproveWorkspace,
onUpdate,
onDelete,
onDuplicate,
onWorkflowSaved,
onDiscard,
onReorder,
}: WorkflowListProps) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const savedWorkflowsById = useMemo(
() => new Map(savedWorkflowItems.map((workflow) => [workflow.id, workflow])),
[savedWorkflowItems],
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = workflowItems.findIndex((wf) => wf.id === active.id);
const newIndex = workflowItems.findIndex((wf) => wf.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(workflowItems, oldIndex, newIndex);
onReorder(reordered);
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={workflowItems.map((wf) => wf.id)}
strategy={verticalListSortingStrategy}
>
<div
className="grid min-w-0 gap-3"
data-settings-dirty={orderDirtyIds.size > 0}
data-settings-dirty-level="container"
data-testid="workflow-order-list"
>
{workflowItems.map((workflow) => (
<SortableWorkflowItem
key={workflow.id}
workflow={workflow}
isDirty={orderDirtyIds.has(workflow.id)}
readOnly={isImproveWorkspace}
>
<WorkflowCard
workflow={workflow}
savedWorkflow={savedWorkflowsById.get(workflow.id)}
isWorkflowDirty={isWorkflowDirty(workflow)}
isOrderDirty={orderDirtyIds.has(workflow.id)}
initialWorkflowSteps={initialStepsByWorkflowId.get(workflow.id)}
otherWorkflows={workflowItems.filter((w) => w.id !== workflow.id)}
isImproveWorkspace={isImproveWorkspace}
onUpdateWorkflow={(updates) => onUpdate(workflow.id, updates)}
onDeleteWorkflow={async () => {
await onDelete(workflow.id);
}}
onDuplicateWorkflow={(steps) => onDuplicate(workflow, steps)}
onWorkflowSaved={onWorkflowSaved}
onDiscardWorkflow={() => onDiscard(workflow.id)}
/>
</SortableWorkflowItem>
))}
</div>
</SortableContext>
</DndContext>
);
}
export function WorkspaceWorkflowsClient({
workspace,
workflows,
workflowTemplates,
isImproveWorkspace = false,
}: WorkspaceWorkflowsClientProps) {
const { t } = useTranslation();
const page = useWorkspaceWorkflowsPage(workspace, workflows, workflowTemplates);
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
if (!workspace)
return <WorkspaceNotFoundCard onBack={() => page.router.push("/settings/workspaces")} />;
return (
<div className="space-y-8">
{/* No section header — see the Repositories tab: the section below is
already named, marked and described. */}
<SettingsSection
divided
icon={<IconArrowsShuffle className="h-5 w-5" />}
title={t("workflows:workflows")}
// The read-only note is the section's description here rather than a
// heading above it: the duplicated heading is gone, and this workspace
// being immutable is the one thing the static description omits.
description={
isImproveWorkspace
? t("workflows:workflowsReadOnlyImprove")
: t("workflows:workflowsSectionDescription")
}
action={
isImproveWorkspace ? undefined : (
<WorkflowSectionActions
onExport={page.handleExportAll}
onImport={() => page.setIsImportDialogOpen(true)}
onAdd={page.handleOpenAddWorkflowDialog}
onGitHubSync={() => setSyncDialogOpen(true)}
/>
)
}
>
{!isImproveWorkspace && (
<WorkflowSyncSection
workspaceId={workspace.id}
dialogOpen={syncDialogOpen}
onDialogOpenChange={setSyncDialogOpen}
/>
)}
<WorkflowList
workflowItems={page.workflowItems}
savedWorkflowItems={page.savedWorkflowItems}
orderDirtyIds={page.workflowOrderDirtyIds}
initialStepsByWorkflowId={page.initialStepsByWorkflowId}
isWorkflowDirty={page.isWorkflowDirty}
isImproveWorkspace={isImproveWorkspace}
onUpdate={page.handleUpdateWorkflow}
onDelete={page.handleDeleteWorkflow}
onDuplicate={page.handleDuplicateWorkflow}
onWorkflowSaved={page.handleWorkflowSaved}
onDiscard={page.handleDiscardWorkflow}
onReorder={page.handleReorderWorkflows}
/>
</SettingsSection>
<WorkflowDialogs page={page} />
</div>
);
}
type WorkflowOrderDraftArgs = {
workspace: Workspace | null;
workflowItems: Workflow[];
savedWorkflowItems: Workflow[];
setWorkflowItems: React.Dispatch<React.SetStateAction<Workflow[]>>;
setSavedWorkflowItems: React.Dispatch<React.SetStateAction<Workflow[]>>;
idMappings: React.RefObject<Map<string, string>>;
};
export function getWorkflowOrderDirtyIds(
workflows: Workflow[],
savedWorkflows: Workflow[],
): ReadonlySet<string> {
const savedPositions = new Map(
savedWorkflows.map((workflow, position) => [workflow.id, position]),
);
return new Set(
workflows.flatMap((workflow, position) =>
savedPositions.get(workflow.id) === position ? [] : [workflow.id],
),
);
}
function useWorkflowOrderDraft({
workspace,
workflowItems,
savedWorkflowItems,
setWorkflowItems,
setSavedWorkflowItems,
idMappings,
}: WorkflowOrderDraftArgs) {
const currentOrder = workflowItems.map((workflow) => workflow.id);
const savedOrder = savedWorkflowItems.map((workflow) => workflow.id);
const dirtyIds = getWorkflowOrderDirtyIds(workflowItems, savedWorkflowItems);
useSettingsSaveContributor({
id: `workflow-order:${workspace?.id ?? "missing"}`,
order: 1000,
revision: currentOrder.join("\0"),
isDirty: currentOrder.join("\0") !== savedOrder.join("\0"),
save: async () => {
if (!workspace) return;
const persistedOrder = currentOrder.map((id) => idMappings.current.get(id) ?? id);
if (persistedOrder.some((id) => id.startsWith(TEMP_WORKFLOW_PREFIX))) {
throw new Error(translate("workflows:saveWorkflowsBeforeOrdering"));
}
if (persistedOrder.length > 0) {
await reorderWorkflowsAction(workspace.id, persistedOrder);
}
setSavedWorkflowItems((previous) => sortWorkflows(previous, persistedOrder));
},
discard: () => setWorkflowItems(savedWorkflowItems),
});
return dirtyIds;
}
function sortWorkflows(workflows: Workflow[], order: string[]): Workflow[] {
const positions = new Map(order.map((id, position) => [id, position]));
return [...workflows].sort(
(left, right) =>
(positions.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
(positions.get(right.id) ?? Number.MAX_SAFE_INTEGER),
);
}
function useWorkspaceWorkflowsPage(
workspace: Workspace | null,
workflows: Workflow[],
workflowTemplates: WorkflowTemplate[],
) {
const router = useRouter();
const { toast } = useToast();
// Workflow settings is kanban-only by design (ADR-0004): office-style
// workflows are managed from the Office surface and are not importable /
// exportable here, so we keep them out of this view entirely.
const kanbanWorkflows = useMemo(
() => workflows.filter((wf) => wf.style !== "office"),
[workflows],
);
const {
workflowItems,
setWorkflowItems,
savedWorkflowItems,
setSavedWorkflowItems,
isWorkflowDirty,
} = useWorkflowSettings(kanbanWorkflows, workspace?.id);
const workflowIdMappingsRef = useRef(new Map<string, string>());
const importExport = useWorkflowImportExport(workspace, workflowItems, router, toast);
const importState = useWorkflowImport({ workspace, router, toast });
const actions = useWorkflowActions({
workspace,
workflowItems,
savedWorkflowItems,
workflowTemplates,
setWorkflowItems,
setSavedWorkflowItems,
});
const handleReorderWorkflows = (reordered: Workflow[]) => {
setWorkflowItems(reordered);
};
const handleWorkflowSaved = (params: WorkflowSavedParams) => {
if (params.clientWorkflow.id !== params.savedWorkflow.id) {
workflowIdMappingsRef.current.set(params.clientWorkflow.id, params.savedWorkflow.id);
}
actions.handleWorkflowSaved(params);
};
const workflowOrderDirtyIds = useWorkflowOrderDraft({
workspace,
workflowItems,
savedWorkflowItems,
setWorkflowItems,
setSavedWorkflowItems,
idMappings: workflowIdMappingsRef,
});
return {
router,
workflowItems,
savedWorkflowItems,
workflowOrderDirtyIds,
isWorkflowDirty,
...importExport,
...importState,
...actions,
handleWorkflowSaved,
handleReorderWorkflows,
workflowTemplates,
};
}