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({
@@ -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} -
- - +
+
+
+ + +
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 (
  • - + - - + {onRemove ? ( + + ) : null} +
  • + +