From f46c60aa90500f5d44e496b3608671bdb0bcb08a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 17 Aug 2026 17:01:30 -0700 Subject: [PATCH 01/30] feat(workflows): add URL-addressable editor foundation Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/app/navigation/useAppNavigation.ts | 44 + .../src/app/routes/WorkflowsRouteScreen.tsx | 42 +- .../app/routes/lazyWorkflowsRouteScreen.ts | 6 + .../src/app/routes/workflows.$workflowId.tsx | 55 +- desktop/src/app/routes/workflows.tsx | 46 +- .../features/workflows/ui/ChannelCombobox.tsx | 21 +- .../workflows/ui/CreateWorkflowDialog.tsx | 23 - .../features/workflows/ui/WorkflowCard.tsx | 13 +- .../features/workflows/ui/WorkflowDialog.tsx | 735 +++++++++++++--- .../workflows/ui/WorkflowFormBuilder.tsx | 825 +++++++++++++----- .../workflows/ui/WorkflowStepCard.tsx | 191 ++-- .../ui/WorkflowWebhookSecretDialog.tsx | 36 +- .../features/workflows/ui/WorkflowsScreen.tsx | 36 +- .../features/workflows/ui/WorkflowsView.tsx | 118 ++- .../workflows/ui/workflowEditorPane.test.mjs | 60 ++ .../workflows/ui/workflowEditorPane.ts | 32 + .../workflows/ui/workflowFormTypes.test.mjs | 134 +++ .../workflows/ui/workflowFormTypes.ts | 328 ++++++- desktop/tests/e2e/workflows.spec.ts | 248 +++++- 19 files changed, 2400 insertions(+), 593 deletions(-) create mode 100644 desktop/src/app/routes/lazyWorkflowsRouteScreen.ts delete mode 100644 desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx create mode 100644 desktop/src/features/workflows/ui/workflowEditorPane.test.mjs create mode 100644 desktop/src/features/workflows/ui/workflowEditorPane.ts create mode 100644 desktop/src/features/workflows/ui/workflowFormTypes.test.mjs diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a6a..b7d7cf4397b 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -175,6 +175,47 @@ export function useAppNavigation() { [commitNavigation], ); + const goNewWorkflow = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows", + search: { pane: "trigger", view: "create" }, + state: { workflowEditorHasOrigin: true }, + }, + behavior, + ), + [commitNavigation], + ); + + const goEditWorkflow = React.useCallback( + (workflowId: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows/$workflowId", + params: { workflowId }, + search: { pane: "trigger", view: "edit" }, + state: { workflowEditorHasOrigin: true }, + }, + behavior, + ), + [commitNavigation], + ); + + const goDuplicateWorkflow = React.useCallback( + (workflowId: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows/$workflowId", + params: { workflowId }, + search: { pane: "trigger", view: "duplicate" }, + state: { workflowEditorHasOrigin: true }, + }, + behavior, + ), + [commitNavigation], + ); + const goChannel = React.useCallback( ( channelId: string, @@ -330,9 +371,12 @@ export function useAppNavigation() { closeWorkflowDetail, goAgents, goChannel, + goDuplicateWorkflow, + goEditWorkflow, goForumPost, goHome, goNewMessage, + goNewWorkflow, goProject, goProjects, goPulse, diff --git a/desktop/src/app/routes/WorkflowsRouteScreen.tsx b/desktop/src/app/routes/WorkflowsRouteScreen.tsx index 0a0a4dfb367..0766d97b5e8 100644 --- a/desktop/src/app/routes/WorkflowsRouteScreen.tsx +++ b/desktop/src/app/routes/WorkflowsRouteScreen.tsx @@ -1,15 +1,39 @@ +import * as React from "react"; + import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; -import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen"; +import { + type WorkflowEditorRoute, + WorkflowsScreen, +} from "@/features/workflows/ui/WorkflowsScreen"; +import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane"; type WorkflowsRouteScreenProps = { + editor?: WorkflowEditorRoute | null; + onEditorPaneChange: (pane: WorkflowEditorPane) => void; selectedWorkflowId: string | null; }; export function WorkflowsRouteScreen({ + editor = null, + onEditorPaneChange, selectedWorkflowId, }: WorkflowsRouteScreenProps) { - const { closeWorkflowDetail, goWorkflow } = useAppNavigation(); + const { + closeWorkflowDetail, + goDuplicateWorkflow, + goEditWorkflow, + goNewWorkflow, + goWorkflow, + goWorkflows, + } = useAppNavigation(); + const closeEditor = React.useCallback(() => { + if (editor?.hasOrigin) { + window.history.back(); + return; + } + void goWorkflows({ replace: true }); + }, [editor?.hasOrigin, goWorkflows]); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; const memberChannels = channels.filter((channel) => channel.isMember); @@ -17,8 +41,20 @@ export function WorkflowsRouteScreen({ return ( { + onCreateWorkflow={() => { + void goNewWorkflow(); + }} + onDuplicateWorkflow={(workflowId) => { + void goDuplicateWorkflow(workflowId); + }} + onEditWorkflow={(workflowId) => { + void goEditWorkflow(workflowId); + }} + onEditorPaneChange={onEditorPaneChange} + onViewWorkflow={(workflowId) => { void goWorkflow(workflowId); }} selectedWorkflowId={selectedWorkflowId} diff --git a/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts b/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts new file mode 100644 index 00000000000..8def7e65024 --- /dev/null +++ b/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts @@ -0,0 +1,6 @@ +import * as React from "react"; + +export const LazyWorkflowsRouteScreen = React.lazy(async () => { + const module = await import("./WorkflowsRouteScreen"); + return { default: module.WorkflowsRouteScreen }; +}); diff --git a/desktop/src/app/routes/workflows.$workflowId.tsx b/desktop/src/app/routes/workflows.$workflowId.tsx index f6a74aa15d1..9b8a408d429 100644 --- a/desktop/src/app/routes/workflows.$workflowId.tsx +++ b/desktop/src/app/routes/workflows.$workflowId.tsx @@ -1,25 +1,62 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { + parseWorkflowEditorPane, + serializeWorkflowEditorPane, +} from "@/features/workflows/ui/workflowEditorPane"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen"; export const Route = createFileRoute("/workflows/$workflowId")({ - component: WorkflowDetailRouteComponent, + component: WorkflowRouteComponent, + validateSearch: (search: Record) => ({ + pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)), + view: + search.view === "edit" || search.view === "duplicate" + ? search.view + : undefined, + }), }); -const WorkflowsRouteScreen = React.lazy(async () => { - const module = await import("./WorkflowsRouteScreen"); - return { default: module.WorkflowsRouteScreen }; -}); - -function WorkflowDetailRouteComponent() { +function WorkflowRouteComponent() { usePreviewFeatureWarning("workflows"); + const navigate = Route.useNavigate(); + const location = useLocation(); const { workflowId } = Route.useParams(); + const { pane, view } = Route.useSearch(); + const hasOrigin = + (location.state as { workflowEditorHasOrigin?: unknown } | undefined) + ?.workflowEditorHasOrigin === true; + const editor: + | import("@/features/workflows/ui/WorkflowsScreen").WorkflowEditorRoute + | null = + view === "edit" || view === "duplicate" + ? { + hasOrigin, + mode: view, + pane: parseWorkflowEditorPane(pane), + workflowId, + } + : null; return ( }> - + { + void navigate({ + replace: true, + resetScroll: false, + search: { + pane: serializeWorkflowEditorPane(nextPane), + view, + }, + }); + }} + selectedWorkflowId={editor ? null : workflowId} + /> ); } diff --git a/desktop/src/app/routes/workflows.tsx b/desktop/src/app/routes/workflows.tsx index 7ab6461fd0b..76dd3c1561f 100644 --- a/desktop/src/app/routes/workflows.tsx +++ b/desktop/src/app/routes/workflows.tsx @@ -1,23 +1,55 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { + parseWorkflowEditorPane, + serializeWorkflowEditorPane, +} from "@/features/workflows/ui/workflowEditorPane"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen"; export const Route = createFileRoute("/workflows")({ component: WorkflowsRouteComponent, -}); - -const WorkflowsRouteScreen = React.lazy(async () => { - const module = await import("./WorkflowsRouteScreen"); - return { default: module.WorkflowsRouteScreen }; + validateSearch: (search: Record) => ({ + pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)), + view: search.view === "create" ? search.view : undefined, + }), }); function WorkflowsRouteComponent() { usePreviewFeatureWarning("workflows"); + const navigate = Route.useNavigate(); + const location = useLocation(); + const { pane, view } = Route.useSearch(); + const hasOrigin = + (location.state as { workflowEditorHasOrigin?: unknown } | undefined) + ?.workflowEditorHasOrigin === true; + return ( }> - + { + void navigate({ + replace: true, + resetScroll: false, + search: { + pane: serializeWorkflowEditorPane(nextPane), + view, + }, + }); + }} + selectedWorkflowId={null} + /> ); } diff --git a/desktop/src/features/workflows/ui/ChannelCombobox.tsx b/desktop/src/features/workflows/ui/ChannelCombobox.tsx index 11fb4327f82..c9d64a7052c 100644 --- a/desktop/src/features/workflows/ui/ChannelCombobox.tsx +++ b/desktop/src/features/workflows/ui/ChannelCombobox.tsx @@ -11,23 +11,37 @@ function formatChannelLabel(ch: Channel): string { type ChannelComboboxProps = { channels: Channel[]; + defaultOpen?: boolean; disabled?: boolean; id?: string; onChange: (value: string) => void; + readOnly?: boolean; + required?: boolean; + variant?: "header" | "field"; value: string; }; export function ChannelCombobox({ channels, + defaultOpen = false, disabled, id, onChange, + readOnly = false, + required = false, + variant = "header", value, }: ChannelComboboxProps) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const [highlightedIndex, setHighlightedIndex] = React.useState(0); + React.useEffect(() => { + if (!defaultOpen || readOnly) return; + const frame = window.requestAnimationFrame(() => setOpen(true)); + return () => window.cancelAnimationFrame(frame); + }, [defaultOpen, readOnly]); + const selected = channels.find((c) => c.id === value); const filtered = React.useMemo(() => { @@ -83,15 +97,18 @@ export function ChannelCombobox({ } return ( - + + + ); + } + + return ( +
+ + {generating ? "Generating name…" : name || "Untitled workflow"} + + +
+ ); +} + export function WorkflowDialog({ channels, mode, + onDeleteWorkflow, + onDuplicateWorkflow, + onEditWorkflow, + onEditorPaneChange, onOpenChange, + onTriggerWorkflow, open, + pane, workflow, }: WorkflowDialogProps) { + const formBuilderRef = React.useRef(null); + const workflowSnapshotRef = React.useRef(workflow); + const workflowSnapshot = workflowSnapshotRef.current; const channelId = - mode === "edit" && workflow?.channelId - ? workflow.channelId - : (channels[0]?.id ?? ""); + mode === "edit" && workflowSnapshot?.channelId + ? workflowSnapshot.channelId + : ""; const [selectedChannelId, setSelectedChannelId] = React.useState(channelId); const [yamlDefinition, setYamlDefinition] = React.useState(() => - getInitialYaml(mode, workflow), + getInitialYaml(mode, workflowSnapshot), + ); + const [editorMode, setEditorMode] = React.useState(() => + getInitialEditorMode(getInitialYaml(mode, workflowSnapshot)), ); + const [editorParseError, setEditorParseError] = React.useState( + null, + ); + const [workflowNameEditing, setWorkflowNameEditing] = React.useState(false); + const [nameLeadingElement, setNameLeadingElement] = + React.useState(null); const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{ - relayHttpUrl: string; + relayHttpUrl: string | null; + relayUrlError: string | null; webhookSecret: string; workflowId: string; } | null>(null); + const [discardConfirmationOpen, setDiscardConfirmationOpen] = + React.useState(false); + const [generatingName, setGeneratingName] = React.useState(false); + const initialValuesRef = React.useRef({ + channelId, + yaml: getInitialYaml(mode, workflowSnapshot), + }); + const yamlDefinitionRef = React.useRef(yamlDefinition); + const allowNavigationRef = React.useRef(false); + const proceedingNavigationRef = React.useRef(false); const createMutation = useCreateWorkflowMutation(selectedChannelId); const updateMutation = useUpdateWorkflowMutation( - workflow?.id ?? "", - workflow?.revision ?? "", + workflowSnapshot?.id ?? "", + workflowSnapshot?.revision ?? "", ); const mutation = mode === "edit" ? updateMutation : createMutation; const selectedChannel = channels.find((c) => c.id === selectedChannelId) ?? null; + const parsedDefinition = yamlDefinition.trim() + ? yamlToFormState(yamlDefinition) + : null; + const isAddingFirstStep = + mode === "create" && + editorMode === "form" && + (parsedDefinition === null || + (parsedDefinition.ok && parsedDefinition.state.steps.length === 0)); - const defaultChannelId = channels[0]?.id ?? ""; - const workflowChannelId = workflow?.channelId ?? null; const resetCreate = createMutation.reset; const resetUpdate = updateMutation.reset; - // Re-initialize when dialog opens or workflow/mode changes React.useEffect(() => { - if (open) { - const newChannelId = - mode === "edit" && workflowChannelId - ? workflowChannelId - : defaultChannelId; - setSelectedChannelId(newChannelId); - setYamlDefinition(getInitialYaml(mode, workflow)); - setSavedWebhookInfo(null); - resetCreate(); - resetUpdate(); + let active = true; + setSavedWebhookInfo(null); + setDiscardConfirmationOpen(false); + resetCreate(); + resetUpdate(); + + if (mode === "create" && !workflowSnapshot) { + setGeneratingName(true); + void generateBackupPassphrase({ words: 3, separator: "-" }) + .then((name) => { + if (!active || yamlDefinitionRef.current.trim()) return; + const generatedYaml = formStateToYaml({ + ...DEFAULT_FORM_STATE, + name, + }); + yamlDefinitionRef.current = generatedYaml; + initialValuesRef.current = { + ...initialValuesRef.current, + yaml: generatedYaml, + }; + setYamlDefinition(generatedYaml); + }) + .catch(() => { + // Leave the editable "Untitled workflow" fallback in place. + }) + .finally(() => { + if (active) setGeneratingName(false); + }); + } else { + setGeneratingName(false); + } + + return () => { + active = false; + }; + }, [mode, resetCreate, resetUpdate, workflowSnapshot]); + + const closeDialog = React.useCallback(() => { + resetCreate(); + resetUpdate(); + setDiscardConfirmationOpen(false); + onOpenChange(false); + }, [onOpenChange, resetCreate, resetUpdate]); + + const isDirty = + yamlDefinition !== initialValuesRef.current.yaml || + selectedChannelId !== initialValuesRef.current.channelId; + const navigationBlocker = useBlocker({ + enableBeforeUnload: isDirty, + shouldBlockFn: ({ current, next }) => { + const currentSearch = current.search as { + pane?: unknown; + view?: unknown; + }; + const nextSearch = next.search as { pane?: unknown; view?: unknown }; + const isPaneOnlyNavigation = + current.pathname === next.pathname && + currentSearch.view === nextSearch.view && + currentSearch.pane !== nextSearch.pane; + return isDirty && !allowNavigationRef.current && !isPaneOnlyNavigation; + }, + withResolver: true, + }); + + React.useEffect(() => { + if (navigationBlocker.status === "blocked") { + setDiscardConfirmationOpen(true); } - }, [ - open, - mode, - workflow, - workflowChannelId, - defaultChannelId, - resetCreate, - resetUpdate, - ]); + }, [navigationBlocker.status]); const handleOpenChange = React.useCallback( (nextOpen: boolean) => { - if (!nextOpen) { - resetCreate(); - resetUpdate(); + if (nextOpen) { + onOpenChange(true); + } else if (isDirty) { + setDiscardConfirmationOpen(true); + } else { + closeDialog(); } - onOpenChange(nextOpen); }, - [onOpenChange, resetCreate, resetUpdate], + [closeDialog, isDirty, onOpenChange], ); async function handleSubmit() { @@ -136,118 +396,351 @@ export function WorkflowDialog({ try { const saved = await mutation.mutateAsync(yamlDefinition); - handleOpenChange(false); + initialValuesRef.current = { + channelId: selectedChannelId, + yaml: yamlDefinition, + }; + allowNavigationRef.current = true; if (saved.webhookSecret) { - const relayHttpUrl = await getRelayHttpUrl(); - setSavedWebhookInfo({ - relayHttpUrl, + const webhookInfo = { + relayHttpUrl: null, + relayUrlError: null, webhookSecret: saved.webhookSecret, workflowId: saved.workflow.id, - }); + }; + setSavedWebhookInfo(webhookInfo); + try { + const relayHttpUrl = await getRelayHttpUrl(); + setSavedWebhookInfo({ ...webhookInfo, relayHttpUrl }); + } catch (error) { + setSavedWebhookInfo({ + ...webhookInfo, + relayUrlError: + error instanceof Error + ? error.message + : "Could not load the webhook URL", + }); + } + } else { + closeDialog(); } } catch { - // React Query stores the error; keep the dialog open. + // React Query stores the error; keep the dialog open and dirty. } } - const showChannelSelector = mode !== "edit" && channels.length > 1; - const showChannelInfo = mode !== "edit" && channels.length === 1; + const handleEditorModeChange = React.useCallback( + (nextMode: string) => { + if (nextMode === editorMode) return; + + if (nextMode === "yaml") { + setEditorParseError(null); + setEditorMode("yaml"); + return; + } + + if (!yamlDefinition.trim()) { + setEditorParseError(null); + setEditorMode("form"); + return; + } + + const result = yamlToFormState(yamlDefinition); + if (result.ok) { + setEditorParseError(null); + setEditorMode("form"); + } else { + setEditorParseError(result.error); + } + }, + [editorMode, yamlDefinition], + ); + + const workflowName = visibleWorkflowName( + yamlDefinition, + workflowSnapshot?.name, + ); + const canEditWorkflowName = + !yamlDefinition.trim() || yamlToFormState(yamlDefinition).ok; + const workflowEnabled = parsedDefinition?.ok + ? parsedDefinition.state.enabled + : workflowSnapshot + ? getWorkflowEnabled(workflowSnapshot.definition) + : true; + const handleWorkflowNameCommit = React.useCallback( + (name: string) => { + const nextYaml = yamlWithWorkflowName(yamlDefinitionRef.current, name); + if (nextYaml === null) return false; + mutation.reset(); + yamlDefinitionRef.current = nextYaml; + setYamlDefinition(nextYaml); + return true; + }, + [mutation.reset], + ); + const handleToggleWorkflowEnabled = React.useCallback(() => { + const nextYaml = yamlWithWorkflowEnabled( + yamlDefinitionRef.current, + !workflowEnabled, + ); + if (nextYaml === null) return; + mutation.reset(); + yamlDefinitionRef.current = nextYaml; + setYamlDefinition(nextYaml); + }, [mutation.reset, workflowEnabled]); + + const showChannelSelector = mode !== "edit"; return ( <> - - - - {TITLES[mode]} - - {mode === "edit" - ? "Modify the workflow definition." - : channels.length === 1 - ? "Create a workflow scoped to this channel." - : "Define a workflow and assign it to a channel."} - - - -
- {showChannelSelector ? ( -
- Channel - { - mutation.reset(); - setSelectedChannelId(value); - }} - value={selectedChannelId} + + + +
+ + {TITLES[mode]} + + + {mode === "edit" + ? "Update when this workflow runs and what it does." + : mode === "duplicate" + ? "Copy this workflow and adjust its details." + : "Automate actions when something happens in a channel."} + +
+ +
-

- {selectedChannel - ? `New workflows will belong to ${selectedChannel.name}.` - : "Join or create a channel before adding a workflow."} -

- ) : (showChannelInfo || mode === "edit") && selectedChannel ? ( -

- {mode === "edit" - ? "Editing workflow in" - : "This workflow will be created in"}{" "} - - {selectedChannel.name} - - . -

- ) : null} +
+
+ {mode === "edit" && workflowSnapshot ? ( + onDeleteWorkflow(workflowSnapshot)} + onDuplicate={() => onDuplicateWorkflow(workflowSnapshot.id)} + onEdit={() => onEditWorkflow(workflowSnapshot.id)} + onToggleEnabled={handleToggleWorkflowEnabled} + onTrigger={() => onTriggerWorkflow(workflowSnapshot.id)} + /> + ) : null} + + + +
+ +
{ mutation.reset(); + yamlDefinitionRef.current = yaml; setYamlDefinition(yaml); }} + onSelectedNodeChange={onEditorPaneChange} + parseError={editorParseError} + ref={formBuilderRef} + scopeField={ + showChannelSelector ? ( +
+ { + mutation.reset(); + setSelectedChannelId(value); + if (value) onEditorPaneChange({ type: "trigger" }); + }} + required + variant={editorMode === "yaml" ? "field" : "header"} + value={selectedChannelId} + /> + {channels.length === 0 ? ( +

+ Join or create a channel before adding a workflow. +

+ ) : null} +
+ ) : mode === "edit" && selectedChannel ? ( + + ) : null + } + selectedNode={pane} + workflowChannelId={selectedChannelId || null} yaml={yamlDefinition} /> - - {mutation.error instanceof Error ? ( -

- {mutation.error.message} -

- ) : null}
-
- - + {mutation.error.message} +

+ ) : null} + +
+ + + + Form + + + + YAML + + + +
+ + {isAddingFirstStep ? ( + + ) : ( + + )} +
+ { + setDiscardConfirmationOpen(nextOpen); + if ( + !nextOpen && + navigationBlocker.status === "blocked" && + !proceedingNavigationRef.current + ) { + navigationBlocker.reset(); + } + }} + open={discardConfirmationOpen} + > + + + Discard changes? + + Your unsaved workflow changes will be lost. + + + + + + + + + + + + + {savedWebhookInfo ? ( { if (!nextOpen) { - setSavedWebhookInfo(null); + allowNavigationRef.current = true; + closeDialog(); } }} open relayHttpUrl={savedWebhookInfo.relayHttpUrl} + relayUrlError={savedWebhookInfo.relayUrlError} webhookSecret={savedWebhookInfo.webhookSecret} workflowId={savedWebhookInfo.workflowId} /> diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index c2b7bfda1a7..86c1172ac1b 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -1,24 +1,37 @@ -import { Code, Plus } from "lucide-react"; +import { Check, ChevronDown, Plus, Trash2, X, Zap } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; +import { createPortal } from "react-dom"; +import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; -import { Checkbox } from "@/shared/ui/checkbox"; +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; +import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; import { WorkflowStepCard } from "./WorkflowStepCard"; -import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; +import type { WorkflowEditorPane } from "./workflowEditorPane"; +import { FieldLabel } from "./workflowFormPrimitives"; import { DEFAULT_FORM_STATE, + ACTION_LABELS, + SELECTABLE_ACTION_TYPES, + SELECTABLE_TRIGGER_TYPES, TRIGGER_LABELS, - TRIGGER_TYPES, formStateToYaml, nextStepId, yamlToFormState, } from "./workflowFormTypes"; import type { + ActionType, StepFormState, TriggerConfig, - TriggerType, WorkflowFormState, } from "./workflowFormTypes"; @@ -35,7 +48,7 @@ function TriggerConfigFields({ return (
- Filter expression (optional) + Condition (optional)

- Evalexpr filter — leave empty to trigger on all matching events. + Evalexpr. Empty matches all events.

); @@ -66,15 +79,12 @@ function TriggerConfigFields({ placeholder="e.g. thumbsup" value={trigger.emoji ?? ""} /> -

- Leave empty to trigger on any reaction. -

); case "webhook": return (

- A unique webhook URL will be generated when the workflow is created. + A unique URL is generated after creation.

); case "schedule": @@ -109,7 +119,7 @@ function TriggerConfigFields({ />

- Provide either a cron expression or a simple interval. + Use either cron or interval.

); @@ -119,33 +129,250 @@ function TriggerConfigFields({ } type WorkflowFormBuilderProps = { + channels: Channel[]; disabled?: boolean; + nameLeadingContainer?: HTMLElement | null; + mode: WorkflowEditorMode; onChange: (yaml: string) => void; + onSelectedNodeChange: (pane: WorkflowEditorPane) => void; + parseError: string | null; + scopeField?: React.ReactNode; + selectedNode: WorkflowEditorPane; + workflowChannelId?: string | null; yaml: string; }; -export function WorkflowFormBuilder({ +export type WorkflowFormBuilderHandle = { + addFirstStep: () => void; +}; + +export type WorkflowEditorMode = "form" | "yaml"; + +function nodePosition( + node: Exclude, + steps: StepFormState[], +): number { + if (node.type === "trigger") return 0; + const index = steps.findIndex((step) => step.id === node.stepId); + return index < 0 ? 0 : index + 1; +} + +const inspectorContentVariants = { + enter: (direction: number) => ({ + opacity: 0, + y: direction < 0 ? 12 : -12, + }), + center: { opacity: 1, y: 0 }, + exit: (direction: number) => ({ + opacity: 0, + y: direction < 0 ? -12 : 12, + }), +}; + +function InspectorTypeMenu({ + ariaLabel, disabled, + labels, onChange, - yaml, -}: WorkflowFormBuilderProps) { + options, + value, +}: { + ariaLabel: string; + disabled?: boolean; + labels: Record; + onChange: (value: T) => void; + options: readonly T[]; + value: T; +}) { + return ( + + + + + + {options.map((option) => ( + onChange(option)}> + + {labels[option]} + + ))} + + + ); +} + +function WorkflowNode({ + description, + disabled, + icon, + label, + number, + onAddAfter, + onClick, + onRemove, + selected, + showTitle = true, + subtitle, + title, +}: { + description: string; + disabled?: boolean; + icon?: React.ReactNode; + label: string; + number?: number; + onAddAfter: (action: ActionType) => void; + onClick: () => void; + onRemove?: () => void; + selected: boolean; + showTitle?: boolean; + subtitle?: string; + title: string; +}) { + const isNumbered = number !== undefined; + + return ( +
  • +
    + + + {onRemove ? ( + + ) : null} +
    + + + + + + + + {SELECTABLE_ACTION_TYPES.map((action) => ( + onAddAfter(action)} + > + {ACTION_LABELS[action]} + + ))} + + + +
  • + ); +} + +export const WorkflowFormBuilder = React.forwardRef< + WorkflowFormBuilderHandle, + WorkflowFormBuilderProps +>(function WorkflowFormBuilder( + { + channels: _channels, + disabled, + nameLeadingContainer, + mode, + onChange, + onSelectedNodeChange, + parseError, + scopeField, + selectedNode: selectedRouteNode, + workflowChannelId: _workflowChannelId, + yaml, + }, + ref, +) { // Parse once on mount instead of calling yamlToFormState three times const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null); - const [mode, setMode] = React.useState<"form" | "yaml">( - initialParseRef.current === null || initialParseRef.current.ok - ? "form" - : "yaml", - ); const [formState, setFormState] = React.useState( initialParseRef.current?.ok ? initialParseRef.current.state : DEFAULT_FORM_STATE, ); - const [parseError, setParseError] = React.useState( - initialParseRef.current !== null && !initialParseRef.current.ok - ? initialParseRef.current.error - : null, - ); + const selectedNode = + selectedRouteNode?.type === "trigger" || + (selectedRouteNode?.type === "step" && + formState.steps.some((step) => step.id === selectedRouteNode.stepId)) + ? selectedRouteNode + : null; + const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1); + const shouldReduceMotion = useReducedMotion(); + const previousModeRef = React.useRef(mode); + const pendingPaneReconciliationRef = React.useRef(null); const updateFormState = React.useCallback( (next: WorkflowFormState) => { @@ -155,31 +382,82 @@ export function WorkflowFormBuilder({ [onChange], ); - const handleToggleMode = React.useCallback(() => { - if (mode === "form") { - setMode("yaml"); - setParseError(null); - } else { - const result = yamlToFormState(yaml); - if (result.ok) { - setFormState(result.state); - setParseError(null); - setMode("form"); - } else { - setParseError(result.error); + React.useEffect(() => { + if (previousModeRef.current === mode) return; + previousModeRef.current = mode; + + if (mode === "yaml") { + onSelectedNodeChange(null); + return; + } + + const result = yamlToFormState(yaml); + if (result.ok) setFormState(result.state); + }, [mode, onSelectedNodeChange, yaml]); + + React.useEffect(() => { + if (pendingPaneReconciliationRef.current) { + if ( + selectedRouteNode?.type === pendingPaneReconciliationRef.current.type && + (selectedRouteNode?.type !== "step" || + (pendingPaneReconciliationRef.current.type === "step" && + selectedRouteNode.stepId === + pendingPaneReconciliationRef.current.stepId)) + ) { + pendingPaneReconciliationRef.current = null; } + return; } - }, [mode, yaml]); - - const addStep = React.useCallback(() => { - updateFormState({ - ...formState, - steps: [ - ...formState.steps, - { id: nextStepId(formState.steps), action: "delay" }, - ], - }); - }, [formState, updateFormState]); + if ( + mode === "form" && + selectedRouteNode?.type === "step" && + !formState.steps.some((step) => step.id === selectedRouteNode.stepId) + ) { + onSelectedNodeChange(null); + } + }, [formState.steps, mode, onSelectedNodeChange, selectedRouteNode]); + + const selectNode = React.useCallback( + (nextNode: Exclude) => { + if (selectedNode) { + const currentPosition = nodePosition(selectedNode, formState.steps); + const nextPosition = nodePosition(nextNode, formState.steps); + if (nextPosition !== currentPosition) { + setSelectionDirection(nextPosition < currentPosition ? -1 : 1); + } + } + onSelectedNodeChange(nextNode); + }, + [formState.steps, onSelectedNodeChange, selectedNode], + ); + + const insertStep = React.useCallback( + (index: number, action: ActionType) => { + const nextSteps = [...formState.steps]; + const newStep: StepFormState = { + id: nextStepId(formState.steps), + action, + }; + if (action === "call_webhook") { + newStep.method = "POST"; + } + nextSteps.splice(index, 0, newStep); + updateFormState({ + ...formState, + steps: nextSteps, + }); + selectNode({ type: "step", stepId: newStep.id }); + }, + [formState, selectNode, updateFormState], + ); + + React.useImperativeHandle( + ref, + () => ({ + addFirstStep: () => insertStep(0, "send_message"), + }), + [insertStep], + ); const removeStep = React.useCallback( (index: number) => { @@ -187,173 +465,328 @@ export function WorkflowFormBuilder({ ...formState, steps: formState.steps.filter((_, i) => i !== index), }); + + if (selectedNode?.type !== "step") return; + const selectedIndex = formState.steps.findIndex( + (step) => step.id === selectedNode.stepId, + ); + + if (selectedIndex === index) { + const fallbackPane = + index > 0 + ? { type: "step" as const, stepId: formState.steps[index - 1].id } + : formState.steps[index + 1] + ? { + type: "step" as const, + stepId: formState.steps[index + 1].id, + } + : ({ type: "trigger" } as const); + pendingPaneReconciliationRef.current = fallbackPane; + setSelectionDirection(-1); + onSelectedNodeChange(fallbackPane); + } }, - [formState, updateFormState], + [formState, onSelectedNodeChange, selectedNode, updateFormState], ); const updateStep = React.useCallback( (index: number, step: StepFormState) => { + const previousStep = formState.steps[index]; const next = [...formState.steps]; next[index] = step; updateFormState({ ...formState, steps: next }); + if ( + selectedNode?.type === "step" && + previousStep?.id === selectedNode.stepId && + step.id !== previousStep.id + ) { + onSelectedNodeChange({ type: "step", stepId: step.id }); + } }, - [formState, updateFormState], + [formState, onSelectedNodeChange, selectedNode, updateFormState], ); - return ( -
    -
    - -
    - - {parseError ? ( -

    - Cannot switch to form view: {parseError} -

    - ) : null} + const selectedStep = + selectedNode?.type === "step" + ? formState.steps.find((step) => step.id === selectedNode.stepId) + : undefined; + const selectedStepIndex = selectedStep + ? formState.steps.findIndex((step) => step.id === selectedStep.id) + : -1; - {mode === "yaml" ? ( -
    -