diff --git a/desktop/src/features/workflows/ui/ChannelCombobox.tsx b/desktop/src/features/workflows/ui/ChannelCombobox.tsx index 11fb4327f..901e540d4 100644 --- a/desktop/src/features/workflows/ui/ChannelCombobox.tsx +++ b/desktop/src/features/workflows/ui/ChannelCombobox.tsx @@ -1,12 +1,14 @@ -import { Check, ChevronsUpDown, Search } from "lucide-react"; +import { Check, ChevronDown, Hash, Lock, Search } from "lucide-react"; import * as React from "react"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; -function formatChannelLabel(ch: Channel): string { - return `${ch.name} · ${ch.channelType} · ${ch.visibility}`; +function ChannelPrivacyIcon({ channel }: { channel: Channel }) { + const Icon = channel.visibility === "private" ? Lock : Hash; + + return ; } type ChannelComboboxProps = { @@ -86,9 +88,10 @@ export function ChannelCombobox({ - - {selected ? formatChannelLabel(selected) : "Select a channel..."} + + {selected ? : null} + + {selected ? selected.name : "Select a channel..."} + + - @@ -124,7 +131,10 @@ export function ChannelCombobox({ value={query} /> - + {filtered.length === 0 ? ( No channels found. @@ -142,18 +152,19 @@ export function ChannelCombobox({ onClick={() => selectChannel(channel.id)} type="button" > - + {channel.name}{" "} - · {channel.channelType} · {channel.visibility} + · {channel.channelType} + )) )} diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index 89e13c1d7..119b2aac3 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { Code } from "lucide-react"; import { stringify as yamlStringify } from "yaml"; import { @@ -8,6 +9,7 @@ import { import type { Channel, Workflow } from "@/shared/api/types"; import { getRelayHttpUrl } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Dialog, DialogContent, @@ -16,9 +18,12 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { ChannelCombobox } from "./ChannelCombobox"; -import { WorkflowFormBuilder } from "./WorkflowFormBuilder"; +import { + WorkflowFormBuilder, + type WorkflowEditorMode, +} from "./WorkflowFormBuilder"; import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog"; -import { FieldLabel } from "./workflowFormPrimitives"; +import { yamlToFormState } from "./workflowFormTypes"; type DialogMode = "create" | "edit" | "duplicate"; @@ -42,6 +47,11 @@ function getInitialYaml( return yamlStringify(def); } +function getInitialEditorMode(yaml: string): WorkflowEditorMode { + if (!yaml) return "form"; + return yamlToFormState(yaml).ok ? "form" : "yaml"; +} + const TITLES: Record = { create: "Create workflow", edit: "Edit workflow", @@ -76,6 +86,14 @@ export function WorkflowDialog({ const [yamlDefinition, setYamlDefinition] = React.useState(() => getInitialYaml(mode, workflow), ); + const [editorMode, setEditorMode] = React.useState(() => + getInitialEditorMode(getInitialYaml(mode, workflow)), + ); + const [editorParseError, setEditorParseError] = React.useState( + null, + ); + const [footerLeadingElement, setFooterLeadingElement] = + React.useState(null); const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{ relayHttpUrl: string; webhookSecret: string; @@ -102,7 +120,10 @@ export function WorkflowDialog({ ? workflowChannelId : defaultChannelId; setSelectedChannelId(newChannelId); - setYamlDefinition(getInitialYaml(mode, workflow)); + const initialYaml = getInitialYaml(mode, workflow); + setYamlDefinition(initialYaml); + setEditorMode(getInitialEditorMode(initialYaml)); + setEditorParseError(null); setSavedWebhookInfo(null); resetCreate(); resetUpdate(); @@ -147,6 +168,33 @@ export function WorkflowDialog({ } } + 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 showChannelSelector = mode !== "edit" && channels.length > 1; const showChannelInfo = mode !== "edit" && channels.length === 1; @@ -154,31 +202,51 @@ export function WorkflowDialog({ <> - - {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."} - + + + {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."} + + + + + + Form + + + + YAML + + + { mutation.reset(); setYamlDefinition(yaml); }} + parseError={editorParseError} scopeField={ showChannelSelector ? ( - - Channel + ) : (showChannelInfo || mode === "edit") && selectedChannel ? ( - - Channel - + + {selectedChannel.name} @@ -214,25 +281,30 @@ export function WorkflowDialog({ ) : null} - - handleOpenChange(false)} - type="button" - variant="outline" - > - Cancel - - - {mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]} - + + + + handleOpenChange(false)} + type="button" + variant="outline" + > + Cancel + + + {mutation.isPending + ? PENDING_LABELS[mode] + : SUBMIT_LABELS[mode]} + + diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index f8bd7a295..c6f5c0de5 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -1,14 +1,7 @@ -import { - ChevronDown, - Code, - GitBranch, - Plus, - Trash2, - X, - Zap, -} from "lucide-react"; +import { 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 { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; @@ -22,7 +15,6 @@ import { } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; -import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Textarea } from "@/shared/ui/textarea"; import { WorkflowStepCard } from "./WorkflowStepCard"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; @@ -142,81 +134,121 @@ function TriggerConfigFields({ } type WorkflowFormBuilderProps = { - activationLabel: string; disabled?: boolean; + footerLeadingContainer?: HTMLElement | null; + mode: WorkflowEditorMode; onChange: (yaml: string) => void; + parseError: string | null; scopeField?: React.ReactNode; yaml: string; }; +export type WorkflowEditorMode = "form" | "yaml"; + type SelectedNode = | { type: "trigger" } | { type: "step"; index: number } | null; +function nodePosition(node: Exclude): number { + return node.type === "trigger" ? 0 : node.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 WorkflowNode({ - connectsToNext, description, disabled, icon, label, + number, onAddAfter, onClick, + onRemove, selected, + showTitle = true, title, }: { - connectsToNext: boolean; description: string; disabled?: boolean; - icon: React.ReactNode; + icon?: React.ReactNode; label: string; + number?: number; onAddAfter: (action: ActionType) => void; onClick: () => void; + onRemove?: () => void; selected: boolean; + showTitle?: boolean; title: string; }) { + const isNumbered = number !== undefined; + return ( - - + - {icon} - - - - {title} + + {isNumbered ? number : icon} - - {description} + + {showTitle ? ( + + {title} + + ) : null} + + {description} + - - + - - + {onRemove ? ( + + + + ) : null} + + + - {connectsToNext ? ( - - ) : null} ); } export function WorkflowFormBuilder({ - activationLabel, disabled, + footerLeadingContainer, + mode, onChange, + parseError, scopeField, yaml, }: WorkflowFormBuilderProps) { // 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, setSelectedNode] = React.useState(null); + const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1); const shouldReduceMotion = useReducedMotion(); + const previousModeRef = React.useRef(mode); const updateFormState = React.useCallback( (next: WorkflowFormState) => { @@ -291,29 +311,31 @@ export function WorkflowFormBuilder({ [onChange], ); - const handleModeChange = React.useCallback( - (nextMode: string) => { - if (nextMode === mode) return; + React.useEffect(() => { + if (previousModeRef.current === mode) return; + previousModeRef.current = mode; - if (nextMode === "yaml") { - setMode("yaml"); - setParseError(null); - setSelectedNode(null); - return; - } + if (mode === "yaml") { + setSelectedNode(null); + return; + } - if (nextMode === "form") { - const result = yamlToFormState(yaml); - if (result.ok) { - setFormState(result.state); - setParseError(null); - setMode("form"); - } else { - setParseError(result.error); + const result = yamlToFormState(yaml); + if (result.ok) setFormState(result.state); + }, [mode, yaml]); + + const selectNode = React.useCallback( + (nextNode: Exclude) => { + if (selectedNode) { + const currentPosition = nodePosition(selectedNode); + const nextPosition = nodePosition(nextNode); + if (nextPosition !== currentPosition) { + setSelectionDirection(nextPosition < currentPosition ? -1 : 1); } } + setSelectedNode(nextNode); }, - [mode, yaml], + [selectedNode], ); const insertStep = React.useCallback( @@ -331,9 +353,9 @@ export function WorkflowFormBuilder({ ...formState, steps: nextSteps, }); - setSelectedNode({ type: "step", index }); + selectNode({ type: "step", index }); }, - [formState, updateFormState], + [formState, selectNode, updateFormState], ); const removeStep = React.useCallback( @@ -342,16 +364,22 @@ export function WorkflowFormBuilder({ ...formState, steps: formState.steps.filter((_, i) => i !== index), }); - setSelectedNode((current) => { - if (current?.type !== "step") return current; - if (current.index === index) return null; - if (current.index > index) { - return { type: "step", index: current.index - 1 }; - } - return current; - }); + + if (selectedNode?.type !== "step") return; + + if (selectedNode.index === index) { + setSelectionDirection(-1); + setSelectedNode( + index === 0 + ? { type: "trigger" } + : { type: "step", index: index - 1 }, + ); + } else if (selectedNode.index > index) { + setSelectionDirection(-1); + setSelectedNode({ type: "step", index: selectedNode.index - 1 }); + } }, - [formState, updateFormState], + [formState, selectedNode, updateFormState], ); const updateStep = React.useCallback( @@ -369,59 +397,34 @@ export function WorkflowFormBuilder({ : undefined; return ( - - - - Build the sequence, then select a node to configure it. - - - - - Form - - - - YAML - - - - + <> + + {parseError ? ( + + Cannot switch to form view: {parseError} + + ) : null} - {parseError ? ( - - Cannot switch to form view: {parseError} - - ) : null} - - {mode === "yaml" ? ( - - {scopeField} - - onChange(event.target.value)} - value={yaml} - /> - - Edit the raw YAML definition directly. - + {mode === "yaml" ? ( + + {scopeField} + + onChange(event.target.value)} + value={yaml} + /> + + Edit the raw YAML definition directly. + + - - ) : ( - - - + ) : ( + + Workflow name - {scopeField} - Description (optional) @@ -456,223 +457,212 @@ export function WorkflowFormBuilder({ value={formState.description} /> - - - - Activation - - - - {activationLabel} - - - updateFormState({ ...formState, enabled: checked }) - } - /> - - - - - - - - Workflow sequence - - - Steps run from top to bottom. - - - {!selectedNode ? ( - - Select a node to edit - - ) : null} + + + + {scopeField ? {scopeField} : null} + + } + label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`} + onAddAfter={(action) => insertStep(0, action)} + onClick={() => selectNode({ type: "trigger" })} + selected={selectedNode?.type === "trigger"} + title="Trigger" + /> + + {formState.steps.map((step, index) => { + const nodeTitle = + step.name?.trim() || ACTION_LABELS[step.action]; + return ( + insertStep(index + 1, action)} + onClick={() => selectNode({ type: "step", index })} + onRemove={() => removeStep(index)} + selected={ + selectedNode?.type === "step" && + selectedNode.index === index + } + showTitle={false} + title={`Step ${index + 1}`} + /> + ); + })} + + {formState.steps.length > 0 ? ( + + + End + + + ) : null} + - - - 0} - description={TRIGGER_LABELS[formState.trigger.on]} - disabled={disabled} - icon={} - label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`} - onAddAfter={(action) => insertStep(0, action)} - onClick={() => setSelectedNode({ type: "trigger" })} - selected={selectedNode?.type === "trigger"} - title="Trigger" - /> - - {formState.steps.map((step, index) => { - const nodeTitle = - step.name?.trim() || ACTION_LABELS[step.action]; - return ( - } - key={step.id} - label={`Step ${index + 1}: ${nodeTitle}`} - onAddAfter={(action) => insertStep(index + 1, action)} - onClick={() => setSelectedNode({ type: "step", index })} - selected={ - selectedNode?.type === "step" && - selectedNode.index === index - } - title={`Step ${index + 1}`} - /> - ); - })} - - {formState.steps.length > 0 ? ( - - - End - - - ) : null} - + + + {selectedNode ? ( + + + + + {selectedNode.type === "trigger" + ? "Trigger" + : `Step ${selectedNode.index + 1}`} + + + {selectedNode.type === "trigger" + ? TRIGGER_LABELS[formState.trigger.on] + : selectedStep?.name?.trim() || + (selectedStep + ? ACTION_LABELS[selectedStep.action] + : "Step")} + + + + {selectedNode.type === "step" && selectedStep ? ( + removeStep(selectedNode.index)} + size="icon" + type="button" + variant="ghost" + > + + + ) : null} + setSelectedNode(null)} + size="icon" + type="button" + variant="ghost" + > + + + + + + + + + {selectedNode.type === "trigger" ? ( + + + + Event + + + updateFormState({ + ...formState, + trigger: { on: value as TriggerType }, + }) + } + value={formState.trigger.on} + > + {TRIGGER_TYPES.map((type) => ( + + {TRIGGER_LABELS[type]} + + ))} + + + + updateFormState({ ...formState, trigger }) + } + trigger={formState.trigger} + /> + + ) : selectedStep ? ( + removeStep(selectedNode.index)} + onUpdate={(updated) => + updateStep(selectedNode.index, updated) + } + showHeader={false} + step={selectedStep} + triggerType={formState.trigger.on} + /> + ) : null} + + + + + ) : null} + - - - {selectedNode ? ( - + {mode === "form" && footerLeadingContainer + ? createPortal( + + + Enable + + + updateFormState({ ...formState, enabled: checked }) } - > - - - - {selectedNode.type === "trigger" - ? "Trigger" - : `Step ${selectedNode.index + 1}`} - - - {selectedNode.type === "trigger" - ? TRIGGER_LABELS[formState.trigger.on] - : selectedStep?.name?.trim() || - (selectedStep - ? ACTION_LABELS[selectedStep.action] - : "Step")} - - - - {selectedNode.type === "step" && selectedStep ? ( - removeStep(selectedNode.index)} - size="icon" - type="button" - variant="ghost" - > - - - ) : null} - setSelectedNode(null)} - size="icon" - type="button" - variant="ghost" - > - - - - - - - - - {selectedNode.type === "trigger" ? ( - - - - Event - - - updateFormState({ - ...formState, - trigger: { on: value as TriggerType }, - }) - } - value={formState.trigger.on} - > - {TRIGGER_TYPES.map((type) => ( - - {TRIGGER_LABELS[type]} - - ))} - - - - updateFormState({ ...formState, trigger }) - } - trigger={formState.trigger} - /> - - ) : selectedStep ? ( - removeStep(selectedNode.index)} - onUpdate={(updated) => - updateStep(selectedNode.index, updated) - } - showHeader={false} - step={selectedStep} - triggerType={formState.trigger.on} - /> - ) : null} - - - - - ) : null} - - - )} - + /> + , + footerLeadingContainer, + ) + : null} + > ); } diff --git a/desktop/src/shared/ui/popover.tsx b/desktop/src/shared/ui/popover.tsx index 4c161efe5..295b0c879 100644 --- a/desktop/src/shared/ui/popover.tsx +++ b/desktop/src/shared/ui/popover.tsx @@ -23,6 +23,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor; type PopoverContentProps = React.ComponentPropsWithoutRef< typeof PopoverPrimitive.Content > & { + portalled?: boolean; surface?: "default" | "textured"; textureSize?: CardTextureSize; textureTone?: CardTextureTone; @@ -36,6 +37,7 @@ const PopoverContent = React.forwardRef< { className, align = "center", + portalled = true, sideOffset, style, surface = "default", @@ -44,8 +46,8 @@ const PopoverContent = React.forwardRef< ...props }, ref, - ) => ( - + ) => { + const content = ( - - ), + ); + + return portalled ? ( + {content} + ) : ( + content + ); + }, ); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 23d629ec0..d75a1d3b0 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -34,7 +34,7 @@ async function createWorkflow( await dialog.getByLabel("Description (optional)").fill(options.description); } if (options?.enabled === false) { - await dialog.getByLabel("Enable after creation").click(); + await dialog.getByLabel("Enable").click(); } if (options?.trigger) { await dialog.getByRole("button", { name: /^Trigger:/ }).click(); @@ -102,6 +102,38 @@ test("disables autocapitalization in the workflow form", async ({ page }) => { ); }); +test("switches an empty workflow between form and YAML modes", async ({ + page, +}) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toBeVisible(); + + await dialog.getByRole("tab", { name: "Form" }).click(); + await expect(dialog.getByLabel("Workflow name")).toBeVisible(); +}); + +test("scrolls the channel list with the mouse wheel", async ({ page }) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("combobox", { name: "Channel" }).click(); + + const channelList = page.getByTestId("channel-combobox-list"); + await expect(channelList).toBeVisible(); + await channelList.hover(); + await page.mouse.wheel(0, 500); + + await expect + .poll(() => channelList.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); +}); + test("opens node configuration in a contextual inspector", async ({ page }) => { await navigateToWorkflows(page); @@ -123,12 +155,17 @@ test("opens node configuration in a contextual inspector", async ({ page }) => { await expect(inspector.getByLabel("Action")).toHaveValue("send_message"); await expect(inspector.getByLabel("Message text")).toBeVisible(); await expect(dialog.getByText("End", { exact: true })).toBeVisible(); - await expect( - dialog.getByRole("button", { name: /^Step 1:/ }), - ).toHaveAttribute("aria-pressed", "true"); + const stepNode = dialog.getByRole("button", { name: /^Step 1:/ }); + await expect(stepNode).toHaveAttribute("aria-pressed", "true"); - await inspector.getByRole("button", { name: "Close inspector" }).click(); - await expect(inspector).not.toBeVisible(); + await stepNode.hover(); + const removeStep = dialog.getByRole("button", { name: "Remove Step 1" }); + await expect(removeStep).toBeVisible(); + await removeStep.click(); + await expect(stepNode).not.toBeVisible(); + await expect(inspector).toBeVisible(); + await expect(inspector.getByLabel("Event")).toBeVisible(); + await expect(dialog.getByText("End", { exact: true })).not.toBeVisible(); }); test("switches between the form and YAML editors", async ({ page }) => {
No channels found. @@ -142,18 +152,19 @@ export function ChannelCombobox({ onClick={() => selectChannel(channel.id)} type="button" > - + {channel.name}{" "} - · {channel.channelType} · {channel.visibility} + · {channel.channelType} + )) )} diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index 89e13c1d7..119b2aac3 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { Code } from "lucide-react"; import { stringify as yamlStringify } from "yaml"; import { @@ -8,6 +9,7 @@ import { import type { Channel, Workflow } from "@/shared/api/types"; import { getRelayHttpUrl } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Dialog, DialogContent, @@ -16,9 +18,12 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { ChannelCombobox } from "./ChannelCombobox"; -import { WorkflowFormBuilder } from "./WorkflowFormBuilder"; +import { + WorkflowFormBuilder, + type WorkflowEditorMode, +} from "./WorkflowFormBuilder"; import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog"; -import { FieldLabel } from "./workflowFormPrimitives"; +import { yamlToFormState } from "./workflowFormTypes"; type DialogMode = "create" | "edit" | "duplicate"; @@ -42,6 +47,11 @@ function getInitialYaml( return yamlStringify(def); } +function getInitialEditorMode(yaml: string): WorkflowEditorMode { + if (!yaml) return "form"; + return yamlToFormState(yaml).ok ? "form" : "yaml"; +} + const TITLES: Record = { create: "Create workflow", edit: "Edit workflow", @@ -76,6 +86,14 @@ export function WorkflowDialog({ const [yamlDefinition, setYamlDefinition] = React.useState(() => getInitialYaml(mode, workflow), ); + const [editorMode, setEditorMode] = React.useState(() => + getInitialEditorMode(getInitialYaml(mode, workflow)), + ); + const [editorParseError, setEditorParseError] = React.useState( + null, + ); + const [footerLeadingElement, setFooterLeadingElement] = + React.useState(null); const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{ relayHttpUrl: string; webhookSecret: string; @@ -102,7 +120,10 @@ export function WorkflowDialog({ ? workflowChannelId : defaultChannelId; setSelectedChannelId(newChannelId); - setYamlDefinition(getInitialYaml(mode, workflow)); + const initialYaml = getInitialYaml(mode, workflow); + setYamlDefinition(initialYaml); + setEditorMode(getInitialEditorMode(initialYaml)); + setEditorParseError(null); setSavedWebhookInfo(null); resetCreate(); resetUpdate(); @@ -147,6 +168,33 @@ export function WorkflowDialog({ } } + 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 showChannelSelector = mode !== "edit" && channels.length > 1; const showChannelInfo = mode !== "edit" && channels.length === 1; @@ -154,31 +202,51 @@ export function WorkflowDialog({ <> - - {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."} - + + + {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."} + + + + + + Form + + + + YAML + + + { mutation.reset(); setYamlDefinition(yaml); }} + parseError={editorParseError} scopeField={ showChannelSelector ? ( - - Channel + ) : (showChannelInfo || mode === "edit") && selectedChannel ? ( - - Channel - + + {selectedChannel.name} @@ -214,25 +281,30 @@ export function WorkflowDialog({ ) : null} - - handleOpenChange(false)} - type="button" - variant="outline" - > - Cancel - - - {mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]} - + + + + handleOpenChange(false)} + type="button" + variant="outline" + > + Cancel + + + {mutation.isPending + ? PENDING_LABELS[mode] + : SUBMIT_LABELS[mode]} + + diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index f8bd7a295..c6f5c0de5 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -1,14 +1,7 @@ -import { - ChevronDown, - Code, - GitBranch, - Plus, - Trash2, - X, - Zap, -} from "lucide-react"; +import { 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 { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; @@ -22,7 +15,6 @@ import { } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; -import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Textarea } from "@/shared/ui/textarea"; import { WorkflowStepCard } from "./WorkflowStepCard"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; @@ -142,81 +134,121 @@ function TriggerConfigFields({ } type WorkflowFormBuilderProps = { - activationLabel: string; disabled?: boolean; + footerLeadingContainer?: HTMLElement | null; + mode: WorkflowEditorMode; onChange: (yaml: string) => void; + parseError: string | null; scopeField?: React.ReactNode; yaml: string; }; +export type WorkflowEditorMode = "form" | "yaml"; + type SelectedNode = | { type: "trigger" } | { type: "step"; index: number } | null; +function nodePosition(node: Exclude): number { + return node.type === "trigger" ? 0 : node.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 WorkflowNode({ - connectsToNext, description, disabled, icon, label, + number, onAddAfter, onClick, + onRemove, selected, + showTitle = true, title, }: { - connectsToNext: boolean; description: string; disabled?: boolean; - icon: React.ReactNode; + icon?: React.ReactNode; label: string; + number?: number; onAddAfter: (action: ActionType) => void; onClick: () => void; + onRemove?: () => void; selected: boolean; + showTitle?: boolean; title: string; }) { + const isNumbered = number !== undefined; + return ( - - + - {icon} - - - - {title} + + {isNumbered ? number : icon} - - {description} + + {showTitle ? ( + + {title} + + ) : null} + + {description} + - - + - - + {onRemove ? ( + + + + ) : null} + + + - {connectsToNext ? ( - - ) : null} ); } export function WorkflowFormBuilder({ - activationLabel, disabled, + footerLeadingContainer, + mode, onChange, + parseError, scopeField, yaml, }: WorkflowFormBuilderProps) { // 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, setSelectedNode] = React.useState(null); + const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1); const shouldReduceMotion = useReducedMotion(); + const previousModeRef = React.useRef(mode); const updateFormState = React.useCallback( (next: WorkflowFormState) => { @@ -291,29 +311,31 @@ export function WorkflowFormBuilder({ [onChange], ); - const handleModeChange = React.useCallback( - (nextMode: string) => { - if (nextMode === mode) return; + React.useEffect(() => { + if (previousModeRef.current === mode) return; + previousModeRef.current = mode; - if (nextMode === "yaml") { - setMode("yaml"); - setParseError(null); - setSelectedNode(null); - return; - } + if (mode === "yaml") { + setSelectedNode(null); + return; + } - if (nextMode === "form") { - const result = yamlToFormState(yaml); - if (result.ok) { - setFormState(result.state); - setParseError(null); - setMode("form"); - } else { - setParseError(result.error); + const result = yamlToFormState(yaml); + if (result.ok) setFormState(result.state); + }, [mode, yaml]); + + const selectNode = React.useCallback( + (nextNode: Exclude) => { + if (selectedNode) { + const currentPosition = nodePosition(selectedNode); + const nextPosition = nodePosition(nextNode); + if (nextPosition !== currentPosition) { + setSelectionDirection(nextPosition < currentPosition ? -1 : 1); } } + setSelectedNode(nextNode); }, - [mode, yaml], + [selectedNode], ); const insertStep = React.useCallback( @@ -331,9 +353,9 @@ export function WorkflowFormBuilder({ ...formState, steps: nextSteps, }); - setSelectedNode({ type: "step", index }); + selectNode({ type: "step", index }); }, - [formState, updateFormState], + [formState, selectNode, updateFormState], ); const removeStep = React.useCallback( @@ -342,16 +364,22 @@ export function WorkflowFormBuilder({ ...formState, steps: formState.steps.filter((_, i) => i !== index), }); - setSelectedNode((current) => { - if (current?.type !== "step") return current; - if (current.index === index) return null; - if (current.index > index) { - return { type: "step", index: current.index - 1 }; - } - return current; - }); + + if (selectedNode?.type !== "step") return; + + if (selectedNode.index === index) { + setSelectionDirection(-1); + setSelectedNode( + index === 0 + ? { type: "trigger" } + : { type: "step", index: index - 1 }, + ); + } else if (selectedNode.index > index) { + setSelectionDirection(-1); + setSelectedNode({ type: "step", index: selectedNode.index - 1 }); + } }, - [formState, updateFormState], + [formState, selectedNode, updateFormState], ); const updateStep = React.useCallback( @@ -369,59 +397,34 @@ export function WorkflowFormBuilder({ : undefined; return ( - - - - Build the sequence, then select a node to configure it. - - - - - Form - - - - YAML - - - - + <> + + {parseError ? ( + + Cannot switch to form view: {parseError} + + ) : null} - {parseError ? ( - - Cannot switch to form view: {parseError} - - ) : null} - - {mode === "yaml" ? ( - - {scopeField} - - onChange(event.target.value)} - value={yaml} - /> - - Edit the raw YAML definition directly. - + {mode === "yaml" ? ( + + {scopeField} + + onChange(event.target.value)} + value={yaml} + /> + + Edit the raw YAML definition directly. + + - - ) : ( - - - + ) : ( + + Workflow name - {scopeField} - Description (optional) @@ -456,223 +457,212 @@ export function WorkflowFormBuilder({ value={formState.description} /> - - - - Activation - - - - {activationLabel} - - - updateFormState({ ...formState, enabled: checked }) - } - /> - - - - - - - - Workflow sequence - - - Steps run from top to bottom. - - - {!selectedNode ? ( - - Select a node to edit - - ) : null} + + + + {scopeField ? {scopeField} : null} + + } + label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`} + onAddAfter={(action) => insertStep(0, action)} + onClick={() => selectNode({ type: "trigger" })} + selected={selectedNode?.type === "trigger"} + title="Trigger" + /> + + {formState.steps.map((step, index) => { + const nodeTitle = + step.name?.trim() || ACTION_LABELS[step.action]; + return ( + insertStep(index + 1, action)} + onClick={() => selectNode({ type: "step", index })} + onRemove={() => removeStep(index)} + selected={ + selectedNode?.type === "step" && + selectedNode.index === index + } + showTitle={false} + title={`Step ${index + 1}`} + /> + ); + })} + + {formState.steps.length > 0 ? ( + + + End + + + ) : null} + - - - 0} - description={TRIGGER_LABELS[formState.trigger.on]} - disabled={disabled} - icon={} - label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`} - onAddAfter={(action) => insertStep(0, action)} - onClick={() => setSelectedNode({ type: "trigger" })} - selected={selectedNode?.type === "trigger"} - title="Trigger" - /> - - {formState.steps.map((step, index) => { - const nodeTitle = - step.name?.trim() || ACTION_LABELS[step.action]; - return ( - } - key={step.id} - label={`Step ${index + 1}: ${nodeTitle}`} - onAddAfter={(action) => insertStep(index + 1, action)} - onClick={() => setSelectedNode({ type: "step", index })} - selected={ - selectedNode?.type === "step" && - selectedNode.index === index - } - title={`Step ${index + 1}`} - /> - ); - })} - - {formState.steps.length > 0 ? ( - - - End - - - ) : null} - + + + {selectedNode ? ( + + + + + {selectedNode.type === "trigger" + ? "Trigger" + : `Step ${selectedNode.index + 1}`} + + + {selectedNode.type === "trigger" + ? TRIGGER_LABELS[formState.trigger.on] + : selectedStep?.name?.trim() || + (selectedStep + ? ACTION_LABELS[selectedStep.action] + : "Step")} + + + + {selectedNode.type === "step" && selectedStep ? ( + removeStep(selectedNode.index)} + size="icon" + type="button" + variant="ghost" + > + + + ) : null} + setSelectedNode(null)} + size="icon" + type="button" + variant="ghost" + > + + + + + + + + + {selectedNode.type === "trigger" ? ( + + + + Event + + + updateFormState({ + ...formState, + trigger: { on: value as TriggerType }, + }) + } + value={formState.trigger.on} + > + {TRIGGER_TYPES.map((type) => ( + + {TRIGGER_LABELS[type]} + + ))} + + + + updateFormState({ ...formState, trigger }) + } + trigger={formState.trigger} + /> + + ) : selectedStep ? ( + removeStep(selectedNode.index)} + onUpdate={(updated) => + updateStep(selectedNode.index, updated) + } + showHeader={false} + step={selectedStep} + triggerType={formState.trigger.on} + /> + ) : null} + + + + + ) : null} + - - - {selectedNode ? ( - + {mode === "form" && footerLeadingContainer + ? createPortal( + + + Enable + + + updateFormState({ ...formState, enabled: checked }) } - > - - - - {selectedNode.type === "trigger" - ? "Trigger" - : `Step ${selectedNode.index + 1}`} - - - {selectedNode.type === "trigger" - ? TRIGGER_LABELS[formState.trigger.on] - : selectedStep?.name?.trim() || - (selectedStep - ? ACTION_LABELS[selectedStep.action] - : "Step")} - - - - {selectedNode.type === "step" && selectedStep ? ( - removeStep(selectedNode.index)} - size="icon" - type="button" - variant="ghost" - > - - - ) : null} - setSelectedNode(null)} - size="icon" - type="button" - variant="ghost" - > - - - - - - - - - {selectedNode.type === "trigger" ? ( - - - - Event - - - updateFormState({ - ...formState, - trigger: { on: value as TriggerType }, - }) - } - value={formState.trigger.on} - > - {TRIGGER_TYPES.map((type) => ( - - {TRIGGER_LABELS[type]} - - ))} - - - - updateFormState({ ...formState, trigger }) - } - trigger={formState.trigger} - /> - - ) : selectedStep ? ( - removeStep(selectedNode.index)} - onUpdate={(updated) => - updateStep(selectedNode.index, updated) - } - showHeader={false} - step={selectedStep} - triggerType={formState.trigger.on} - /> - ) : null} - - - - - ) : null} - - - )} - + /> + , + footerLeadingContainer, + ) + : null} + > ); } diff --git a/desktop/src/shared/ui/popover.tsx b/desktop/src/shared/ui/popover.tsx index 4c161efe5..295b0c879 100644 --- a/desktop/src/shared/ui/popover.tsx +++ b/desktop/src/shared/ui/popover.tsx @@ -23,6 +23,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor; type PopoverContentProps = React.ComponentPropsWithoutRef< typeof PopoverPrimitive.Content > & { + portalled?: boolean; surface?: "default" | "textured"; textureSize?: CardTextureSize; textureTone?: CardTextureTone; @@ -36,6 +37,7 @@ const PopoverContent = React.forwardRef< { className, align = "center", + portalled = true, sideOffset, style, surface = "default", @@ -44,8 +46,8 @@ const PopoverContent = React.forwardRef< ...props }, ref, - ) => ( - + ) => { + const content = ( - - ), + ); + + return portalled ? ( + {content} + ) : ( + content + ); + }, ); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 23d629ec0..d75a1d3b0 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -34,7 +34,7 @@ async function createWorkflow( await dialog.getByLabel("Description (optional)").fill(options.description); } if (options?.enabled === false) { - await dialog.getByLabel("Enable after creation").click(); + await dialog.getByLabel("Enable").click(); } if (options?.trigger) { await dialog.getByRole("button", { name: /^Trigger:/ }).click(); @@ -102,6 +102,38 @@ test("disables autocapitalization in the workflow form", async ({ page }) => { ); }); +test("switches an empty workflow between form and YAML modes", async ({ + page, +}) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toBeVisible(); + + await dialog.getByRole("tab", { name: "Form" }).click(); + await expect(dialog.getByLabel("Workflow name")).toBeVisible(); +}); + +test("scrolls the channel list with the mouse wheel", async ({ page }) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("combobox", { name: "Channel" }).click(); + + const channelList = page.getByTestId("channel-combobox-list"); + await expect(channelList).toBeVisible(); + await channelList.hover(); + await page.mouse.wheel(0, 500); + + await expect + .poll(() => channelList.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); +}); + test("opens node configuration in a contextual inspector", async ({ page }) => { await navigateToWorkflows(page); @@ -123,12 +155,17 @@ test("opens node configuration in a contextual inspector", async ({ page }) => { await expect(inspector.getByLabel("Action")).toHaveValue("send_message"); await expect(inspector.getByLabel("Message text")).toBeVisible(); await expect(dialog.getByText("End", { exact: true })).toBeVisible(); - await expect( - dialog.getByRole("button", { name: /^Step 1:/ }), - ).toHaveAttribute("aria-pressed", "true"); + const stepNode = dialog.getByRole("button", { name: /^Step 1:/ }); + await expect(stepNode).toHaveAttribute("aria-pressed", "true"); - await inspector.getByRole("button", { name: "Close inspector" }).click(); - await expect(inspector).not.toBeVisible(); + await stepNode.hover(); + const removeStep = dialog.getByRole("button", { name: "Remove Step 1" }); + await expect(removeStep).toBeVisible(); + await removeStep.click(); + await expect(stepNode).not.toBeVisible(); + await expect(inspector).toBeVisible(); + await expect(inspector.getByLabel("Event")).toBeVisible(); + await expect(dialog.getByText("End", { exact: true })).not.toBeVisible(); }); test("switches between the form and YAML editors", async ({ page }) => {
+
{selectedChannel.name}
- Build the sequence, then select a node to configure it. -
+ Cannot switch to form view: {parseError} +
- Cannot switch to form view: {parseError} -
- Edit the raw YAML definition directly. -
+ Edit the raw YAML definition directly. +
- Activation -
- Steps run from top to bottom. -
- Select a node to edit -
+ {selectedNode.type === "trigger" + ? "Trigger" + : `Step ${selectedNode.index + 1}`} +
- {selectedNode.type === "trigger" - ? "Trigger" - : `Step ${selectedNode.index + 1}`} -