mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish workflow trigger conditions and sequence editor
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -887,6 +887,7 @@ async fn should_fire_workflow(
|
||||
) -> bool {
|
||||
if let TriggerDef::ReactionAdded {
|
||||
emoji: Some(ref expected),
|
||||
..
|
||||
} = def.trigger
|
||||
{
|
||||
if &trigger_ctx.emoji != expected {
|
||||
@@ -900,33 +901,13 @@ async fn should_fire_workflow(
|
||||
}
|
||||
}
|
||||
|
||||
if let TriggerDef::MessagePosted {
|
||||
filter: Some(ref expr),
|
||||
} = def.trigger
|
||||
{
|
||||
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
workflow_id = %workflow_id,
|
||||
"Trigger filter evaluated false — skipping workflow"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
workflow_id = %workflow_id,
|
||||
"Trigger filter error: {e} — skipping workflow"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let TriggerDef::DiffPosted {
|
||||
filter: Some(ref expr),
|
||||
} = def.trigger
|
||||
{
|
||||
let filter = match &def.trigger {
|
||||
TriggerDef::MessagePosted { filter }
|
||||
| TriggerDef::ReactionAdded { filter, .. }
|
||||
| TriggerDef::DiffPosted { filter } => filter.as_ref(),
|
||||
TriggerDef::Schedule { .. } | TriggerDef::Webhook => None,
|
||||
};
|
||||
if let Some(expr) = filter {
|
||||
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
@@ -1364,7 +1345,10 @@ steps:
|
||||
|
||||
#[test]
|
||||
fn trigger_matches_reaction() {
|
||||
let trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
assert!(trigger_matches_event(
|
||||
&trigger,
|
||||
buzz_core::kind::KIND_REACTION
|
||||
@@ -1375,6 +1359,36 @@ steps:
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reaction_filter_matches_target_message() {
|
||||
let yaml = r#"
|
||||
name: "React to one message"
|
||||
trigger:
|
||||
on: reaction_added
|
||||
filter: 'trigger_message_id == "target-message"'
|
||||
steps:
|
||||
- id: wait
|
||||
action: delay
|
||||
duration: 1s
|
||||
"#;
|
||||
let (def, _) = WorkflowEngine::parse_yaml(yaml).expect("parse failed");
|
||||
let mut trigger_ctx = executor::TriggerContext {
|
||||
message_id: "target-message".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
|
||||
"reaction to the selected message should fire"
|
||||
);
|
||||
|
||||
trigger_ctx.message_id = "different-message".to_owned();
|
||||
assert!(
|
||||
!should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
|
||||
"reaction to a different message should be filtered out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_trigger_never_matches_events() {
|
||||
let trigger = TriggerDef::Schedule {
|
||||
@@ -1421,7 +1435,10 @@ steps:
|
||||
|
||||
#[test]
|
||||
fn reaction_added_matches_kind_7_only() {
|
||||
let trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
// Must match KIND_REACTION = 7.
|
||||
assert!(trigger_matches_event(&trigger, 7));
|
||||
// Must NOT match stream message (kind 9).
|
||||
@@ -1436,6 +1453,7 @@ steps:
|
||||
// trigger_matches_event only checks the kind number.
|
||||
let trigger = TriggerDef::ReactionAdded {
|
||||
emoji: Some("thumbsup".to_owned()),
|
||||
filter: None,
|
||||
};
|
||||
assert!(trigger_matches_event(&trigger, 7));
|
||||
assert!(!trigger_matches_event(&trigger, 9));
|
||||
@@ -1458,7 +1476,10 @@ steps:
|
||||
// before calling trigger_matches_event, but verify the function itself
|
||||
// also returns false for these kinds.
|
||||
let msg_trigger = TriggerDef::MessagePosted { filter: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
|
||||
for kind in buzz_core::kind::KIND_WORKFLOW_TRIGGERED
|
||||
..=buzz_core::kind::KIND_WORKFLOW_APPROVAL_DENIED
|
||||
@@ -1478,7 +1499,10 @@ steps:
|
||||
fn trigger_matches_event_kind_zero_matches_nothing() {
|
||||
// Kind 0 is a profile event — no trigger should match it.
|
||||
let msg_trigger = TriggerDef::MessagePosted { filter: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
let sched_trigger = TriggerDef::Schedule {
|
||||
cron: None,
|
||||
interval: Some("1h".to_owned()),
|
||||
@@ -1715,7 +1739,11 @@ steps:
|
||||
async fn setup_db() -> buzz_db::Db {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned());
|
||||
// Local-only test default; this is not a production credential.
|
||||
.unwrap_or_else(|_| {
|
||||
let local_test_database = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
||||
local_test_database.to_owned()
|
||||
});
|
||||
buzz_db::Db::new(&buzz_db::DbConfig {
|
||||
database_url,
|
||||
..Default::default()
|
||||
|
||||
@@ -47,6 +47,9 @@ pub enum TriggerDef {
|
||||
/// Optional: only fire for this specific emoji.
|
||||
#[serde(default)]
|
||||
emoji: Option<String>,
|
||||
/// Optional evalexpr filter over the reaction context.
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
},
|
||||
/// Fires when a diff message (kind:40008) is posted in the workflow's channel.
|
||||
DiffPosted {
|
||||
@@ -300,11 +303,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_reaction_added_trigger() {
|
||||
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";
|
||||
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\n filter: 'trigger_message_id == \"abc123\"'\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";
|
||||
let (def, _) = parse_yaml(yaml).expect("parse failed");
|
||||
match &def.trigger {
|
||||
TriggerDef::ReactionAdded { emoji } => {
|
||||
TriggerDef::ReactionAdded { emoji, filter } => {
|
||||
assert_eq!(emoji.as_deref(), Some("clipboard"));
|
||||
assert_eq!(filter.as_deref(), Some("trigger_message_id == \"abc123\""));
|
||||
}
|
||||
other => panic!("unexpected trigger: {other:?}"),
|
||||
}
|
||||
@@ -488,8 +492,9 @@ mod tests {
|
||||
let yaml = "name: Any Reaction\ntrigger:\n on: reaction_added\nsteps:\n - id: s1\n action: add_reaction\n emoji: eyes\n";
|
||||
let (def, _) = parse_yaml(yaml).expect("parse failed");
|
||||
match &def.trigger {
|
||||
TriggerDef::ReactionAdded { emoji } => {
|
||||
TriggerDef::ReactionAdded { emoji, filter } => {
|
||||
assert!(emoji.is_none(), "emoji should default to None");
|
||||
assert!(filter.is_none(), "filter should default to None");
|
||||
}
|
||||
other => panic!("unexpected trigger: {other:?}"),
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { AuthorGridPicker } from "./AuthorGridPicker";
|
||||
import { ChannelCombobox } from "./ChannelCombobox";
|
||||
import { WorkflowEmojiField } from "./WorkflowEmojiField";
|
||||
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
|
||||
import {
|
||||
@@ -62,8 +61,6 @@ 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":
|
||||
@@ -79,8 +76,6 @@ 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_timestamp":
|
||||
@@ -183,8 +178,10 @@ export function WorkflowConditionBuilder({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Condition</legend>
|
||||
<fieldset aria-label="Condition">
|
||||
<legend className="mb-2 text-sm font-medium text-foreground">
|
||||
Run when
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{fieldOptions.map((field) => {
|
||||
const isMatchAll = field.value === "";
|
||||
@@ -331,19 +328,6 @@ export function WorkflowConditionBuilder({
|
||||
}
|
||||
value={editor.value}
|
||||
/>
|
||||
) : editor.field === "trigger_channel_id" ? (
|
||||
<ChannelCombobox
|
||||
ariaLabel="Channel ID"
|
||||
channels={channels}
|
||||
disabled={disabled}
|
||||
emptyLabel="Choose a channel"
|
||||
id={`${idPrefix}-value`}
|
||||
onChange={(channelId) =>
|
||||
emitEditor({ ...editor, value: channelId })
|
||||
}
|
||||
value={editor.value}
|
||||
variant="field"
|
||||
/>
|
||||
) : editor.field === "trigger_emoji" ? (
|
||||
<WorkflowEmojiField
|
||||
ariaLabel="Choose condition emoji"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
@@ -24,9 +25,9 @@ import { Input } from "@/shared/ui/input";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
|
||||
import { WorkflowEmojiField } from "./WorkflowEmojiField";
|
||||
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
|
||||
import { WorkflowStepCard } from "./WorkflowStepCard";
|
||||
import { buildConditionExpression } from "./workflowConditionExpression";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
import {
|
||||
DEFAULT_FORM_STATE,
|
||||
@@ -78,19 +79,25 @@ function TriggerConfigFields({
|
||||
);
|
||||
case "reaction_added":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-emoji">
|
||||
Emoji filter (optional)
|
||||
</FieldLabel>
|
||||
<WorkflowEmojiField
|
||||
ariaLabel="Choose emoji filter"
|
||||
clearAriaLabel="Clear emoji filter"
|
||||
disabled={disabled}
|
||||
id="wf-trigger-emoji"
|
||||
onChange={(emoji) => onUpdate({ ...trigger, emoji })}
|
||||
value={trigger.emoji}
|
||||
/>
|
||||
</div>
|
||||
<WorkflowConditionBuilder
|
||||
channels={channels}
|
||||
disabled={disabled}
|
||||
idPrefix="wf-trigger-filter"
|
||||
matchAllLabel="All reactions"
|
||||
onChange={(filter) =>
|
||||
onUpdate({ ...trigger, emoji: undefined, filter })
|
||||
}
|
||||
triggerType={trigger.on}
|
||||
value={
|
||||
trigger.filter ??
|
||||
buildConditionExpression({
|
||||
field: "trigger_emoji",
|
||||
operator: "equals",
|
||||
value: trigger.emoji ?? "",
|
||||
}) ??
|
||||
""
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "webhook":
|
||||
return (
|
||||
@@ -217,6 +224,7 @@ function WorkflowNode({
|
||||
selected,
|
||||
showTitle = true,
|
||||
subtitle,
|
||||
terminal,
|
||||
title,
|
||||
}: {
|
||||
description: string;
|
||||
@@ -230,9 +238,11 @@ function WorkflowNode({
|
||||
selected: boolean;
|
||||
showTitle?: boolean;
|
||||
subtitle?: string;
|
||||
terminal: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
const isNumbered = number !== undefined;
|
||||
const [addMenuOpen, setAddMenuOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<li className="flex flex-col items-center">
|
||||
@@ -295,14 +305,29 @@ function WorkflowNode({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="relative flex h-18 items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<div
|
||||
className="group relative flex h-18 items-center justify-center"
|
||||
data-menu-open={addMenuOpen}
|
||||
data-terminal={terminal}
|
||||
data-testid="workflow-node-ingress"
|
||||
>
|
||||
{terminal ? null : (
|
||||
<ArrowDown
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-muted-foreground transition-opacity duration-200 ease-out group-hover:opacity-10 group-data-[menu-open=true]:opacity-10 group-has-[:focus-visible]:opacity-10 motion-reduce:transition-none"
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu onOpenChange={setAddMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={
|
||||
title === "Trigger" ? "Add step" : `Add after ${title}`
|
||||
}
|
||||
className="relative z-10 h-7 w-7 rounded-full bg-background shadow-sm"
|
||||
className={cn(
|
||||
"relative z-10 h-7 w-7 rounded-full bg-background shadow-sm",
|
||||
!terminal &&
|
||||
"pointer-events-none absolute scale-125 opacity-0 transition-[opacity,transform,background-color,color,border-color,box-shadow] duration-200 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-data-[menu-open=true]:pointer-events-auto group-data-[menu-open=true]:scale-100 group-data-[menu-open=true]:opacity-100 focus-visible:pointer-events-auto focus-visible:scale-100 focus-visible:opacity-100 motion-reduce:transition-none",
|
||||
)}
|
||||
disabled={disabled}
|
||||
size="icon"
|
||||
type="button"
|
||||
@@ -325,7 +350,7 @@ function WorkflowNode({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -348,7 +373,9 @@ export function WorkflowFormBuilder({
|
||||
? initialParseRef.current.state
|
||||
: DEFAULT_FORM_STATE,
|
||||
);
|
||||
const [selectedNode, setSelectedNode] = React.useState<SelectedNode>(null);
|
||||
const [selectedNode, setSelectedNode] = React.useState<SelectedNode>({
|
||||
type: "trigger",
|
||||
});
|
||||
const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const previousModeRef = React.useRef(mode);
|
||||
@@ -372,6 +399,7 @@ export function WorkflowFormBuilder({
|
||||
|
||||
const result = yamlToFormState(yaml);
|
||||
if (result.ok) setFormState(result.state);
|
||||
setSelectedNode({ type: "trigger" });
|
||||
}, [mode, yaml]);
|
||||
|
||||
const selectNode = React.useCallback(
|
||||
@@ -522,6 +550,7 @@ export function WorkflowFormBuilder({
|
||||
onAddAfter={(action) => insertStep(0, action)}
|
||||
onClick={() => selectNode({ type: "trigger" })}
|
||||
selected={selectedNode?.type === "trigger"}
|
||||
terminal={formState.steps.length === 0}
|
||||
title="Trigger"
|
||||
/>
|
||||
|
||||
@@ -545,18 +574,11 @@ export function WorkflowFormBuilder({
|
||||
}
|
||||
showTitle={false}
|
||||
subtitle={stepName ? actionLabel : undefined}
|
||||
terminal={index === formState.steps.length - 1}
|
||||
title={`Step ${index + 1}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{formState.steps.length > 0 ? (
|
||||
<li className="flex justify-center">
|
||||
<span className="rounded-full border border-border bg-muted/30 px-5 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
End
|
||||
</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
@@ -380,18 +379,7 @@ export function WorkflowStepCard({
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t border-border/50 py-5">
|
||||
<SectionHeading title="Run controls" />
|
||||
<div>
|
||||
<WorkflowConditionBuilder
|
||||
channels={channels}
|
||||
disabled={disabled}
|
||||
idPrefix={`${prefix}-condition`}
|
||||
matchAllLabel="Every run"
|
||||
onChange={(condition) => onUpdate({ ...step, condition })}
|
||||
triggerType={triggerType}
|
||||
value={step.condition ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<SectionHeading title="Step timeout" />
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
|
||||
Timeout (seconds)
|
||||
|
||||
@@ -68,19 +68,37 @@ test("normalizes safe webhook fields and rejects reserved or invalid names", ()
|
||||
test("shows trigger-relevant fields", () => {
|
||||
assert.deepEqual(
|
||||
conditionFieldsForTrigger("reaction_added").map((field) => field.value),
|
||||
[
|
||||
"trigger_emoji",
|
||||
"trigger_author",
|
||||
"trigger_channel_id",
|
||||
"trigger_message_id",
|
||||
],
|
||||
["trigger_emoji", "trigger_author", "trigger_message_id"],
|
||||
);
|
||||
assert.equal(conditionFieldsForTrigger("webhook")[0].value, "webhook_field");
|
||||
for (const triggerType of ["message_posted", "diff_posted"]) {
|
||||
assert.equal(
|
||||
conditionFieldsForTrigger(triggerType).some(
|
||||
(field) => field.value === "trigger_message_id",
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (const triggerType of [
|
||||
"message_posted",
|
||||
"diff_posted",
|
||||
"reaction_added",
|
||||
"webhook",
|
||||
"schedule",
|
||||
]) {
|
||||
assert.equal(
|
||||
conditionFieldsForTrigger(triggerType).some(
|
||||
(field) => field.value === "trigger_channel_id",
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("limits opaque identifiers to equality operators", () => {
|
||||
for (const field of [
|
||||
"trigger_author",
|
||||
"trigger_emoji",
|
||||
"trigger_channel_id",
|
||||
"trigger_message_id",
|
||||
"future_resource_id",
|
||||
|
||||
@@ -22,7 +22,9 @@ const EXACT_MATCH_OPERATORS = [
|
||||
export function conditionOperatorsForField(
|
||||
field: string,
|
||||
): readonly ConditionOperator[] {
|
||||
return field === "trigger_author" || field.endsWith("_id")
|
||||
return field === "trigger_author" ||
|
||||
field === "trigger_emoji" ||
|
||||
field.endsWith("_id")
|
||||
? EXACT_MATCH_OPERATORS
|
||||
: CONDITION_OPERATORS;
|
||||
}
|
||||
@@ -47,33 +49,24 @@ export type ParsedConditionExpression = {
|
||||
|
||||
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 AUTHOR_FIELD: ConditionField = {
|
||||
label: "Author pubkey",
|
||||
value: "trigger_author",
|
||||
};
|
||||
|
||||
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,
|
||||
AUTHOR_FIELD,
|
||||
],
|
||||
diff_posted: [{ label: "Diff text", value: "trigger_text" }, AUTHOR_FIELD],
|
||||
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" },
|
||||
AUTHOR_FIELD,
|
||||
{ label: "Reacted-to message ID", value: "trigger_message_id" },
|
||||
],
|
||||
webhook: [{ label: "Webhook field…", value: "webhook_field" }],
|
||||
schedule: [{ label: "Scheduled timestamp", value: "trigger_timestamp" }],
|
||||
};
|
||||
|
||||
export function conditionFieldsForTrigger(
|
||||
|
||||
@@ -181,7 +181,8 @@ export function formStateToYaml(state: WorkflowFormState): string {
|
||||
const trigger: Record<string, unknown> = { on: state.trigger.on };
|
||||
if (
|
||||
(state.trigger.on === "message_posted" ||
|
||||
state.trigger.on === "diff_posted") &&
|
||||
state.trigger.on === "diff_posted" ||
|
||||
state.trigger.on === "reaction_added") &&
|
||||
state.trigger.filter
|
||||
) {
|
||||
trigger.filter = state.trigger.filter;
|
||||
|
||||
@@ -94,7 +94,7 @@ async function createWorkflow(
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
trigger?: string;
|
||||
stepCondition?: string;
|
||||
triggerCondition?: string;
|
||||
stepName?: string;
|
||||
stepTimeoutSecs?: string;
|
||||
},
|
||||
@@ -122,19 +122,20 @@ async function createWorkflow(
|
||||
.getByRole("menuitem", { name: TRIGGER_OPTION_LABELS[options.trigger] })
|
||||
.click();
|
||||
}
|
||||
if (options?.triggerCondition) {
|
||||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||||
await dialog
|
||||
.getByRole("group", { name: "Condition" })
|
||||
.getByRole("button", { name: "Custom" })
|
||||
.click();
|
||||
await dialog.getByLabel("Custom expression").fill(options.triggerCondition);
|
||||
}
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delay" }).click();
|
||||
if (options?.stepName) {
|
||||
await dialog.getByLabel("Name (optional)").fill(options.stepName);
|
||||
}
|
||||
if (options?.stepCondition) {
|
||||
await dialog
|
||||
.getByRole("group", { name: "Condition" })
|
||||
.getByRole("button", { name: "Custom" })
|
||||
.click();
|
||||
await dialog.getByLabel("Custom expression").fill(options.stepCondition);
|
||||
}
|
||||
if (options?.stepTimeoutSecs) {
|
||||
await dialog.getByLabel("Timeout (seconds)").fill(options.stepTimeoutSecs);
|
||||
}
|
||||
@@ -276,8 +277,9 @@ test("builds a valid trigger condition from plain-language choices", async ({
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
|
||||
const conditionFields = inspector.getByRole("group", { name: "Condition" });
|
||||
await expect(inspector.getByText("Run when", { exact: true })).toBeVisible();
|
||||
const conditionOptions = conditionFields.getByRole("button");
|
||||
await expect(conditionOptions).toHaveCount(6);
|
||||
await expect(conditionOptions).toHaveCount(4);
|
||||
const allMessages = conditionFields.getByRole("button", {
|
||||
name: "All messages",
|
||||
});
|
||||
@@ -287,9 +289,12 @@ test("builds a valid trigger condition from plain-language choices", async ({
|
||||
await expect(
|
||||
conditionFields.getByRole("button", { name: "Message text" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
for (const name of ["Author pubkey", "Channel ID", "Message ID", "Custom"]) {
|
||||
for (const name of ["Author pubkey", "Custom"]) {
|
||||
await expect(conditionFields.getByRole("button", { name })).toBeVisible();
|
||||
}
|
||||
for (const name of ["Channel ID", "Message ID"]) {
|
||||
await expect(conditionFields.getByRole("button", { name })).toHaveCount(0);
|
||||
}
|
||||
|
||||
await conditionFields.getByRole("button", { name: "Message text" }).click();
|
||||
await expect(allMessages).toHaveAttribute("aria-pressed", "false");
|
||||
@@ -325,44 +330,6 @@ test("builds a valid trigger condition from plain-language choices", async ({
|
||||
await expect(inspector.getByLabel("Custom expression")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("chooses a trigger channel condition from the live channel list", 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.getByRole("button", { name: "Channel ID" }).click();
|
||||
const matchOperator = inspector.getByLabel("Match");
|
||||
await expect(matchOperator).toHaveValue("equals");
|
||||
await expect(matchOperator.locator("option")).toHaveText(["is", "is not"]);
|
||||
const channelCondition = inspector.getByRole("combobox", {
|
||||
name: "Channel ID",
|
||||
});
|
||||
await expect(channelCondition).toContainText("Choose a channel");
|
||||
await channelCondition.click();
|
||||
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
await channelList.hover();
|
||||
await page.mouse.wheel(0, 500);
|
||||
await expect
|
||||
.poll(() => channelList.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const search = page.getByPlaceholder("Search channels...");
|
||||
await search.fill("random");
|
||||
await page.getByRole("button", { name: "random · stream" }).click();
|
||||
await expect(channelCondition).toContainText("random");
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||||
/trigger_channel_id == "[0-9a-f-]{36}"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("chooses a trigger author condition from live user search", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -536,9 +503,13 @@ test("chooses a reaction emoji condition with the app emoji picker", async ({
|
||||
await inspector.getByLabel("Trigger event").click();
|
||||
await page.getByRole("menuitem", { name: "Reaction Added" }).click();
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delay" }).click();
|
||||
inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
await expect(
|
||||
inspector.getByRole("button", { name: "Reacted-to message ID" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
inspector.getByRole("button", { name: "Channel ID" }),
|
||||
).toHaveCount(0);
|
||||
await inspector.getByRole("button", { name: "Reaction emoji" }).click();
|
||||
|
||||
const conditionEmoji = inspector.getByRole("button", {
|
||||
@@ -552,7 +523,7 @@ test("chooses a reaction emoji condition with the app emoji picker", async ({
|
||||
await expect(conditionEmoji).toContainText(":buzz:");
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||||
/trigger_emoji[\s\S]*:buzz:/,
|
||||
/filter: trigger_emoji == ":buzz:"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -639,15 +610,18 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
|
||||
const dialog = page.getByRole("dialog");
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
|
||||
await expect(inspector).not.toBeVisible();
|
||||
await expect(dialog.getByText("End", { exact: true })).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||||
await expect(inspector).toBeVisible();
|
||||
await expect(inspector.getByLabel("Trigger event")).toHaveAttribute(
|
||||
"data-value",
|
||||
"message_posted",
|
||||
);
|
||||
await expect(
|
||||
inspector.getByRole("button", { name: "All messages" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
const initialIngress = dialog.getByTestId("workflow-node-ingress");
|
||||
await expect(initialIngress).toHaveAttribute("data-terminal", "true");
|
||||
await expect(initialIngress.locator("svg.lucide-arrow-down")).toHaveCount(0);
|
||||
await expect(initialIngress.getByLabel("Add step")).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
@@ -659,7 +633,41 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
|
||||
"send_message",
|
||||
);
|
||||
await expect(inspector.getByLabel("Message text")).toBeVisible();
|
||||
await expect(dialog.getByText("End", { exact: true })).toBeVisible();
|
||||
await expect(inspector.getByRole("group", { name: "Condition" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
inspector.getByRole("heading", { name: "Step timeout" }),
|
||||
).toBeVisible();
|
||||
const ingresses = page.getByTestId("workflow-node-ingress");
|
||||
await expect(ingresses).toHaveCount(2);
|
||||
const betweenIngress = ingresses.first();
|
||||
const terminalIngress = ingresses.last();
|
||||
const betweenAddButton = betweenIngress.getByLabel("Add step");
|
||||
const betweenArrow = betweenIngress.locator("svg.lucide-arrow-down");
|
||||
await expect(betweenIngress).toHaveAttribute("data-terminal", "false");
|
||||
await expect(terminalIngress).toHaveAttribute("data-terminal", "true");
|
||||
await expect(terminalIngress.getByLabel("Add after Step 1")).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(betweenArrow).toHaveCSS("opacity", "1");
|
||||
await expect(betweenAddButton).toHaveCSS("opacity", "0");
|
||||
await expect(betweenAddButton).toHaveCSS("scale", "1.25");
|
||||
await expect(betweenAddButton).toHaveCSS("translate", "none");
|
||||
await betweenIngress.hover();
|
||||
await expect(betweenArrow).toHaveCSS("opacity", "0.1");
|
||||
await expect(betweenAddButton).toHaveCSS("opacity", "1");
|
||||
await expect(betweenAddButton).toHaveCSS("scale", "1");
|
||||
await expect(betweenAddButton).toHaveCSS("translate", "none");
|
||||
await betweenAddButton.click();
|
||||
await expect(betweenIngress).toHaveAttribute("data-menu-open", "true");
|
||||
await expect(page.getByRole("menuitem", { name: "Delay" })).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(betweenArrow).toHaveCSS("opacity", "0.1");
|
||||
await expect(betweenAddButton).toHaveCSS("opacity", "1");
|
||||
await expect(betweenAddButton).toHaveCSS("scale", "1");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(betweenIngress).toHaveAttribute("data-menu-open", "false");
|
||||
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
|
||||
await expect(triggerNode).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(stepNode).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
@@ -697,7 +705,7 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
|
||||
await expect(stepNode).not.toBeVisible();
|
||||
await expect(inspector).toBeVisible();
|
||||
await expect(inspector.getByLabel("Trigger event")).toBeVisible();
|
||||
await expect(dialog.getByText("End", { exact: true })).not.toBeVisible();
|
||||
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("switches between the form and YAML editors", async ({ page }) => {
|
||||
@@ -727,7 +735,7 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
enabled: false,
|
||||
trigger: "diff_posted",
|
||||
stepName: "Notify reviewers",
|
||||
stepCondition: 'str_contains(trigger_text, "src/")',
|
||||
triggerCondition: 'str_contains(trigger_text, "src/")',
|
||||
stepTimeoutSecs: "45",
|
||||
});
|
||||
|
||||
@@ -736,7 +744,9 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
await expect(card).toContainText(workflowName);
|
||||
await expect(card).toContainText("When a diff is posted, wait for a moment");
|
||||
await expect(card).toContainText(
|
||||
"When a matching diff is posted, wait for a moment",
|
||||
);
|
||||
await expect(card).toContainText(description);
|
||||
await expect(card).toContainText("Diff Posted");
|
||||
await expect(card).toContainText("disabled");
|
||||
|
||||
Reference in New Issue
Block a user