feat(workflows): polish conditions and fix listing

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 14:12:34 -07:00
parent 75c6336818
commit 20073b3a9d
9 changed files with 862 additions and 60 deletions
+34 -11
View File
@@ -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<uuid::Uuid> {
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::<uuid::Uuid>() {
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::<uuid::Uuid>().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();
+21 -13
View File
@@ -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<String>,
@@ -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<String>) -> Vec<Value> {
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,
@@ -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(
@@ -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<ConditionOperator, string> = {
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_<JSON field>" : field.value,
)
.join(", ");
const fieldOptions = [
...fields,
{ label: "Custom", value: CUSTOM_CONDITION_FIELD },
];
return (
<div className="space-y-3">
<fieldset className="space-y-1.5">
<legend className="text-xs font-medium text-muted-foreground">
Condition (optional)
</legend>
<div className="grid grid-cols-2 gap-2.5">
{fieldOptions.map((field) => {
const isCustom = field.value === CUSTOM_CONDITION_FIELD;
const isSelected = editor.field === field.value;
return (
<div
className={cn("relative", isCustom && "col-span-2")}
key={field.value}
>
<button
aria-pressed={isSelected}
className={cn(
"flex min-h-12 w-full cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color]",
"focus-visible:ring-2 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
isSelected
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
)}
disabled={disabled}
onClick={() => {
if (isSelected) {
setEditor({
field: "",
operator: "contains",
value: "",
webhookField: "",
});
onChange("");
return;
}
if (isCustom) {
setEditor({ ...editor, field: field.value });
return;
}
emitEditor({
field: field.value,
operator: "contains",
value: "",
webhookField: "",
});
}}
type="button"
>
{field.label}
</button>
</div>
);
})}
</div>
</fieldset>
{editor.field === CUSTOM_CONDITION_FIELD ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${idPrefix}-custom-expression`}>
Custom expression
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-custom-expression`}
onChange={(event) => onChange(event.target.value)}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={value}
/>
<p className="text-xs text-muted-foreground">
Use an evalexpr expression with <code>{evalexprFields}</code>.
</p>
</div>
) : editor.field ? (
<>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${idPrefix}-operator`}>Match</FieldLabel>
<FormSelect
disabled={disabled}
id={`${idPrefix}-operator`}
onChange={(operator) =>
emitEditor({
...editor,
operator: operator as ConditionOperator,
})
}
value={editor.operator}
>
{Object.entries(OPERATOR_LABELS).map(([operator, label]) => (
<option key={operator} value={operator}>
{label}
</option>
))}
</FormSelect>
</div>
{editor.field === "webhook_field" ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${idPrefix}-webhook-field`}>
JSON field name
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-webhook-field`}
onChange={(event) =>
emitEditor({
...editor,
webhookField: event.target.value,
})
}
placeholder="e.g. environment"
value={editor.webhookField}
/>
{webhookFieldInvalid ? (
<p className="text-xs text-destructive">
Use letters, numbers, and underscores, starting with a letter
or underscore. Names cannot start with trigger_ or steps_.
</p>
) : null}
</div>
) : null}
{needsValue ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${idPrefix}-value`}>
{editor.field === "webhook_field"
? "Value"
: valueLabel(editor.field)}
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-value`}
onChange={(event) =>
emitEditor({ ...editor, value: event.target.value })
}
placeholder={
editor.field === "webhook_field"
? "Value to match"
: valuePlaceholder(editor.field)
}
value={editor.value}
/>
</div>
) : null}
</>
) : null}
<p className="text-xs text-muted-foreground">{matchAllHint}</p>
</div>
);
}
@@ -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 (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-filter">
Condition (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-filter"
onChange={(event) =>
onUpdate({ ...trigger, filter: event.target.value })
}
placeholder='e.g. contains(text, "deploy")'
<div>
<WorkflowConditionBuilder
disabled={disabled}
idPrefix="wf-trigger-filter"
matchAllHint="Leave empty to trigger on every message."
onChange={(filter) => onUpdate({ ...trigger, filter })}
triggerType={trigger.on}
value={trigger.filter ?? ""}
/>
<p className="text-xs text-muted-foreground">
Evalexpr. Empty matches all events.
</p>
</div>
);
case "reaction_added":
@@ -72,15 +80,62 @@ function TriggerConfigFields({
<FieldLabel htmlFor="wf-trigger-emoji">
Emoji filter (optional)
</FieldLabel>
<Input
autoCapitalize="off"
id="wf-trigger-emoji"
onChange={(event) =>
onUpdate({ ...trigger, emoji: event.target.value })
}
placeholder="e.g. thumbsup"
value={trigger.emoji ?? ""}
/>
<div className="flex gap-2">
<Popover onOpenChange={setEmojiPickerOpen} open={emojiPickerOpen}>
<PopoverTrigger asChild>
<Button
aria-label="Choose emoji filter"
className="flex-1 justify-start px-3 font-normal"
disabled={disabled}
id="wf-trigger-emoji"
type="button"
variant="outline"
>
{trigger.emoji ? (
<>
<StatusEmoji
className="h-5 w-5 text-base"
value={trigger.emoji}
/>
<span>{emojiDisplayName(trigger.emoji)}</span>
</>
) : (
<>
<SmilePlus className="text-muted-foreground" />
<span className="text-muted-foreground">
Choose a reaction
</span>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
sideOffset={4}
>
<EmojiPicker
autoFocus
onSelect={(emoji) => {
onUpdate({ ...trigger, emoji });
setEmojiPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
{trigger.emoji ? (
<Button
aria-label="Clear emoji filter"
disabled={disabled}
onClick={() => onUpdate({ ...trigger, emoji: undefined })}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
) : null}
</div>
</div>
);
case "webhook":
@@ -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({
<section className="space-y-4 border-t border-border/50 py-5">
<SectionHeading title="Run controls" />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-condition`}>
Condition (optional)
</FieldLabel>
<Input
autoCapitalize="off"
<div>
<WorkflowConditionBuilder
disabled={disabled}
id={`${prefix}-condition`}
onChange={(event) =>
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 ?? ""}
/>
</div>
@@ -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,
);
});
@@ -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<TriggerType, ConditionField[]> = {
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<ParsedConditionExpression, "field" | "webhookField"> | 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;
}
+89 -1
View File
@@ -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,
}) => {