diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx
index c6f5c0de5..7db923907 100644
--- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx
@@ -1,4 +1,4 @@
-import { Plus, Trash2, X, Zap } 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";
@@ -9,15 +9,13 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
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 { FieldLabel } from "./workflowFormPrimitives";
import {
DEFAULT_FORM_STATE,
ACTION_LABELS,
@@ -32,7 +30,6 @@ import type {
ActionType,
StepFormState,
TriggerConfig,
- TriggerType,
WorkflowFormState,
} from "./workflowFormTypes";
@@ -166,6 +163,52 @@ const inspectorContentVariants = {
}),
};
+function InspectorTypeMenu({
+ ariaLabel,
+ disabled,
+ labels,
+ onChange,
+ 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,
@@ -177,6 +220,7 @@ function WorkflowNode({
onRemove,
selected,
showTitle = true,
+ subtitle,
title,
}: {
description: string;
@@ -189,6 +233,7 @@ function WorkflowNode({
onRemove?: () => void;
selected: boolean;
showTitle?: boolean;
+ subtitle?: string;
title: string;
}) {
const isNumbered = number !== undefined;
@@ -223,10 +268,15 @@ function WorkflowNode({
{showTitle ? (
-
+
{title}
) : null}
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
{description}
@@ -265,8 +315,6 @@ function WorkflowNode({
- Add action
-
{ACTION_TYPES.map((action) => (
{formState.steps.map((step, index) => {
- const nodeTitle =
- step.name?.trim() || ACTION_LABELS[step.action];
+ const stepName = step.name?.trim();
+ const actionLabel = ACTION_LABELS[step.action];
+ const nodeTitle = stepName || actionLabel;
return (
);
@@ -531,14 +581,36 @@ export function WorkflowFormBuilder({
? "Trigger"
: `Step ${selectedNode.index + 1}`}
-
- {selectedNode.type === "trigger"
- ? TRIGGER_LABELS[formState.trigger.on]
- : selectedStep?.name?.trim() ||
- (selectedStep
- ? ACTION_LABELS[selectedStep.action]
- : "Step")}
-
+ {selectedNode.type === "trigger" ? (
+
+ updateFormState({
+ ...formState,
+ trigger: { on: triggerType },
+ })
+ }
+ options={TRIGGER_TYPES}
+ value={formState.trigger.on}
+ />
+ ) : selectedStep ? (
+ {
+ const next = { ...selectedStep, action };
+ if (action === "call_webhook" && !next.method) {
+ next.method = "POST";
+ }
+ updateStep(selectedNode.index, next);
+ }}
+ options={ACTION_TYPES}
+ value={selectedStep.action}
+ />
+ ) : null}
{selectedNode.type === "step" && selectedStep ? (
@@ -592,27 +664,14 @@ export function WorkflowFormBuilder({
>
{selectedNode.type === "trigger" ? (
-
-
- Event
-
-
- updateFormState({
- ...formState,
- trigger: { on: value as TriggerType },
- })
- }
- value={formState.trigger.on}
- >
- {TRIGGER_TYPES.map((type) => (
-
- ))}
-
+
+
+ Trigger settings
+
+
+ Configure when this event should start the
+ workflow.
+
diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
index 5aeaa64cb..bd32555b9 100644
--- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
@@ -5,13 +5,8 @@ import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
-import { ACTION_LABELS, ACTION_TYPES } from "./workflowFormTypes";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
-import type {
- ActionType,
- StepFormState,
- TriggerType,
-} from "./workflowFormTypes";
+import type { StepFormState, TriggerType } from "./workflowFormTypes";
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
switch (action) {
@@ -150,9 +145,9 @@ function StepConfigFields({
);
case "call_webhook":
return (
-
+
-
URL
+
Endpoint URL
-
- Method (optional)
-
+
HTTP method
-
Body (optional)
+
+ Request body (optional)
+
+ );
+}
+
export function WorkflowStepCard({
bare = false,
showHeader = true,
@@ -319,12 +331,12 @@ export function WorkflowStepCard({
return (
{showHeader ? (
-
+
Step {index + 1}
@@ -342,60 +354,46 @@ export function WorkflowStepCard({
) : null}
-
-
-
-
-
Action
-
{
- const next = { ...step, action: value as ActionType };
- if (value === "call_webhook" && !next.method) {
- next.method = "POST";
- }
- onUpdate(next);
- }}
- value={step.action}
- >
- {ACTION_TYPES.map((action) => (
-
- ))}
-
+
+ Leave empty to run whenever the previous node completes.
+
-
+
-
-
- Run condition (optional)
-
-
- onUpdate({ ...step, condition: event.target.value })
- }
- placeholder='e.g. str_contains(trigger_text, "deploy")'
- value={step.condition ?? ""}
+
-
-
+
+
Name (optional)
+
+ onUpdate({ ...step, name: event.target.value })
+ }
+ placeholder="e.g. Notify deployment channel"
+ value={step.name ?? ""}
+ />
+
+ Shown on the node instead of the action name.
+
+
+
+
Step ID
+
onUpdate({ ...step, id: event.target.value })}
+ placeholder="unique_step_id"
+ value={step.id}
+ />
+
+ Unique key used by workflow configuration and history.
+
+
+
);
}
diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts
index d75a1d3b0..0259dbc1e 100644
--- a/desktop/tests/e2e/workflows.spec.ts
+++ b/desktop/tests/e2e/workflows.spec.ts
@@ -2,6 +2,14 @@ import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
+const TRIGGER_OPTION_LABELS: Record
= {
+ diff_posted: "Diff Posted",
+ message_posted: "Message Posted",
+ reaction_added: "Reaction Added",
+ schedule: "Schedule",
+ webhook: "Webhook",
+};
+
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -38,23 +46,22 @@ async function createWorkflow(
}
if (options?.trigger) {
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
- await dialog.getByLabel("Event").selectOption(options.trigger);
+ await dialog.getByLabel("Trigger event").click();
+ await page
+ .getByRole("menuitem", { name: TRIGGER_OPTION_LABELS[options.trigger] })
+ .click();
}
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Delay" }).click();
if (options?.stepName) {
- await dialog.getByLabel("Step name (optional)").fill(options.stepName);
+ await dialog.getByLabel("Name (optional)").fill(options.stepName);
}
if (options?.stepCondition) {
- await dialog
- .getByLabel("Run condition (optional)")
- .fill(options.stepCondition);
+ await dialog.getByLabel("Condition (optional)").fill(options.stepCondition);
}
if (options?.stepTimeoutSecs) {
- await dialog
- .getByLabel("Timeout seconds (optional)")
- .fill(options.stepTimeoutSecs);
+ await dialog.getByLabel("Timeout (seconds)").fill(options.stepTimeoutSecs);
}
await dialog.getByRole("button", { name: "Create workflow" }).click();
@@ -96,7 +103,7 @@ test("disables autocapitalization in the workflow form", async ({ page }) => {
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Delay" }).click();
- await expect(dialog.getByLabel("Step name (optional)")).toHaveAttribute(
+ await expect(dialog.getByLabel("Name (optional)")).toHaveAttribute(
"autocapitalize",
"off",
);
@@ -146,16 +153,33 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
await expect(inspector).toBeVisible();
- await expect(inspector.getByLabel("Event")).toBeVisible();
+ await expect(inspector.getByLabel("Trigger event")).toHaveAttribute(
+ "data-value",
+ "message_posted",
+ );
await dialog.getByRole("button", { name: "Add step" }).click();
- await expect(page.getByText("Add action", { exact: true })).toBeVisible();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
- await expect(inspector.getByLabel("Action")).toHaveValue("send_message");
+ await expect(inspector.getByLabel("Action")).toHaveAttribute(
+ "data-value",
+ "send_message",
+ );
await expect(inspector.getByLabel("Message text")).toBeVisible();
await expect(dialog.getByText("End", { exact: true })).toBeVisible();
+
+ await inspector.getByLabel("Action").click();
+ await page.getByRole("menuitem", { name: "Send DM" }).click();
+ await expect(inspector.getByLabel("Action")).toHaveAttribute(
+ "data-value",
+ "send_dm",
+ );
+ await expect(inspector.getByLabel("To (pubkey)")).toBeVisible();
+
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
+ await inspector.getByLabel("Name (optional)").fill("Notify deployer");
+ await expect(stepNode).toContainText("Notify deployer");
+ await expect(stepNode).toContainText("Send DM");
await expect(stepNode).toHaveAttribute("aria-pressed", "true");
await stepNode.hover();
@@ -164,7 +188,7 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
await removeStep.click();
await expect(stepNode).not.toBeVisible();
await expect(inspector).toBeVisible();
- await expect(inspector.getByLabel("Event")).toBeVisible();
+ await expect(inspector.getByLabel("Trigger event")).toBeVisible();
await expect(dialog.getByText("End", { exact: true })).not.toBeVisible();
});