From c463f043e4a6cb9cd343af0df0d772214573d6a8 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 12 Aug 2026 23:08:28 -0700 Subject: [PATCH] Polish workflow message inputs Signed-off-by: Taylor Ho --- .../features/workflows/ui/ChannelCombobox.tsx | 123 ++++++++---- .../features/workflows/ui/WorkflowDialog.tsx | 2 + .../workflows/ui/WorkflowFormBuilder.tsx | 11 ++ .../workflows/ui/WorkflowStepCard.tsx | 55 ++++-- .../workflows/ui/WorkflowTemplateTextarea.tsx | 179 ++++++++++++++++++ .../ui/workflowTemplateVariables.test.mjs | 61 ++++++ .../workflows/ui/workflowTemplateVariables.ts | 149 +++++++++++++++ desktop/tests/e2e/workflows.spec.ts | 33 ++++ 8 files changed, 565 insertions(+), 48 deletions(-) create mode 100644 desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx create mode 100644 desktop/src/features/workflows/ui/workflowTemplateVariables.test.mjs create mode 100644 desktop/src/features/workflows/ui/workflowTemplateVariables.ts diff --git a/desktop/src/features/workflows/ui/ChannelCombobox.tsx b/desktop/src/features/workflows/ui/ChannelCombobox.tsx index 7f219a1026..2562bbfba6 100644 --- a/desktop/src/features/workflows/ui/ChannelCombobox.tsx +++ b/desktop/src/features/workflows/ui/ChannelCombobox.tsx @@ -12,18 +12,28 @@ function ChannelPrivacyIcon({ channel }: { channel: Channel }) { } type ChannelComboboxProps = { + allowEmpty?: boolean; + ariaLabel?: string; channels: Channel[]; disabled?: boolean; + emptyLabel?: string; id?: string; + isChannelDisabled?: (channel: Channel) => boolean; onChange: (value: string) => void; + variant?: "header" | "field"; value: string; }; export function ChannelCombobox({ + allowEmpty = false, + ariaLabel = "Channel", channels, disabled, + emptyLabel = "Choose a channel", id, + isChannelDisabled, onChange, + variant = "header", value, }: ChannelComboboxProps) { const [open, setOpen] = React.useState(false); @@ -42,6 +52,10 @@ export function ChannelCombobox({ c.id.toLowerCase().includes(q), ); }, [channels, query]); + const selectable = React.useMemo( + () => filtered.filter((channel) => !isChannelDisabled?.(channel)), + [filtered, isChannelDisabled], + ); function handleOpenChange(next: boolean) { setOpen(next); @@ -57,22 +71,24 @@ export function ChannelCombobox({ } function handleKeyDown(e: React.KeyboardEvent) { - if (filtered.length === 0) return; + if (selectable.length === 0) return; switch (e.key) { case "ArrowDown": { e.preventDefault(); - setHighlightedIndex((i) => (i + 1) % filtered.length); + setHighlightedIndex((i) => (i + 1) % selectable.length); break; } case "ArrowUp": { e.preventDefault(); - setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length); + setHighlightedIndex( + (i) => (i - 1 + selectable.length) % selectable.length, + ); break; } case "Enter": { e.preventDefault(); - const target = filtered[highlightedIndex]; + const target = selectable[highlightedIndex]; if (target) selectChannel(target.id); break; } @@ -88,10 +104,14 @@ export function ChannelCombobox({
@@ -135,38 +164,62 @@ export function ChannelCombobox({ className="max-h-60 overflow-y-auto p-1" data-testid="channel-combobox-list" > + {allowEmpty && !query ? ( + + ) : null} {filtered.length === 0 ? (

No channels found.

) : ( - filtered.map((channel, index) => ( - - )) + disabled={optionDisabled} + key={channel.id} + onClick={() => selectChannel(channel.id)} + type="button" + > + + + {channel.name}{" "} + + · {channel.channelType} + + + + + ); + }) )}
diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index e5d97df42d..c053599807 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -256,6 +256,7 @@ export function WorkflowDialog({
) : null } + workflowChannelId={selectedChannelId || null} yaml={yamlDefinition} />
diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 3e3cce2dc6..1131ef4f05 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -11,6 +11,7 @@ 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 { cn } from "@/shared/lib/cn"; import { @@ -106,6 +107,7 @@ function TriggerConfigFields({ } type WorkflowFormBuilderProps = { + channels: Channel[]; disabled?: boolean; footerLeadingContainer?: HTMLElement | null; mode: WorkflowEditorMode; @@ -113,6 +115,7 @@ type WorkflowFormBuilderProps = { parseError: string | null; scopeField?: React.ReactNode; yaml: string; + workflowChannelId?: string | null; }; export type WorkflowEditorMode = "form" | "yaml"; @@ -323,6 +326,7 @@ function WorkflowNode({ } export function WorkflowFormBuilder({ + channels, disabled, footerLeadingContainer, mode, @@ -330,6 +334,7 @@ export function WorkflowFormBuilder({ parseError, scopeField, yaml, + workflowChannelId, }: WorkflowFormBuilderProps) { // Parse once on mount instead of calling yamlToFormState three times const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null); @@ -672,15 +677,21 @@ export function WorkflowFormBuilder({ ) : selectedStep ? ( removeStep(selectedNode.index)} onUpdate={(updated) => updateStep(selectedNode.index, updated) } + previousSteps={formState.steps.slice( + 0, + selectedNode.index, + )} showHeader={false} step={selectedStep} triggerType={formState.trigger.on} + workflowChannelId={workflowChannelId} /> ) : null} diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index bd690d49bf..18bc38f663 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -1,11 +1,14 @@ import { Trash2 } from "lucide-react"; +import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; +import { ChannelCombobox } from "./ChannelCombobox"; import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; +import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor"; import type { StepFormState, TriggerType } from "./workflowFormTypes"; @@ -49,13 +52,19 @@ function StepConfigFields({ step, prefix, disabled, + channels, + previousSteps, triggerType, + workflowChannelId, onUpdate, }: { step: StepFormState; prefix: string; disabled?: boolean; + channels: Channel[]; + previousSteps: StepFormState[]; triggerType: TriggerType; + workflowChannelId?: string | null; onUpdate: (step: StepFormState) => void; }) { switch (step.action) { @@ -80,15 +89,15 @@ function StepConfigFields({
Message text -