mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Remove workflow step conditions from form
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user