Polish workflow creation form

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 14:11:50 -07:00
parent b269e8df7e
commit 75019b0906
4 changed files with 318 additions and 196 deletions
@@ -43,21 +43,21 @@ function getInitialYaml(
}
const TITLES: Record<DialogMode, string> = {
create: "Create Workflow",
edit: "Edit Workflow",
duplicate: "Duplicate Workflow",
create: "Create workflow",
edit: "Edit workflow",
duplicate: "Duplicate workflow",
};
const SUBMIT_LABELS: Record<DialogMode, string> = {
create: "Create",
edit: "Save",
duplicate: "Create Copy",
create: "Create workflow",
edit: "Save changes",
duplicate: "Create copy",
};
const PENDING_LABELS: Record<DialogMode, string> = {
create: "Creating...",
edit: "Saving...",
duplicate: "Creating...",
create: "Creating…",
edit: "Saving…",
duplicate: "Creating…",
};
export function WorkflowDialog({
@@ -153,56 +153,57 @@ export function WorkflowDialog({
return (
<>
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-xl">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{TITLES[mode]}</DialogTitle>
<DialogDescription>
{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."}
? "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."}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
{showChannelSelector ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
<ChannelCombobox
channels={channels}
disabled={mutation.isPending}
id="wf-channel-select"
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
}}
value={selectedChannelId}
/>
<p className="text-xs text-muted-foreground">
{selectedChannel
? `New workflows will belong to ${selectedChannel.name}.`
: "Join or create a channel before adding a workflow."}
</p>
</div>
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
<p className="text-sm text-muted-foreground">
{mode === "edit"
? "Editing workflow in"
: "This workflow will be created in"}{" "}
<span className="font-medium text-foreground">
{selectedChannel.name}
</span>
.
</p>
) : null}
<WorkflowFormBuilder
activationLabel={
mode === "edit" ? "Workflow enabled" : "Enable after creation"
}
disabled={mutation.isPending}
onChange={(yaml) => {
mutation.reset();
setYamlDefinition(yaml);
}}
scopeField={
showChannelSelector ? (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
<ChannelCombobox
channels={channels}
disabled={mutation.isPending}
id="wf-channel-select"
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
}}
value={selectedChannelId}
/>
{!selectedChannel ? (
<p className="text-xs text-muted-foreground">
Join or create a channel before adding a workflow.
</p>
) : null}
</div>
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
<div className="space-y-1">
<FieldLabel>Channel</FieldLabel>
<p className="text-sm font-medium text-foreground">
{selectedChannel.name}
</p>
</div>
) : null
}
yaml={yamlDefinition}
/>
@@ -1,9 +1,10 @@
import { Code, Plus } from "lucide-react";
import { Code, Plus, Zap } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
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";
@@ -35,7 +36,7 @@ function TriggerConfigFields({
return (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-filter">
Filter expression (optional)
Condition (optional)
</FieldLabel>
<Input
autoCapitalize="off"
@@ -47,7 +48,8 @@ function TriggerConfigFields({
value={trigger.filter ?? ""}
/>
<p className="text-xs text-muted-foreground">
Evalexpr filter — leave empty to trigger on all matching events.
Run only when this evalexpr expression matches. Leave empty to run
for every matching event.
</p>
</div>
);
@@ -119,14 +121,57 @@ function TriggerConfigFields({
}
type WorkflowFormBuilderProps = {
activationLabel: string;
disabled?: boolean;
onChange: (yaml: string) => void;
scopeField?: React.ReactNode;
yaml: string;
};
function FormSection({
children,
description,
title,
}: {
children: React.ReactNode;
description?: string;
title: string;
}) {
return (
<section className="space-y-3 border-t border-border/70 pt-4">
<div className="space-y-0.5">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
{children}
</section>
);
}
function FlowNode({
children,
marker,
}: {
children: React.ReactNode;
marker: React.ReactNode;
}) {
return (
<li className="relative grid grid-cols-[2rem_minmax(0,1fr)] gap-3 pb-4 last:pb-0">
<div className="relative z-10 flex h-8 w-8 items-center justify-center rounded-full border border-muted-foreground/50 bg-background text-xs font-semibold text-foreground">
{marker}
</div>
<div className="min-w-0">{children}</div>
</li>
);
}
export function WorkflowFormBuilder({
activationLabel,
disabled,
onChange,
scopeField,
yaml,
}: WorkflowFormBuilderProps) {
// Parse once on mount instead of calling yamlToFormState three times
@@ -155,21 +200,29 @@ 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);
const handleModeChange = React.useCallback(
(nextMode: string) => {
if (nextMode === mode) return;
if (nextMode === "yaml") {
setMode("yaml");
setParseError(null);
setMode("form");
} else {
setParseError(result.error);
return;
}
}
}, [mode, yaml]);
if (nextMode === "form") {
const result = yamlToFormState(yaml);
if (result.ok) {
setFormState(result.state);
setParseError(null);
setMode("form");
} else {
setParseError(result.error);
}
}
},
[mode, yaml],
);
const addStep = React.useCallback(() => {
updateFormState({
@@ -201,19 +254,27 @@ export function WorkflowFormBuilder({
);
return (
<div className="space-y-4">
<div className="space-y-5">
<div className="flex items-center justify-end">
<Button
className="h-7 gap-1.5 text-xs"
disabled={disabled}
onClick={handleToggleMode}
size="sm"
type="button"
variant="ghost"
>
<Code className="h-4 w-4" />
{mode === "form" ? "Edit as YAML" : "Back to form"}
</Button>
<Tabs onValueChange={handleModeChange} value={mode}>
<TabsList aria-label="Workflow editor mode" className="h-8 p-0.5">
<TabsTrigger
className="h-7 px-3 text-xs"
disabled={disabled}
value="form"
>
Form
</TabsTrigger>
<TabsTrigger
className="h-7 gap-1.5 px-3 text-xs"
disabled={disabled}
value="yaml"
>
<Code className="h-3.5 w-3.5" />
YAML
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{parseError ? (
@@ -223,135 +284,178 @@ export function WorkflowFormBuilder({
) : null}
{mode === "yaml" ? (
<div className="space-y-1.5">
<Textarea
autoCapitalize="off"
className="min-h-[240px] resize-y font-mono text-xs"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
value={yaml}
/>
<p className="text-xs text-muted-foreground">
Edit the raw YAML definition directly.
</p>
<div className="space-y-4">
{scopeField}
<div className="space-y-1.5">
<Textarea
aria-label="Workflow YAML"
autoCapitalize="off"
className="min-h-[240px] resize-y font-mono text-xs"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
value={yaml}
/>
<p className="text-xs text-muted-foreground">
Edit the raw YAML definition directly.
</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-name">Workflow name</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id="wf-name"
onChange={(event) =>
updateFormState({ ...formState, name: event.target.value })
}
placeholder="e.g. deploy_notifier"
value={formState.name}
/>
</div>
<div className="space-y-5">
<FormSection title="Basics">
<div
className={scopeField ? "grid gap-3 sm:grid-cols-2" : undefined}
>
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-name">Workflow name</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id="wf-name"
onChange={(event) =>
updateFormState({ ...formState, name: event.target.value })
}
placeholder="e.g. deploy_notifier"
value={formState.name}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-description">
Description (optional)
</FieldLabel>
<Textarea
autoCapitalize="off"
className="min-h-[72px] resize-y text-sm"
disabled={disabled}
id="wf-description"
onChange={(event) =>
updateFormState({
...formState,
description: event.target.value,
})
}
placeholder="What does this workflow do?"
value={formState.description}
/>
</div>
{scopeField}
</div>
<div className="flex items-center gap-2 rounded-md border border-border/70 px-3 py-2">
<Checkbox
checked={formState.enabled}
disabled={disabled}
id="wf-enabled"
onCheckedChange={(checked) =>
updateFormState({
...formState,
enabled: checked === true,
})
}
/>
<label className="text-sm" htmlFor="wf-enabled">
Workflow is enabled
</label>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-type">Trigger</FieldLabel>
<FormSelect
<FieldLabel htmlFor="wf-description">
Description (optional)
</FieldLabel>
<Textarea
autoCapitalize="off"
className="min-h-[60px] resize-y text-sm"
disabled={disabled}
id="wf-trigger-type"
onChange={(value) =>
id="wf-description"
onChange={(event) =>
updateFormState({
...formState,
trigger: { on: value as TriggerType },
description: event.target.value,
})
}
value={formState.trigger.on}
>
{TRIGGER_TYPES.map((type) => (
<option key={type} value={type}>
{TRIGGER_LABELS[type]}
</option>
))}
</FormSelect>
placeholder="What does this workflow do?"
value={formState.description}
/>
</div>
<TriggerConfigFields
onUpdate={(trigger) => updateFormState({ ...formState, trigger })}
trigger={formState.trigger}
/>
</div>
</FormSection>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FieldLabel>Steps</FieldLabel>
<Button
className="h-7 gap-1.5 text-xs"
disabled={disabled}
onClick={addStep}
size="sm"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
Add step
</Button>
</div>
<FormSection
description="Events move from the trigger through each step in order."
title="Workflow"
>
<div className="relative">
<div
aria-hidden
className="absolute bottom-4 left-4 top-4 w-0.5 -translate-x-1/2 bg-muted-foreground/40"
/>
<ol aria-label="Workflow sequence" className="relative">
<FlowNode marker={<Zap className="h-4 w-4" />}>
<div className="space-y-3 rounded-lg border border-border/70 bg-muted/10 p-3">
<div className="space-y-0.5">
<p className="text-sm font-medium text-foreground">
Trigger
</p>
<p className="text-xs text-muted-foreground">
Starts the workflow
</p>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-type">Event</FieldLabel>
<FormSelect
disabled={disabled}
id="wf-trigger-type"
onChange={(value) =>
updateFormState({
...formState,
trigger: { on: value as TriggerType },
})
}
value={formState.trigger.on}
>
{TRIGGER_TYPES.map((type) => (
<option key={type} value={type}>
{TRIGGER_LABELS[type]}
</option>
))}
</FormSelect>
</div>
<TriggerConfigFields
onUpdate={(trigger) =>
updateFormState({ ...formState, trigger })
}
trigger={formState.trigger}
/>
</div>
</FlowNode>
{formState.steps.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
No steps yet — add one to get started.
</p>
) : (
<div className="space-y-2">
{formState.steps.map((step, index) => (
<WorkflowStepCard
disabled={disabled}
index={index}
key={step.id}
onRemove={() => removeStep(index)}
onUpdate={(updated) => updateStep(index, updated)}
step={step}
triggerType={formState.trigger.on}
/>
<FlowNode key={step.id} marker={index + 1}>
<WorkflowStepCard
disabled={disabled}
index={index}
onRemove={() => removeStep(index)}
onUpdate={(updated) => updateStep(index, updated)}
step={step}
triggerType={formState.trigger.on}
/>
</FlowNode>
))}
<FlowNode marker={<Plus className="h-4 w-4" />}>
<Button
className="h-auto w-full justify-start whitespace-normal border-dashed px-3 py-3 text-left"
disabled={disabled}
onClick={addStep}
type="button"
variant="outline"
>
<span className="space-y-0.5">
<span className="block text-sm font-medium">
Add step
</span>
<span className="block text-xs font-normal text-muted-foreground">
Runs after{" "}
{formState.steps.length > 0
? `step ${formState.steps.length}`
: "the trigger"}
</span>
</span>
</Button>
</FlowNode>
</ol>
</div>
</FormSection>
<FormSection title="Activation">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<label
className="block text-sm font-medium text-foreground"
htmlFor="wf-enabled"
>
{activationLabel}
</label>
<p className="text-xs text-muted-foreground">
Turn this off to keep the workflow paused.
</p>
</div>
)}
</div>
<Switch
checked={formState.enabled}
disabled={disabled}
id="wf-enabled"
onCheckedChange={(checked) =>
updateFormState({ ...formState, enabled: checked })
}
/>
</div>
</FormSection>
</div>
)}
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ async function createWorkflow(
await expect(dialog).toBeVisible();
await dialog.getByLabel("Workflow name").fill(name);
await dialog.getByRole("button", { name: "Add step" }).click();
await dialog.getByRole("button", { name: "Create" }).click();
await dialog.getByRole("button", { name: "Create workflow" }).click();
await expect(dialog).not.toBeVisible();
}
+25 -8
View File
@@ -34,10 +34,10 @@ async function createWorkflow(
await dialog.getByLabel("Description (optional)").fill(options.description);
}
if (options?.enabled === false) {
await dialog.getByLabel("Workflow is enabled").click();
await dialog.getByLabel("Enable after creation").click();
}
if (options?.trigger) {
await dialog.getByLabel("Trigger").selectOption(options.trigger);
await dialog.getByLabel("Event").selectOption(options.trigger);
}
await dialog.getByRole("button", { name: "Add step" }).click();
@@ -55,10 +55,10 @@ async function createWorkflow(
.fill(options.stepTimeoutSecs);
}
await dialog.getByRole("button", { name: "Create" }).click();
await dialog.getByRole("button", { name: "Create workflow" }).click();
await expect(
page.getByRole("heading", { name: "Create Workflow" }),
page.getByRole("heading", { name: "Create workflow" }),
).not.toBeVisible();
}
@@ -99,6 +99,23 @@ test("disables autocapitalization in the workflow form", async ({ page }) => {
);
});
test("switches between the form and YAML editors", async ({ page }) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
const nameInput = dialog.getByLabel("Workflow name");
await nameInput.fill("yaml_round_trip");
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/name: yaml_round_trip/,
);
await dialog.getByRole("tab", { name: "Form" }).click();
await expect(nameInput).toHaveValue("yaml_round_trip");
});
test("captures disabled diff workflows in the list UI", async ({ page }) => {
const workflowName = `diff_workflow_${Date.now()}`;
const description = "Watches diff events for src/ changes";
@@ -157,7 +174,7 @@ test("edits an existing workflow", async ({ page }) => {
// Dialog should open in edit mode
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Edit Workflow")).toBeVisible();
await expect(page.getByText("Edit workflow")).toBeVisible();
// Change the name
const nameInput = page.getByLabel("Workflow name");
@@ -165,7 +182,7 @@ test("edits an existing workflow", async ({ page }) => {
await nameInput.fill(updatedName);
// Save
await page.getByRole("button", { name: "Save" }).click();
await page.getByRole("button", { name: "Save changes" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
// Verify the updated name appears
@@ -184,10 +201,10 @@ test("duplicates a workflow", async ({ page }) => {
// Dialog should open in duplicate mode with "(copy)" suffix
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
await expect(page.getByText("Duplicate workflow")).toBeVisible();
// Submit the duplicate
await page.getByRole("button", { name: "Create Copy" }).click();
await page.getByRole("button", { name: "Create copy" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
// Both the original and copy should exist