"use client"; import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from "react"; import { useTranslation } from "react-i18next"; import { importWorkflowsAction, previewWorkflowImportAction } from "@/app/actions/workspaces"; import { useToast } from "@/components/toast-provider"; import { useRouter } from "@/lib/routing/client-router"; import type { WorkflowImportPreview, WorkflowImportProfileBinding, WorkflowImportProfileCandidate, WorkflowImportProfileConflict, WorkflowImportProfilesRequiredResponse, WorkflowImportProfileStep, ImportWorkflowsResult, Workspace, } from "@/lib/types/http"; export type WorkflowImportPhase = "editing" | "previewing" | "resolving" | "submitting"; export type WorkflowImportSelections = Record; export function workflowImportStepKey( step: Pick, ): string { return `${step.workflow_index}:${step.step_position}`; } function workflowImportConflictKey(conflict: WorkflowImportProfileConflict): string { return workflowImportStepKey(conflict); } export function workflowImportProfileLabel(profile: WorkflowImportProfileCandidate): string { return [profile.name, profile.agent_name, profile.model, profile.mode] .filter(Boolean) .join(" ยท "); } export function workflowImportMissingSteps( preview: WorkflowImportPreview | null, selections: WorkflowImportSelections, ): WorkflowImportProfileStep[] { if (!preview) return []; return preview.steps.filter((step) => !selections[workflowImportStepKey(step)]); } function sameCandidateRevision( previous: WorkflowImportPreview | null, next: WorkflowImportPreview, profileId: string, ): boolean { const previousProfile = previous?.profiles.find((profile) => profile.id === profileId); const nextProfile = next.profiles.find((profile) => profile.id === profileId); return Boolean( previousProfile && nextProfile && previousProfile.updated_at === nextProfile.updated_at, ); } export function reconcileWorkflowImportSelections( previous: WorkflowImportPreview | null, next: WorkflowImportPreview, selections: WorkflowImportSelections, ): WorkflowImportSelections { const nextSelections: WorkflowImportSelections = {}; for (const step of next.steps) { const key = workflowImportStepKey(step); const previousSelection = selections[key]; if (previousSelection && sameCandidateRevision(previous, next, previousSelection)) { nextSelections[key] = previousSelection; continue; } if (previousSelection) continue; if (step.matched_profile) nextSelections[key] = step.matched_profile.id; } return nextSelections; } function buildWorkflowImportBindings( preview: WorkflowImportPreview, selections: WorkflowImportSelections, ): WorkflowImportProfileBinding[] { return preview.steps.flatMap((step) => { const profileId = selections[workflowImportStepKey(step)]; const profile = preview.profiles.find((candidate) => candidate.id === profileId); if (!profileId || !profile) return []; return [ { workflow_index: step.workflow_index, step_position: step.step_position, requested_profile: step.requested_profile, profile_id: profile.id, profile_updated_at: profile.updated_at, }, ]; }); } export function getWorkflowImportConflict( error: unknown, ): WorkflowImportProfilesRequiredResponse | null { if (!error || typeof error !== "object") return null; const body = "body" in error ? (error as { body?: unknown }).body : error; if (!body || typeof body !== "object" || !("code" in body)) return null; const candidate = body as Partial; if (candidate.code !== "workflow_import_profiles_required" || !Array.isArray(candidate.steps)) { return null; } return candidate as WorkflowImportProfilesRequiredResponse; } type WorkflowImportArgs = { workspace: Workspace | null; router: ReturnType; toast: ReturnType["toast"]; }; function clearConflictedSelections( current: WorkflowImportSelections, conflicts: WorkflowImportProfileConflict[], ): WorkflowImportSelections { const conflictedKeys = new Set(conflicts.map(workflowImportConflictKey)); return Object.fromEntries(Object.entries(current).filter(([key]) => !conflictedKeys.has(key))); } function importResultDescription( result: ImportWorkflowsResult, translate: (key: string, options?: Record) => string, ): string { const parts: string[] = []; if (result.created?.length) { parts.push(translate("workflows:importCreated", { names: result.created.join(", ") })); } if (result.skipped?.length) { parts.push(translate("workflows:importSkipped", { names: result.skipped.join(", ") })); } return parts.join(". "); } function notifyImportFailure( toast: WorkflowImportArgs["toast"], translate: (key: string) => string, error: unknown, ): void { toast({ title: translate("workflows:failedToImportWorkflows"), description: error instanceof Error ? error.message : translate("workflows:invalidYaml"), variant: "error", }); } function useWorkflowImportState(workspaceId: string | undefined) { const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [importYaml, setImportYamlState] = useState(""); const [phase, setPhase] = useState("editing"); const [preview, setPreview] = useState(null); const [selections, setSelections] = useState({}); const [profileConflicts, setProfileConflicts] = useState([]); const [activeStepKey, setActiveStepKey] = useState(null); const fileInputRef = useRef(null); const generationRef = useRef(0); const submissionRef = useRef(false); const workspaceIdRef = useRef(workspaceId); const clearReview = useCallback(() => { setPreview(null); setSelections({}); setProfileConflicts([]); setActiveStepKey(null); setPhase("editing"); }, []); const invalidatePreview = useCallback(() => { if (submissionRef.current) return; generationRef.current += 1; clearReview(); }, [clearReview]); useEffect(() => { if (workspaceIdRef.current === workspaceId) return; workspaceIdRef.current = workspaceId; invalidatePreview(); }, [invalidatePreview, workspaceId]); useEffect(() => { return () => { generationRef.current += 1; }; }, []); const setImportYaml = useCallback( (value: string) => { if (submissionRef.current) return; setImportYamlState(value); invalidatePreview(); }, [invalidatePreview], ); const handleFileUpload = useCallback( (event: ChangeEvent) => { if (submissionRef.current) return; const file = event.target.files?.[0]; if (!file) return; const generation = ++generationRef.current; clearReview(); const reader = new FileReader(); reader.onload = (loadEvent) => { if (generationRef.current !== generation) return; setImportYamlState(String(loadEvent.target?.result ?? "")); }; reader.readAsText(file); event.target.value = ""; }, [clearReview], ); const cancelPendingImport = useCallback(() => { if (submissionRef.current) return; generationRef.current += 1; submissionRef.current = false; clearReview(); }, [clearReview]); const resetAfterSuccess = useCallback(() => { generationRef.current += 1; submissionRef.current = false; setIsImportDialogOpen(false); setImportYamlState(""); clearReview(); }, [clearReview]); return { isImportDialogOpen, setIsImportDialogOpen, importYaml, phase, setPhase, preview, setPreview, selections, setSelections, profileConflicts, setProfileConflicts, activeStepKey, setActiveStepKey, fileInputRef, generationRef, submissionRef, setImportYaml, handleFileUpload, cancelPendingImport, resetAfterSuccess, }; } type WorkflowImportState = ReturnType; function useWorkflowImportSubmission({ workspace, router, toast, translate, importYaml, state, }: WorkflowImportArgs & { translate: (key: string, options?: Record) => string; importYaml: string; state: WorkflowImportState; }) { return useCallback( async ( targetPreview: WorkflowImportPreview, targetSelections: WorkflowImportSelections, generation: number, ) => { const { generationRef, submissionRef, setPhase, setActiveStepKey } = state; if (submissionRef.current || generationRef.current !== generation || !workspace) return; const missing = workflowImportMissingSteps(targetPreview, targetSelections); if (missing.length > 0) { setPhase("resolving"); setActiveStepKey(null); return; } submissionRef.current = true; setPhase("submitting"); try { const result = await importWorkflowsAction( workspace.id, importYaml.trim(), buildWorkflowImportBindings(targetPreview, targetSelections), ); if (generationRef.current !== generation) return; toast({ title: translate("workflows:importCompleteTitle"), description: importResultDescription(result, translate), }); state.resetAfterSuccess(); if (result.created?.length) router.refresh(); } catch (error) { if (generationRef.current !== generation) return; const conflict = getWorkflowImportConflict(error); if (conflict) { state.setProfileConflicts(conflict.steps); state.setSelections((current) => clearConflictedSelections(current, conflict.steps)); setPhase("resolving"); return; } notifyImportFailure(toast, translate, error); setPhase("editing"); } finally { if (generationRef.current === generation) submissionRef.current = false; } }, [importYaml, router, state, toast, translate, workspace], ); } function useWorkflowImportPreview({ workspace, importYaml, preview, selections, profileConflicts, state, submit, toast, translate, }: { workspace: Workspace | null; importYaml: string; preview: WorkflowImportPreview | null; selections: WorkflowImportSelections; profileConflicts: WorkflowImportProfileConflict[]; state: WorkflowImportState; submit: ( targetPreview: WorkflowImportPreview, targetSelections: WorkflowImportSelections, generation: number, ) => Promise; toast: WorkflowImportArgs["toast"]; translate: (key: string) => string; }) { return useCallback(async () => { const { generationRef, submissionRef, setPhase, setProfileConflicts, setPreview, setSelections, setActiveStepKey, } = state; if (!workspace || !importYaml.trim() || submissionRef.current) return; const generation = ++generationRef.current; setPhase("previewing"); setProfileConflicts([]); try { const nextPreview = await previewWorkflowImportAction(workspace.id, importYaml.trim()); if (generationRef.current !== generation) return; const nextSelections = clearConflictedSelections( reconcileWorkflowImportSelections(preview, nextPreview, selections), profileConflicts, ); setPreview(nextPreview); setSelections(nextSelections); const missing = workflowImportMissingSteps(nextPreview, nextSelections); if (missing.length > 0) { setPhase("resolving"); setActiveStepKey(null); return; } await submit(nextPreview, nextSelections, generation); } catch (error) { if (generationRef.current !== generation) return; notifyImportFailure(toast, translate, error); setPhase("editing"); } }, [ importYaml, preview, profileConflicts, selections, state, submit, toast, translate, workspace, ]); } export function useWorkflowImport({ workspace, router, toast }: WorkflowImportArgs) { const { t } = useTranslation(); const state = useWorkflowImportState(workspace?.id); const { importYaml, preview, selections, phase, submissionRef } = state; const submit = useWorkflowImportSubmission({ workspace, router, toast, translate: t, importYaml, state, }); const runPreview = useWorkflowImportPreview({ workspace, importYaml, preview, selections, profileConflicts: state.profileConflicts, state, submit, toast, translate: t, }); const handleImport = useCallback(async () => { if (!workspace || !importYaml.trim() || submissionRef.current) return; if (preview) { await submit(preview, selections, state.generationRef.current); return; } await runPreview(); }, [ importYaml, preview, runPreview, selections, state.generationRef, submit, submissionRef, workspace, ]); const selectProfile = useCallback( (stepKey: string, profileId: string) => { state.setSelections((current) => ({ ...current, [stepKey]: profileId })); state.setProfileConflicts((current) => current.filter((conflict) => workflowImportStepKey(conflict) !== stepKey), ); state.setActiveStepKey(null); }, [state], ); const setDialogOpen = useCallback( (open: boolean) => { if (open) { if (!state.isImportDialogOpen && state.preview) state.cancelPendingImport(); state.setIsImportDialogOpen(true); return; } if (phase === "submitting" || submissionRef.current) return; state.cancelPendingImport(); state.setIsImportDialogOpen(open); }, [phase, state, submissionRef], ); const missingSteps = workflowImportMissingSteps(preview, selections); return { isImportDialogOpen: state.isImportDialogOpen, setIsImportDialogOpen: setDialogOpen, importYaml, setImportYaml: state.setImportYaml, handleFileUpload: state.handleFileUpload, fileInputRef: state.fileInputRef as RefObject, handleImport, importLoading: phase === "previewing" || phase === "submitting", importPhase: phase, preview, selections, missingSteps, profileConflicts: state.profileConflicts, activeStepKey: state.activeStepKey, setActiveStepKey: state.setActiveStepKey, selectProfile, retryPreview: runPreview, }; }