mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(workflows): polish conditions and fix listing
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user