Remove workflow step conditions from form

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 23:39:44 -07:00
parent c92338cde0
commit 8be1947168
6 changed files with 167 additions and 125 deletions
@@ -1,4 +1,3 @@
import { useQuery } from "@tanstack/react-query";
import {
ArrowDown,
CalendarClock,
@@ -19,11 +18,7 @@ import * as React from "react";
import { createPortal } from "react-dom";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
@@ -44,10 +39,7 @@ import {
} from "./WorkflowNodeDescriptions";
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
import { WorkflowStepCard } from "./WorkflowStepCard";
import {
buildConditionExpression,
parseConditionExpressions,
} from "./workflowConditionExpression";
import { buildConditionExpression } from "./workflowConditionExpression";
import { FieldLabel } from "./workflowFormPrimitives";
import {
DEFAULT_FORM_STATE,
@@ -68,8 +60,8 @@ import type {
WorkflowFormState,
} from "./workflowFormTypes";
import { defaultScheduleTrigger } from "./workflowSchedule";
import { workflowTriggerDescription } from "./workflowTriggerDescription";
import { workflowStepDescription } from "./workflowStepDescription";
import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation";
const TRIGGER_ICONS: Record<TriggerType, LucideIcon> = {
diff_posted: GitPullRequest,
@@ -414,83 +406,16 @@ export function WorkflowFormBuilder({
const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1);
const shouldReduceMotion = useReducedMotion();
const previousModeRef = React.useRef(mode);
const identityQuery = useIdentityQuery();
const customEmoji = useCustomEmoji();
const parsedTriggerConditions = React.useMemo(
() =>
formState.trigger.filter
? parseConditionExpressions(
formState.trigger.filter,
formState.trigger.on,
)
: [],
[formState.trigger.filter, formState.trigger.on],
);
const triggerReactionCondition = parsedTriggerConditions?.find(
(condition) =>
condition.field === "trigger_emoji" && condition.operator === "equals",
);
const triggerReaction =
formState.trigger.on === "reaction_added"
? triggerReactionCondition?.value || formState.trigger.emoji
: undefined;
const triggerPresentation = useWorkflowTriggerPresentation({
trigger: formState.trigger,
workflowChannelId,
});
const triggerReaction = triggerPresentation.emoji;
const triggerReactionUrl = triggerReaction
? reactionEmojiUrl(triggerReaction, customEmoji)
: undefined;
const triggerAuthorCondition = parsedTriggerConditions?.find(
(condition) => condition.field === "trigger_author",
);
const triggerAuthorPubkey =
triggerAuthorCondition &&
/^[0-9a-f]{64}$/i.test(triggerAuthorCondition.value)
? triggerAuthorCondition.value
: null;
const triggerAuthorProfiles = useUsersBatchQuery(
triggerAuthorPubkey ? [triggerAuthorPubkey] : [],
);
const triggerAuthorProfile = triggerAuthorPubkey
? triggerAuthorProfiles.data?.profiles[triggerAuthorPubkey.toLowerCase()]
: undefined;
const triggerAuthorLabel = triggerAuthorPubkey
? resolveUserLabel({
currentPubkey: identityQuery.data?.pubkey,
profiles: triggerAuthorProfiles.data?.profiles,
pubkey: triggerAuthorPubkey,
})
: null;
const triggerMessageCondition = parsedTriggerConditions?.find(
(condition) => condition.field === "trigger_message_id",
);
const triggerMessageId =
triggerMessageCondition &&
/^[0-9a-f]{64}$/i.test(triggerMessageCondition.value)
? triggerMessageCondition.value
: null;
const triggerMessageQuery = useQuery({
enabled: Boolean(triggerMessageId),
queryKey: ["workflow-trigger-message", workflowChannelId, triggerMessageId],
queryFn: () => getEventById(triggerMessageId ?? ""),
retry: false,
staleTime: 60_000,
});
const triggerMessage =
triggerMessageQuery.data &&
(!workflowChannelId ||
triggerMessageQuery.data.tags.some(
(tag) => tag[0] === "h" && tag[1] === workflowChannelId,
))
? triggerMessageQuery.data
: null;
const triggerMessageLabel = triggerMessage?.content.trim() || undefined;
const triggerMessageLoading =
Boolean(triggerMessageId) &&
triggerMessageQuery.isFetching &&
!triggerMessageQuery.data;
const triggerDescription = workflowTriggerDescription(formState.trigger, {
authorLabel: triggerAuthorLabel ?? undefined,
messageLabel: triggerMessageLabel,
messageLoading: triggerMessageLoading,
});
const triggerDescription = triggerPresentation.description;
const TriggerIcon = TRIGGER_ICONS[formState.trigger.on];
const updateFormState = React.useCallback(
@@ -658,10 +583,10 @@ export function WorkflowFormBuilder({
<WorkflowNode
description={
<TriggerNodeDescription
authorAvatarUrl={triggerAuthorProfile?.avatarUrl}
authorLabel={triggerAuthorLabel}
authorAvatarUrl={triggerPresentation.authorAvatarUrl}
authorLabel={triggerPresentation.authorLabel}
description={triggerDescription}
messageLoading={triggerMessageLoading}
messageLoading={triggerPresentation.messageLoading}
/>
}
disabled={disabled}
@@ -6,7 +6,6 @@ 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";
@@ -379,24 +378,6 @@ export function WorkflowStepCard({
/>
</section>
<section className="space-y-4 border-t border-border/50 py-5">
<SectionHeading title="Step condition" />
<p className="text-xs text-muted-foreground">
Checked after the workflow starts. If it does not match, Buzz skips
only this step and continues the run.
</p>
<WorkflowConditionBuilder
channelId={workflowChannelId}
channels={channels}
disabled={disabled}
idPrefix={`${prefix}-condition`}
matchAllLabel="Always run this step"
onChange={(condition) => onUpdate({ ...step, condition })}
triggerType={triggerType}
value={step.condition ?? ""}
/>
</section>
<section className="space-y-4 border-t border-border/50 py-5">
<SectionHeading title="Step timeout" />
<div className="space-y-1.5">
@@ -0,0 +1,95 @@
import { useQuery } from "@tanstack/react-query";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import { parseConditionExpressions } from "./workflowConditionExpression";
import { workflowTriggerDescription } from "./workflowTriggerDescription";
import type { TriggerConfig } from "./workflowFormTypes";
const FULL_HEX_ID = /^[0-9a-f]{64}$/i;
export type WorkflowTriggerPresentation = {
authorAvatarUrl?: string | null;
authorLabel?: string;
description: string;
emoji?: string;
messageLoading?: boolean;
};
export function useWorkflowTriggerPresentation({
trigger,
workflowChannelId,
}: {
trigger: TriggerConfig;
workflowChannelId?: string | null;
}): WorkflowTriggerPresentation {
const identityQuery = useIdentityQuery();
const conditions = trigger.filter
? parseConditionExpressions(trigger.filter, trigger.on)
: [];
const emojiCondition = conditions?.find(
(condition) =>
condition.field === "trigger_emoji" && condition.operator === "equals",
);
const emoji =
trigger.on === "reaction_added"
? emojiCondition?.value || trigger.emoji
: undefined;
const authorCondition = conditions?.find(
(condition) => condition.field === "trigger_author",
);
const authorPubkey =
authorCondition && FULL_HEX_ID.test(authorCondition.value)
? authorCondition.value
: null;
const profilesQuery = useUsersBatchQuery(authorPubkey ? [authorPubkey] : []);
const authorProfile = authorPubkey
? profilesQuery.data?.profiles[authorPubkey.toLowerCase()]
: undefined;
const authorLabel = authorPubkey
? resolveUserLabel({
currentPubkey: identityQuery.data?.pubkey,
profiles: profilesQuery.data?.profiles,
pubkey: authorPubkey,
})
: undefined;
const messageCondition = conditions?.find(
(condition) => condition.field === "trigger_message_id",
);
const messageId =
messageCondition && FULL_HEX_ID.test(messageCondition.value)
? messageCondition.value
: null;
const messageQuery = useQuery({
enabled: Boolean(messageId),
queryKey: ["workflow-trigger-message", workflowChannelId, messageId],
queryFn: () => getEventById(messageId ?? ""),
retry: false,
staleTime: 60_000,
});
const message =
messageQuery.data &&
(!workflowChannelId ||
messageQuery.data.tags.some(
(tag) => tag[0] === "h" && tag[1] === workflowChannelId,
))
? messageQuery.data
: null;
const messageLoading = Boolean(
messageId && messageQuery.isFetching && !messageQuery.data,
);
return {
authorAvatarUrl: authorProfile?.avatarUrl,
authorLabel,
description: workflowTriggerDescription(trigger, {
authorLabel,
messageLabel: message?.content.trim() || undefined,
messageLoading,
}),
emoji,
messageLoading,
};
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { yamlToFormState } from "./workflowFormTypes.ts";
test("keeps workflows with step conditions in the YAML editor", () => {
const result = yamlToFormState(`name: conditional_step
trigger:
on: message_posted
steps:
- id: notify
action: send_message
if: trigger_author == "abc"
text: hello
`);
assert.deepEqual(result, {
ok: false,
error: "Step conditions are only available in the YAML editor",
});
});
@@ -45,7 +45,6 @@ export type StepFormState = {
id: string;
name?: string;
action: ActionType;
condition?: string;
timeoutSecs?: string;
duration?: string;
text?: string;
@@ -137,7 +136,6 @@ function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
const fields: Record<string, unknown> = {};
if (step.name?.trim()) fields.name = step.name.trim();
if (step.condition?.trim()) fields.if = step.condition.trim();
const timeoutSecs = parseTimeoutSecs(step.timeoutSecs);
if (timeoutSecs !== undefined) fields.timeout_secs = timeoutSecs;
@@ -267,6 +265,12 @@ export function yamlToFormState(
error: `Unsupported action type "${step.action}" — use the YAML editor`,
};
}
if (step.if !== undefined) {
return {
ok: false,
error: "Step conditions are only available in the YAML editor",
};
}
}
const steps: StepFormState[] = rawSteps.map(
@@ -274,7 +278,6 @@ export function yamlToFormState(
id: (step.id as string) ?? `step_${index + 1}`,
name: step.name as string | undefined,
action: (step.action as ActionType) ?? ACTION_TYPES[0],
condition: step.if as string | undefined,
timeoutSecs:
step.timeout_secs !== undefined
? String(step.timeout_secs)
+34 -17
View File
@@ -336,28 +336,31 @@ test("builds a valid trigger condition from plain-language choices", async ({
await expect(inspector.getByLabel("Custom expression")).not.toBeVisible();
});
test("keeps a step condition separate from its action configuration", async ({
page,
}) => {
test("keeps conditions at the trigger level", async ({ page }) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Send message" }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
await expect(inspector.getByLabel("Message text")).toBeVisible();
await expect(
inspector.getByRole("heading", { name: "Step condition" }),
).toBeVisible();
await inspector.getByRole("button", { name: "Message text" }).click();
await inspector.getByLabel("Text to match").fill("deploy");
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Send message" }).click();
await expect(inspector.getByLabel("Message text")).toBeVisible();
await expect(
inspector.getByRole("heading", { name: "Step condition" }),
).toHaveCount(0);
await expect(inspector.getByRole("group", { name: "Condition" })).toHaveCount(
0,
);
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/if: str_contains\(trigger_text, "deploy"\)/,
/filter: str_contains\(trigger_text, "deploy"\)/,
);
await expect(dialog.getByLabel("Workflow YAML")).not.toHaveValue(/\n\s+if:/);
});
test("chooses a trigger author condition from live user search", async ({
@@ -777,13 +780,9 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
);
await expect(
inspector.getByRole("heading", { name: "Step condition" }),
).toBeVisible();
await expect(
inspector.getByRole("button", { name: "Always run this step" }),
).toHaveAttribute("aria-pressed", "true");
await expect(inspector.getByText(/skips only this step/)).toBeVisible();
).toHaveCount(0);
await expect(inspector.getByRole("group", { name: "Condition" })).toHaveCount(
1,
0,
);
await expect(
inspector.getByRole("heading", { name: "Step timeout" }),
@@ -872,6 +871,24 @@ test("switches between the form and YAML editors", async ({ page }) => {
await dialog.getByRole("tab", { name: "Form" }).click();
await expect(nameInput).toHaveValue("yaml_round_trip");
await dialog.getByRole("tab", { name: "YAML" }).click();
await dialog.getByLabel("Workflow YAML").fill(`name: conditional_step
trigger:
on: message_posted
steps:
- id: notify
action: send_message
if: trigger_author == "abc"
text: hello
`);
await dialog.getByRole("tab", { name: "Form" }).click();
await expect(
dialog.getByText(
"Cannot switch to form view: Step conditions are only available in the YAML editor",
),
).toBeVisible();
await expect(dialog.getByLabel("Workflow YAML")).toBeVisible();
});
test("captures disabled diff workflows in the list UI", async ({ page }) => {