From 20073b3a9d183459873ca75e533be775eed55b7f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 12 Aug 2026 20:40:21 -0700 Subject: [PATCH] feat(workflows): polish conditions and fix listing Signed-off-by: Taylor Ho --- crates/buzz-relay/src/handlers/req.rs | 45 ++- desktop/src-tauri/src/commands/workflows.rs | 34 +- .../src-tauri/src/commands/workflows_tests.rs | 22 ++ .../workflows/ui/WorkflowConditionBuilder.tsx | 291 ++++++++++++++++++ .../workflows/ui/WorkflowFormBuilder.tsx | 103 +++++-- .../workflows/ui/WorkflowStepCard.tsx | 18 +- .../ui/workflowConditionExpression.test.mjs | 113 +++++++ .../ui/workflowConditionExpression.ts | 206 +++++++++++++ desktop/tests/e2e/workflows.spec.ts | 90 +++++- 9 files changed, 862 insertions(+), 60 deletions(-) create mode 100644 desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx create mode 100644 desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs create mode 100644 desktop/src/features/workflows/ui/workflowConditionExpression.ts diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf5..d98f5f7bb 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -854,19 +854,20 @@ fn filters_are_nip43_membership_only(filters: &[Filter]) -> bool { }) } -/// Extract a channel UUID from a single filter's `#h` tag. +/// Extract the single channel UUID from a filter's `#h` tag. +/// +/// A multi-value `#h` filter has NIP-01 OR semantics, so it cannot be reduced +/// to one `EventQuery::channel_id` without dropping matches from the other +/// channels. Return `None` in that case and let the caller apply the accessible +/// channel set in SQL before the full filter is evaluated in Rust. fn extract_channel_id_from_filter(filter: &Filter) -> Option { - for (tag_key, tag_values) in filter.generic_tags.iter() { - let key = tag_key.to_string(); - if key == "h" { - for val in tag_values { - if let Ok(id) = val.parse::() { - return Some(id); - } - } - } + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let values = filter.generic_tags.get(&h_tag)?; + if values.len() != 1 { + return None; } - None + + values.iter().next()?.parse::().ok() } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. @@ -1551,6 +1552,28 @@ mod tests { assert_eq!(extract_channel_id_from_filters(&filters), Some(channel_id)); } + #[test] + fn extract_channel_id_from_multi_value_filter_returns_none() { + let channel_a = uuid::Uuid::new_v4(); + let channel_b = uuid::Uuid::new_v4(); + let filter: Filter = serde_json::from_value(serde_json::json!({ + "#h": [channel_a.to_string(), channel_b.to_string()], + })) + .unwrap(); + + assert_eq!(extract_channel_id_from_filter(&filter), None); + assert_eq!( + filter_to_query_params( + &filter, + extract_channel_id_from_filter(&filter), + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + ) + .channel_id, + None, + "multi-channel OR filters must not be narrowed to their first channel", + ); + } + #[test] fn test_extract_channel_id_mixed_channels_returns_none() { let channel_a = uuid::Uuid::new_v4(); diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 25e02980f..e17f39520 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -105,11 +105,13 @@ pub async fn get_channel_workflows( /// /// The Workflows overview screen previously issued one `get_channel_workflows` /// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N -/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one -/// query with all channel ids returns the same set. Each `WorkflowWire` carries -/// its own `channel_id` (from the event's `h` tag), so the frontend can still -/// group results by channel. Neither this nor the per-channel command sets a -/// `limit`, so batching does not change result completeness. +/// relay POSTs. This sends N single-channel filters in one query request. Using +/// one multi-value `#h` filter would be equivalent under NIP-01, but older +/// relays incorrectly narrowed that shape to its first channel. Each +/// `WorkflowWire` carries its own `channel_id` (from the event's `h` tag), so +/// the frontend can still group results by channel. Neither this nor the +/// per-channel command sets a `limit`, so batching does not change result +/// completeness. #[tauri::command] pub async fn get_channels_workflows( channel_ids: Vec, @@ -119,18 +121,24 @@ pub async fn get_channels_workflows( return Ok(Vec::new()); } - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [30620], - "#h": channel_ids, - })], - ) - .await?; + let filters = channel_workflow_filters(channel_ids); + let events = query_relay(&state, &filters).await?; Ok(events.iter().map(workflow_from_event).collect()) } +fn channel_workflow_filters(channel_ids: Vec) -> Vec { + channel_ids + .into_iter() + .map(|channel_id| { + serde_json::json!({ + "kinds": [30620], + "#h": [channel_id], + }) + }) + .collect() +} + #[tauri::command] pub async fn get_workflow( workflow_id: String, diff --git a/desktop/src-tauri/src/commands/workflows_tests.rs b/desktop/src-tauri/src/commands/workflows_tests.rs index 647cc6870..f531fadd4 100644 --- a/desktop/src-tauri/src/commands/workflows_tests.rs +++ b/desktop/src-tauri/src/commands/workflows_tests.rs @@ -188,6 +188,28 @@ fn workflow_wire_serializes_with_snake_case_keys() { } } +#[test] +fn multi_channel_workflow_query_uses_one_filter_per_channel() { + let other_channel = "33333333-3333-3333-3333-333333333333"; + let filters = channel_workflow_filters(vec![CHAN.to_string(), other_channel.to_string()]); + + assert_eq!(filters.len(), 2); + assert_eq!( + filters[0], + serde_json::json!({ + "kinds": [30620], + "#h": [CHAN], + }) + ); + assert_eq!( + filters[1], + serde_json::json!({ + "kinds": [30620], + "#h": [other_channel], + }) + ); +} + #[test] fn trigger_response_uses_persisted_run_id_contract() { let wire = trigger_wire_from_message( diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx new file mode 100644 index 000000000..26c681497 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx @@ -0,0 +1,291 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; +import { + buildConditionExpression, + conditionFieldsForTrigger, + conditionOperatorNeedsValue, + CUSTOM_CONDITION_FIELD, + normalizeWebhookField, + parseConditionExpression, +} from "./workflowConditionExpression"; +import type { + ConditionOperator, + ParsedConditionExpression, +} from "./workflowConditionExpression"; +import type { TriggerType } from "./workflowFormTypes"; + +const OPERATOR_LABELS: Record = { + contains: "contains", + not_contains: "does not contain", + starts_with: "starts with", + ends_with: "ends with", + equals: "is exactly", + not_equals: "is not", + is_not_empty: "is not empty", + is_empty: "is empty", +}; + +function initialEditorState( + value: string, + triggerType: TriggerType, +): ParsedConditionExpression { + const parsed = parseConditionExpression(value, triggerType); + if (parsed) return parsed; + + return { + field: value.trim() ? CUSTOM_CONDITION_FIELD : "", + operator: "contains", + value: "", + webhookField: "", + }; +} + +function valueLabel(field: string): string { + switch (field) { + case "trigger_author": + return "Pubkey"; + case "trigger_channel_id": + return "Channel ID"; + case "trigger_message_id": + return "Message ID"; + case "trigger_emoji": + return "Emoji"; + case "trigger_timestamp": + return "Timestamp"; + default: + return "Text to match"; + } +} + +function valuePlaceholder(field: string): string { + switch (field) { + case "trigger_author": + return "Paste a hex pubkey"; + case "trigger_channel_id": + return "Paste a channel UUID"; + case "trigger_message_id": + return "Paste a message event ID"; + case "trigger_emoji": + return "e.g. 👍"; + case "trigger_timestamp": + return "e.g. 1723507200"; + default: + return "e.g. deploy"; + } +} + +export function WorkflowConditionBuilder({ + disabled, + idPrefix, + matchAllHint = "Leave empty to match every event.", + onChange, + triggerType, + value, +}: { + disabled?: boolean; + idPrefix: string; + matchAllHint?: string; + onChange: (value: string) => void; + triggerType: TriggerType; + value: string; +}) { + const fields = conditionFieldsForTrigger(triggerType); + const [editor, setEditor] = React.useState(() => + initialEditorState(value, triggerType), + ); + const previousTriggerType = React.useRef(triggerType); + + React.useEffect(() => { + if (previousTriggerType.current === triggerType) return; + previousTriggerType.current = triggerType; + setEditor(initialEditorState(value, triggerType)); + }, [triggerType, value]); + + const emitEditor = (next: ParsedConditionExpression) => { + setEditor(next); + const expression = buildConditionExpression({ + field: next.field, + operator: next.operator, + value: next.value, + webhookField: next.webhookField, + }); + onChange(expression ?? ""); + }; + + const needsValue = conditionOperatorNeedsValue(editor.operator); + const webhookFieldInvalid = + editor.field === "webhook_field" && + editor.webhookField.length > 0 && + normalizeWebhookField(editor.webhookField) === null; + const evalexprFields = fields + .map((field) => + field.value === "webhook_field" ? "trigger_" : field.value, + ) + .join(", "); + const fieldOptions = [ + ...fields, + { label: "Custom", value: CUSTOM_CONDITION_FIELD }, + ]; + + return ( +
+
+ + Condition (optional) + +
+ {fieldOptions.map((field) => { + const isCustom = field.value === CUSTOM_CONDITION_FIELD; + const isSelected = editor.field === field.value; + return ( +
+ +
+ ); + })} +
+
+ + {editor.field === CUSTOM_CONDITION_FIELD ? ( +
+ + Custom expression + + onChange(event.target.value)} + placeholder='e.g. str_contains(trigger_text, "deploy")' + value={value} + /> +

+ Use an evalexpr expression with {evalexprFields}. +

+
+ ) : editor.field ? ( + <> +
+ Match + + emitEditor({ + ...editor, + operator: operator as ConditionOperator, + }) + } + value={editor.operator} + > + {Object.entries(OPERATOR_LABELS).map(([operator, label]) => ( + + ))} + +
+ + {editor.field === "webhook_field" ? ( +
+ + JSON field name + + + emitEditor({ + ...editor, + webhookField: event.target.value, + }) + } + placeholder="e.g. environment" + value={editor.webhookField} + /> + {webhookFieldInvalid ? ( +

+ Use letters, numbers, and underscores, starting with a letter + or underscore. Names cannot start with trigger_ or steps_. +

+ ) : null} +
+ ) : null} + + {needsValue ? ( +
+ + {editor.field === "webhook_field" + ? "Value" + : valueLabel(editor.field)} + + + emitEditor({ ...editor, value: event.target.value }) + } + placeholder={ + editor.field === "webhook_field" + ? "Value to match" + : valuePlaceholder(editor.field) + } + value={editor.value} + /> +
+ ) : null} + + ) : null} + +

{matchAllHint}

+
+ ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index f8738e763..7f677696d 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -1,10 +1,21 @@ -import { Check, ChevronDown, Plus, Trash2, X, Zap } from "lucide-react"; +import { + Check, + ChevronDown, + Plus, + SmilePlus, + Trash2, + X, + Zap, +} from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { createPortal } from "react-dom"; +import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; +import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; +import { emojiDisplayName } from "@/shared/lib/emojiName"; import { DropdownMenu, DropdownMenuContent, @@ -12,8 +23,10 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; +import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder"; import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; import { FieldLabel } from "./workflowFormPrimitives"; @@ -44,26 +57,21 @@ function TriggerConfigFields({ trigger: TriggerConfig; onUpdate: (trigger: TriggerConfig) => void; }) { + const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); + switch (trigger.on) { case "message_posted": case "diff_posted": return ( -
- - Condition (optional) - - - onUpdate({ ...trigger, filter: event.target.value }) - } - placeholder='e.g. contains(text, "deploy")' +
+ onUpdate({ ...trigger, filter })} + triggerType={trigger.on} value={trigger.filter ?? ""} /> -

- Evalexpr. Empty matches all events. -

); case "reaction_added": @@ -72,15 +80,62 @@ function TriggerConfigFields({ Emoji filter (optional) - - onUpdate({ ...trigger, emoji: event.target.value }) - } - placeholder="e.g. thumbsup" - value={trigger.emoji ?? ""} - /> +
+ + + + + + { + onUpdate({ ...trigger, emoji }); + setEmojiPickerOpen(false); + }} + /> + + + {trigger.emoji ? ( + + ) : null} +
); case "webhook": diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index 820be5b15..f4e18f8c0 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -4,6 +4,7 @@ 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 { WorkflowConditionBuilder } from "./WorkflowConditionBuilder"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor"; import type { StepFormState, TriggerType } from "./workflowFormTypes"; @@ -353,18 +354,13 @@ export function WorkflowStepCard({
-
- - Condition (optional) - - + - onUpdate({ ...step, condition: event.target.value }) - } - placeholder='e.g. str_contains(trigger_text, "deploy")' + idPrefix={`${prefix}-condition`} + matchAllHint="Leave empty to run this step every time the workflow starts." + onChange={(condition) => onUpdate({ ...step, condition })} + triggerType={triggerType} value={step.condition ?? ""} />
diff --git a/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs b/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs new file mode 100644 index 000000000..25b7b348d --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + buildConditionExpression, + conditionFieldsForTrigger, + normalizeWebhookField, + parseConditionExpression, +} from "./workflowConditionExpression.ts"; + +test("builds the supported string conditions", () => { + assert.equal( + buildConditionExpression({ + field: "trigger_text", + operator: "contains", + value: "deploy", + }), + 'str_contains(trigger_text, "deploy")', + ); + assert.equal( + buildConditionExpression({ + field: "trigger_text", + operator: "not_contains", + value: "draft", + }), + '!str_contains(trigger_text, "draft")', + ); + assert.equal( + buildConditionExpression({ + field: "trigger_author", + operator: "equals", + value: "abc123", + }), + 'trigger_author == "abc123"', + ); +}); + +test("escapes values before placing them in an expression", () => { + assert.equal( + buildConditionExpression({ + field: "trigger_text", + operator: "equals", + value: 'say "hello"\\world', + }), + 'trigger_text == "say \\"hello\\"\\\\world"', + ); +}); + +test("builds empty checks without requiring comparison text", () => { + assert.equal( + buildConditionExpression({ + field: "trigger_emoji", + operator: "is_not_empty", + value: "", + }), + "str_len(trigger_emoji) > 0", + ); +}); + +test("normalizes safe webhook fields and rejects reserved or invalid names", () => { + assert.equal(normalizeWebhookField("environment"), "trigger_environment"); + assert.equal(normalizeWebhookField("deploy_id"), "trigger_deploy_id"); + assert.equal(normalizeWebhookField("trigger_text"), null); + assert.equal(normalizeWebhookField("bad-name"), null); +}); + +test("shows trigger-relevant fields", () => { + assert.deepEqual( + conditionFieldsForTrigger("reaction_added").map((field) => field.value), + [ + "trigger_emoji", + "trigger_author", + "trigger_channel_id", + "trigger_message_id", + ], + ); + assert.equal(conditionFieldsForTrigger("webhook")[0].value, "webhook_field"); +}); + +test("parses generated conditions back into editor fields", () => { + assert.deepEqual( + parseConditionExpression( + 'str_contains(trigger_text, "deploy \\"buzz\\"")', + "message_posted", + ), + { + field: "trigger_text", + operator: "contains", + value: 'deploy "buzz"', + webhookField: "", + }, + ); + assert.deepEqual( + parseConditionExpression('trigger_environment == "prod"', "webhook"), + { + field: "webhook_field", + operator: "equals", + value: "prod", + webhookField: "environment", + }, + ); +}); + +test("keeps unsupported expressions in custom mode", () => { + assert.equal( + parseConditionExpression("trigger_timestamp > 0", "message_posted"), + null, + ); + assert.equal( + parseConditionExpression('trigger_emoji == "👍"', "message_posted"), + null, + ); +}); diff --git a/desktop/src/features/workflows/ui/workflowConditionExpression.ts b/desktop/src/features/workflows/ui/workflowConditionExpression.ts new file mode 100644 index 000000000..ea8e2750a --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowConditionExpression.ts @@ -0,0 +1,206 @@ +import type { TriggerType } from "./workflowFormTypes"; + +export const CONDITION_OPERATORS = [ + "contains", + "not_contains", + "starts_with", + "ends_with", + "equals", + "not_equals", + "is_not_empty", + "is_empty", +] as const; + +export type ConditionOperator = (typeof CONDITION_OPERATORS)[number]; + +export type ConditionField = { + label: string; + value: string; +}; + +export type ParsedConditionExpression = { + field: string; + operator: ConditionOperator; + value: string; + webhookField: string; +}; + +export const CUSTOM_CONDITION_FIELD = "custom"; + +const COMMON_FIELDS: ConditionField[] = [ + { label: "Author pubkey", value: "trigger_author" }, + { label: "Channel ID", value: "trigger_channel_id" }, + { label: "Message ID", value: "trigger_message_id" }, +]; + +const FIELDS_BY_TRIGGER: Record = { + message_posted: [ + { label: "Message text", value: "trigger_text" }, + ...COMMON_FIELDS, + ], + diff_posted: [ + { label: "Diff text", value: "trigger_text" }, + ...COMMON_FIELDS, + ], + reaction_added: [ + { label: "Reaction emoji", value: "trigger_emoji" }, + ...COMMON_FIELDS, + ], + webhook: [ + { label: "Webhook field…", value: "webhook_field" }, + { label: "Channel ID", value: "trigger_channel_id" }, + ], + schedule: [ + { label: "Channel ID", value: "trigger_channel_id" }, + { label: "Scheduled timestamp", value: "trigger_timestamp" }, + ], +}; + +export function conditionFieldsForTrigger( + triggerType: TriggerType, +): ConditionField[] { + return FIELDS_BY_TRIGGER[triggerType]; +} + +export function conditionOperatorNeedsValue( + operator: ConditionOperator, +): boolean { + return operator !== "is_not_empty" && operator !== "is_empty"; +} + +function escapeEvalexprString(value: string): string { + // evalexpr v11 only recognizes escaped quotes and backslashes. Other + // backslash escapes (including \n and \t) are rejected by its parser. + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +export function normalizeWebhookField(field: string): string | null { + const trimmed = field.trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return null; + if (trimmed.startsWith("trigger_") || trimmed.startsWith("steps_")) { + return null; + } + return `trigger_${trimmed}`; +} + +export function buildConditionExpression({ + field, + operator, + value, + webhookField, +}: { + field: string; + operator: ConditionOperator; + value: string; + webhookField?: string; +}): string | null { + const variable = + field === "webhook_field" + ? normalizeWebhookField(webhookField ?? "") + : field; + if (!variable) return null; + + if (operator === "is_not_empty") return `str_len(${variable}) > 0`; + if (operator === "is_empty") return `str_len(${variable}) == 0`; + if (!value.trim()) return null; + + const quotedValue = `"${escapeEvalexprString(value)}"`; + switch (operator) { + case "contains": + return `str_contains(${variable}, ${quotedValue})`; + case "not_contains": + return `!str_contains(${variable}, ${quotedValue})`; + case "starts_with": + return `str_starts_with(${variable}, ${quotedValue})`; + case "ends_with": + return `str_ends_with(${variable}, ${quotedValue})`; + case "equals": + return `${variable} == ${quotedValue}`; + case "not_equals": + return `${variable} != ${quotedValue}`; + default: + return null; + } +} + +function unescapeEvalexprString(value: string): string { + return value.replaceAll(/\\(["\\])/g, "$1"); +} + +function parsedField( + variable: string, + triggerType: TriggerType, +): Pick | null { + const fields = conditionFieldsForTrigger(triggerType); + if (fields.some((field) => field.value === variable)) { + return { field: variable, webhookField: "" }; + } + + if ( + fields.some((field) => field.value === "webhook_field") && + variable.startsWith("trigger_") + ) { + const webhookField = variable.slice("trigger_".length); + if (normalizeWebhookField(webhookField) === variable) { + return { field: "webhook_field", webhookField }; + } + } + + return null; +} + +/** Parse expressions emitted by the condition editor back into form state. */ +export function parseConditionExpression( + expression: string, + triggerType: TriggerType, +): ParsedConditionExpression | null { + const trimmed = expression.trim(); + const emptyMatch = /^str_len\(([A-Za-z_][A-Za-z0-9_]*)\) (>|==) 0$/.exec( + trimmed, + ); + if (emptyMatch) { + const field = parsedField(emptyMatch[1], triggerType); + if (!field) return null; + return { + ...field, + operator: emptyMatch[2] === ">" ? "is_not_empty" : "is_empty", + value: "", + }; + } + + const stringLiteral = '"((?:\\\\["\\\\]|[^"\\\\])*)"'; + const functionMatch = new RegExp( + `^(!)?str_(contains|starts_with|ends_with)\\(([A-Za-z_][A-Za-z0-9_]*), ${stringLiteral}\\)$`, + ).exec(trimmed); + if (functionMatch) { + const field = parsedField(functionMatch[3], triggerType); + if (!field) return null; + const operator = functionMatch[1] + ? "not_contains" + : functionMatch[2] === "starts_with" + ? "starts_with" + : functionMatch[2] === "ends_with" + ? "ends_with" + : "contains"; + return { + ...field, + operator, + value: unescapeEvalexprString(functionMatch[4]), + }; + } + + const equalityMatch = new RegExp( + `^([A-Za-z_][A-Za-z0-9_]*) (!=|==) ${stringLiteral}$`, + ).exec(trimmed); + if (equalityMatch) { + const field = parsedField(equalityMatch[1], triggerType); + if (!field) return null; + return { + ...field, + operator: equalityMatch[2] === "==" ? "equals" : "not_equals", + value: unescapeEvalexprString(equalityMatch[3]), + }; + } + + return null; +} diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 580d2d699..b531d5b26 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -72,7 +72,11 @@ async function createWorkflow( await dialog.getByLabel("Name (optional)").fill(options.stepName); } if (options?.stepCondition) { - await dialog.getByLabel("Condition (optional)").fill(options.stepCondition); + await dialog + .getByRole("group", { name: "Condition (optional)" }) + .getByRole("button", { name: "Custom" }) + .click(); + await dialog.getByLabel("Custom expression").fill(options.stepCondition); } if (options?.stepTimeoutSecs) { await dialog.getByLabel("Timeout (seconds)").fill(options.stepTimeoutSecs); @@ -204,6 +208,90 @@ test("configures common schedules and exposes custom cron", async ({ ); }); +test("builds a valid trigger condition from plain-language choices", async ({ + page, +}) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + const inspector = dialog.getByTestId("workflow-node-inspector"); + + const conditionFields = inspector.getByRole("group", { + name: "Condition (optional)", + }); + const conditionOptions = conditionFields.getByRole("button"); + await expect(conditionOptions).toHaveCount(5); + await expect( + conditionFields.getByRole("button", { name: "Message text" }), + ).toHaveAttribute("aria-pressed", "false"); + for (const name of ["Author pubkey", "Channel ID", "Message ID", "Custom"]) { + await expect(conditionFields.getByRole("button", { name })).toBeVisible(); + } + + await conditionFields.getByRole("button", { name: "Message text" }).click(); + await inspector.getByLabel("Text to match").fill('deploy "buzz"'); + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + /str_contains\(trigger_text, "deploy \\"buzz\\""\)/, + ); + + await dialog.getByRole("tab", { name: "Form" }).click(); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + const roundTrippedFields = inspector.getByRole("group", { + name: "Condition (optional)", + }); + await expect( + roundTrippedFields.getByRole("button", { name: "Message text" }), + ).toHaveAttribute("aria-pressed", "true"); + const customCondition = roundTrippedFields.getByRole("button", { + name: "Custom", + }); + await customCondition.click(); + await expect(inspector.getByLabel("Custom expression")).toHaveValue( + 'str_contains(trigger_text, "deploy \\"buzz\\"")', + ); + await expect(inspector.getByText(/Use an evalexpr expression/)).toBeVisible(); + await customCondition.click(); + await expect(customCondition).toHaveAttribute("aria-pressed", "false"); + await expect(inspector.getByLabel("Custom expression")).not.toBeVisible(); +}); + +test("chooses and clears a reaction trigger with the app emoji picker", async ({ + page, +}) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + const inspector = dialog.getByTestId("workflow-node-inspector"); + await inspector.getByLabel("Trigger event").click(); + await page.getByRole("menuitem", { name: "Reaction Added" }).click(); + + const trigger = inspector.getByRole("button", { + name: "Choose emoji filter", + }); + await expect(trigger).toContainText("Choose a reaction"); + await trigger.click(); + + const picker = page.locator("em-emoji-picker"); + await picker.locator("input[type='search']").fill("buzz"); + await picker.getByRole("button", { name: ":buzz:" }).first().click(); + + await expect(trigger).toContainText(":buzz:"); + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(/:buzz:/); + + await dialog.getByRole("tab", { name: "Form" }).click(); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + await dialog.getByRole("button", { name: "Clear emoji filter" }).click(); + await expect( + dialog.getByRole("button", { name: "Choose emoji filter" }), + ).toContainText("Choose a reaction"); +}); + test("switches an empty workflow between form and YAML modes", async ({ page, }) => {