Improve workflow trigger details

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 23:09:03 -07:00
parent b09299e0ae
commit 5c4a43ec22
8 changed files with 853 additions and 180 deletions
@@ -50,7 +50,7 @@ function matchesPubkeyPrefix(pubkey: string, query: string) {
}
export function AuthorGridPicker({
ariaLabel = "Author pubkey",
ariaLabel = "Author",
disabled,
id,
knownPubkeys = [],
@@ -9,14 +9,14 @@ import { MessageIdPicker } from "./MessageIdPicker";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import {
buildConditionExpression,
buildConditionExpressions,
conditionFieldsForTrigger,
conditionOperatorsForField,
conditionOperatorNeedsValue,
CUSTOM_CONDITION_FIELD,
defaultConditionOperatorForField,
normalizeWebhookField,
parseConditionExpression,
parseConditionExpressions,
} from "./workflowConditionExpression";
import type {
ConditionOperator,
@@ -35,33 +35,39 @@ const OPERATOR_LABELS: Record<ConditionOperator, string> = {
is_empty: "is empty",
};
type ConditionEditorState = {
custom: boolean;
editors: ParsedConditionExpression[];
};
function normalizedEditor(
editor: ParsedConditionExpression,
): ParsedConditionExpression {
const supportedOperators = conditionOperatorsForField(editor.field);
return supportedOperators.includes(editor.operator)
? editor
: {
...editor,
operator: defaultConditionOperatorForField(editor.field),
};
}
function initialEditorState(
value: string,
triggerType: TriggerType,
): ParsedConditionExpression {
const parsed = parseConditionExpression(value, triggerType);
): ConditionEditorState {
const parsed = parseConditionExpressions(value, triggerType);
if (parsed) {
const supportedOperators = conditionOperatorsForField(parsed.field);
return supportedOperators.includes(parsed.operator)
? parsed
: {
...parsed,
operator: defaultConditionOperatorForField(parsed.field),
};
return { custom: false, editors: parsed.map(normalizedEditor) };
}
return {
field: value.trim() ? CUSTOM_CONDITION_FIELD : "",
operator: "contains",
value: "",
webhookField: "",
};
return { custom: value.trim().length > 0, editors: [] };
}
function valueLabel(field: string): string {
switch (field) {
case "trigger_author":
return "Pubkey";
return "Author";
case "trigger_message_id":
return "Message ID";
case "trigger_emoji":
@@ -86,6 +92,148 @@ function valuePlaceholder(field: string): string {
}
}
function ConditionEditorControls({
channelId,
disabled,
editor,
idPrefix,
knownAuthorPubkeys,
label,
onChange,
}: {
channelId?: string | null;
disabled?: boolean;
editor: ParsedConditionExpression;
idPrefix: string;
knownAuthorPubkeys: string[];
label: string;
onChange: (editor: ParsedConditionExpression) => void;
}) {
const needsValue = conditionOperatorNeedsValue(editor.operator);
const operatorOptions = conditionOperatorsForField(editor.field);
const usesExactMatchOperators = operatorOptions.length === 2;
const webhookFieldInvalid =
editor.field === "webhook_field" &&
editor.webhookField.length > 0 &&
normalizeWebhookField(editor.webhookField) === null;
const controlIdPrefix = `${idPrefix}-${editor.field}`;
return (
<fieldset
className="space-y-3 rounded-lg border border-border/60 bg-background/20 p-3"
data-testid={`workflow-condition-editor-${editor.field}`}
>
<legend className="px-1 text-xs font-semibold text-foreground">
{label}
</legend>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${controlIdPrefix}-operator`}>Match</FieldLabel>
<FormSelect
disabled={disabled}
id={`${controlIdPrefix}-operator`}
onChange={(operator) =>
onChange({
...editor,
operator: operator as ConditionOperator,
})
}
value={editor.operator}
>
{operatorOptions.map((operator) => (
<option key={operator} value={operator}>
{usesExactMatchOperators && operator === "equals"
? "is"
: OPERATOR_LABELS[operator]}
</option>
))}
</FormSelect>
</div>
{editor.field === "webhook_field" ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${controlIdPrefix}-webhook-field`}>
JSON field name
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${controlIdPrefix}-webhook-field`}
onChange={(event) =>
onChange({
...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={`${controlIdPrefix}-value`}>
{editor.field === "webhook_field"
? "Value"
: valueLabel(editor.field)}
</FieldLabel>
{editor.field === "trigger_author" ? (
<AuthorGridPicker
disabled={disabled}
id={`${controlIdPrefix}-value`}
knownPubkeys={knownAuthorPubkeys}
onChange={(pubkey) => onChange({ ...editor, value: pubkey })}
value={editor.value}
/>
) : editor.field === "trigger_emoji" ? (
<WorkflowEmojiField
ariaLabel="Choose condition emoji"
clearAriaLabel="Clear condition emoji"
disabled={disabled}
id={`${controlIdPrefix}-value`}
onChange={(emoji) => onChange({ ...editor, value: emoji ?? "" })}
value={editor.value}
/>
) : editor.field === "trigger_message_id" ? (
<MessageIdPicker
channelId={channelId}
disabled={disabled}
id={`${controlIdPrefix}-value`}
onChange={(messageId) =>
onChange({ ...editor, value: messageId })
}
value={editor.value}
/>
) : (
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${controlIdPrefix}-value`}
onChange={(event) =>
onChange({ ...editor, value: event.target.value })
}
placeholder={
editor.field === "webhook_field"
? "Value to match"
: valuePlaceholder(editor.field)
}
value={editor.value}
/>
)}
</div>
) : null}
</fieldset>
);
}
export function WorkflowConditionBuilder({
channelId,
channels,
@@ -139,7 +287,7 @@ export function WorkflowConditionBuilder({
return [...rankedPubkeys];
}, [channels, identityQuery.data?.pubkey]);
const [editor, setEditor] = React.useState(() =>
const [editorState, setEditorState] = React.useState(() =>
initialEditorState(value, triggerType),
);
const previousTriggerType = React.useRef(triggerType);
@@ -147,27 +295,22 @@ export function WorkflowConditionBuilder({
React.useEffect(() => {
if (previousTriggerType.current === triggerType) return;
previousTriggerType.current = triggerType;
setEditor(initialEditorState(value, triggerType));
setEditorState(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 emitEditors = (editors: ParsedConditionExpression[]) => {
setEditorState({ custom: false, editors });
onChange(buildConditionExpressions(editors));
};
const updateEditor = (next: ParsedConditionExpression) => {
emitEditors(
editorState.editors.map((editor) =>
editor.field === next.field ? next : editor,
),
);
};
const needsValue = conditionOperatorNeedsValue(editor.operator);
const operatorOptions = conditionOperatorsForField(editor.field);
const usesExactMatchOperators = operatorOptions.length === 2;
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,
@@ -178,6 +321,9 @@ export function WorkflowConditionBuilder({
...fields,
{ label: "Custom", value: CUSTOM_CONDITION_FIELD },
];
const selectedFields = new Set(
editorState.editors.map((editor) => editor.field),
);
return (
<div className="space-y-3">
@@ -187,7 +333,11 @@ export function WorkflowConditionBuilder({
{fieldOptions.map((field) => {
const isMatchAll = field.value === "";
const isCustom = field.value === CUSTOM_CONDITION_FIELD;
const isSelected = editor.field === field.value;
const isSelected = isMatchAll
? !editorState.custom && editorState.editors.length === 0
: isCustom
? editorState.custom
: selectedFields.has(field.value);
return (
<div
className={cn(
@@ -209,27 +359,34 @@ export function WorkflowConditionBuilder({
)}
disabled={disabled}
onClick={() => {
if (isSelected) return;
if (isMatchAll) {
setEditor({
field: "",
operator: "contains",
value: "",
webhookField: "",
});
setEditorState({ custom: false, editors: [] });
onChange("");
return;
}
if (isCustom) {
setEditor({ ...editor, field: field.value });
setEditorState({ custom: true, editors: [] });
return;
}
emitEditor({
field: field.value,
operator: defaultConditionOperatorForField(field.value),
value: "",
webhookField: "",
});
if (isSelected) {
emitEditors(
editorState.editors.filter(
(editor) => editor.field !== field.value,
),
);
} else {
emitEditors([
...editorState.editors,
{
field: field.value,
operator: defaultConditionOperatorForField(
field.value,
),
value: "",
webhookField: "",
},
]);
}
}}
type="button"
>
@@ -241,7 +398,7 @@ export function WorkflowConditionBuilder({
</div>
</fieldset>
{editor.field === CUSTOM_CONDITION_FIELD ? (
{editorState.custom ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${idPrefix}-custom-expression`}>
Custom expression
@@ -259,118 +416,23 @@ export function WorkflowConditionBuilder({
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}
>
{operatorOptions.map((operator) => (
<option key={operator} value={operator}>
{usesExactMatchOperators && operator === "equals"
? "is"
: OPERATOR_LABELS[operator]}
</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>
{editor.field === "trigger_author" ? (
<AuthorGridPicker
disabled={disabled}
id={`${idPrefix}-value`}
knownPubkeys={knownAuthorPubkeys}
onChange={(pubkey) =>
emitEditor({ ...editor, value: pubkey })
}
value={editor.value}
/>
) : editor.field === "trigger_emoji" ? (
<WorkflowEmojiField
ariaLabel="Choose condition emoji"
clearAriaLabel="Clear condition emoji"
disabled={disabled}
id={`${idPrefix}-value`}
onChange={(emoji) =>
emitEditor({ ...editor, value: emoji ?? "" })
}
value={editor.value}
/>
) : editor.field === "trigger_message_id" ? (
<MessageIdPicker
channelId={channelId}
disabled={disabled}
id={`${idPrefix}-value`}
onChange={(messageId) =>
emitEditor({ ...editor, value: messageId })
}
value={editor.value}
/>
) : (
<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}
) : (
editorState.editors.map((editor) => (
<ConditionEditorControls
channelId={channelId}
disabled={disabled}
editor={editor}
idPrefix={idPrefix}
key={editor.field}
knownAuthorPubkeys={knownAuthorPubkeys}
label={
fields.find((field) => field.value === editor.field)?.label ??
editor.field
}
onChange={updateEditor}
/>
))
)}
</div>
);
}
@@ -1,17 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import {
ArrowDown,
CalendarClock,
Check,
ChevronDown,
GitPullRequest,
LoaderCircle,
MessageSquare,
Plus,
SmilePlus,
Trash2,
TriangleAlert,
Webhook,
X,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
@@ -24,10 +35,14 @@ import {
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
import { WorkflowStepCard } from "./WorkflowStepCard";
import { buildConditionExpression } from "./workflowConditionExpression";
import {
buildConditionExpression,
parseConditionExpressions,
} from "./workflowConditionExpression";
import { FieldLabel } from "./workflowFormPrimitives";
import {
DEFAULT_FORM_STATE,
@@ -44,9 +59,22 @@ import type {
ActionType,
StepFormState,
TriggerConfig,
TriggerType,
WorkflowFormState,
} from "./workflowFormTypes";
import { defaultScheduleTrigger } from "./workflowSchedule";
import {
TRIGGER_MESSAGE_LOADING_LABEL,
workflowTriggerDescription,
} from "./workflowTriggerDescription";
const TRIGGER_ICONS: Record<TriggerType, LucideIcon> = {
diff_posted: GitPullRequest,
message_posted: MessageSquare,
reaction_added: SmilePlus,
schedule: CalendarClock,
webhook: Webhook,
};
function TriggerConfigFields({
channels,
@@ -231,7 +259,7 @@ function WorkflowNode({
terminal,
title,
}: {
description: string;
description: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
label: string;
@@ -359,6 +387,90 @@ function WorkflowNode({
);
}
function TriggerNodeDescription({
authorAvatarUrl,
authorLabel,
description,
messageLoading,
}: {
authorAvatarUrl?: string | null;
authorLabel?: string | null;
description: string;
messageLoading?: boolean;
}) {
const authorIndex = authorLabel ? description.lastIndexOf(authorLabel) : -1;
if (!authorLabel || authorIndex < 0) {
return (
<TriggerDescriptionText
messageLoading={messageLoading}
text={description}
/>
);
}
const prefix = description.slice(0, authorIndex).trimEnd();
const suffix = description
.slice(authorIndex + authorLabel.length)
.trimStart();
return (
<span className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0">{prefix}</span>
<UserAvatar
avatarUrl={authorAvatarUrl ?? null}
className="h-4 w-4"
displayName={authorLabel}
fallbackDelayMs={0}
size="xs"
testId="workflow-trigger-author-avatar"
/>
<span className="min-w-0 truncate">
{authorLabel}{" "}
{suffix ? (
<TriggerDescriptionText
messageLoading={messageLoading}
text={suffix}
/>
) : null}
</span>
</span>
);
}
function TriggerDescriptionText({
messageLoading,
text,
}: {
messageLoading?: boolean;
text: string;
}) {
const loadingIndex = messageLoading
? text.indexOf(TRIGGER_MESSAGE_LOADING_LABEL)
: -1;
if (loadingIndex < 0) return text;
const prefix = text.slice(0, loadingIndex);
const suffix = text.slice(
loadingIndex + TRIGGER_MESSAGE_LOADING_LABEL.length,
);
return (
<>
{prefix}
<motion.span
animate={{ opacity: 1 }}
aria-label="Loading message"
className="inline-flex align-text-bottom"
data-testid="workflow-trigger-message-loading"
initial={{ opacity: 0 }}
role="status"
transition={{ delay: 0.5, duration: 0.15 }}
>
<LoaderCircle aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
</motion.span>
{suffix}
</>
);
}
export function WorkflowFormBuilder({
channels,
disabled,
@@ -383,6 +495,72 @@ export function WorkflowFormBuilder({
const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1);
const shouldReduceMotion = useReducedMotion();
const previousModeRef = React.useRef(mode);
const identityQuery = useIdentityQuery();
const parsedTriggerConditions = React.useMemo(
() =>
formState.trigger.filter
? parseConditionExpressions(
formState.trigger.filter,
formState.trigger.on,
)
: [],
[formState.trigger.filter, formState.trigger.on],
);
const triggerAuthorCondition = parsedTriggerConditions?.find(
(condition) => condition.field === "trigger_author",
);
const triggerAuthorPubkey =
triggerAuthorCondition &&
/^[0-9a-f]{64}$/i.test(triggerAuthorCondition.value)
? triggerAuthorCondition.value
: null;
const triggerAuthorProfiles = useUsersBatchQuery(
triggerAuthorPubkey ? [triggerAuthorPubkey] : [],
);
const triggerAuthorProfile = triggerAuthorPubkey
? triggerAuthorProfiles.data?.profiles[triggerAuthorPubkey.toLowerCase()]
: undefined;
const triggerAuthorLabel = triggerAuthorPubkey
? resolveUserLabel({
currentPubkey: identityQuery.data?.pubkey,
profiles: triggerAuthorProfiles.data?.profiles,
pubkey: triggerAuthorPubkey,
})
: null;
const triggerMessageCondition = parsedTriggerConditions?.find(
(condition) => condition.field === "trigger_message_id",
);
const triggerMessageId =
triggerMessageCondition &&
/^[0-9a-f]{64}$/i.test(triggerMessageCondition.value)
? triggerMessageCondition.value
: null;
const triggerMessageQuery = useQuery({
enabled: Boolean(triggerMessageId),
queryKey: ["workflow-trigger-message", workflowChannelId, triggerMessageId],
queryFn: () => getEventById(triggerMessageId ?? ""),
retry: false,
staleTime: 60_000,
});
const triggerMessage =
triggerMessageQuery.data &&
(!workflowChannelId ||
triggerMessageQuery.data.tags.some(
(tag) => tag[0] === "h" && tag[1] === workflowChannelId,
))
? triggerMessageQuery.data
: null;
const triggerMessageLabel = triggerMessage?.content.trim() || undefined;
const triggerMessageLoading =
Boolean(triggerMessageId) &&
triggerMessageQuery.isFetching &&
!triggerMessageQuery.data;
const triggerDescription = workflowTriggerDescription(formState.trigger, {
authorLabel: triggerAuthorLabel ?? undefined,
messageLabel: triggerMessageLabel,
messageLoading: triggerMessageLoading,
});
const TriggerIcon = TRIGGER_ICONS[formState.trigger.on];
const updateFormState = React.useCallback(
(next: WorkflowFormState) => {
@@ -547,10 +725,23 @@ export function WorkflowFormBuilder({
{scopeField ? <div className="mb-3">{scopeField}</div> : null}
<ol aria-label="Workflow sequence">
<WorkflowNode
description={TRIGGER_LABELS[formState.trigger.on]}
description={
<TriggerNodeDescription
authorAvatarUrl={triggerAuthorProfile?.avatarUrl}
authorLabel={triggerAuthorLabel}
description={triggerDescription}
messageLoading={triggerMessageLoading}
/>
}
disabled={disabled}
icon={<Zap className="h-4 w-4" />}
label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`}
icon={
<TriggerIcon
aria-hidden="true"
className="h-4 w-4"
data-testid={`workflow-trigger-icon-${formState.trigger.on}`}
/>
}
label={`Trigger: ${triggerDescription}`}
onAddAfter={(action) => insertStep(0, action)}
onClick={() => selectNode({ type: "trigger" })}
selected={selectedNode?.type === "trigger"}
@@ -3,10 +3,12 @@ import { test } from "node:test";
import {
buildConditionExpression,
buildConditionExpressions,
conditionFieldsForTrigger,
conditionOperatorsForField,
normalizeWebhookField,
parseConditionExpression,
parseConditionExpressions,
} from "./workflowConditionExpression.ts";
test("builds the supported string conditions", () => {
@@ -135,6 +137,69 @@ test("parses generated conditions back into editor fields", () => {
);
});
test("builds and parses multiple trigger conditions joined by AND", () => {
const conditions = [
{
field: "trigger_emoji",
operator: "equals",
value: "👾",
webhookField: "",
},
{
field: "trigger_author",
operator: "equals",
value: "a".repeat(64),
webhookField: "",
},
{
field: "trigger_message_id",
operator: "equals",
value: "b".repeat(64),
webhookField: "",
},
];
const expression = buildConditionExpressions(conditions);
assert.equal(
expression,
`trigger_emoji == "👾" && trigger_author == "${"a".repeat(64)}" && trigger_message_id == "${"b".repeat(64)}"`,
);
assert.deepEqual(
parseConditionExpressions(expression, "reaction_added"),
conditions,
);
});
test("does not split conjunction text inside strings or nested expressions", () => {
assert.deepEqual(
parseConditionExpressions(
'str_contains(trigger_text, "one && two") && trigger_author == "abc"',
"message_posted",
),
[
{
field: "trigger_text",
operator: "contains",
value: "one && two",
webhookField: "",
},
{
field: "trigger_author",
operator: "equals",
value: "abc",
webhookField: "",
},
],
);
assert.equal(
parseConditionExpressions(
'(trigger_emoji == "👾" && trigger_author == "abc")',
"reaction_added",
),
null,
);
});
test("keeps unsupported expressions in custom mode", () => {
assert.equal(
parseConditionExpression("trigger_timestamp > 0", "message_posted"),
@@ -50,7 +50,7 @@ export type ParsedConditionExpression = {
export const CUSTOM_CONDITION_FIELD = "custom";
const AUTHOR_FIELD: ConditionField = {
label: "Author pubkey",
label: "Author",
value: "trigger_author",
};
@@ -136,6 +136,16 @@ export function buildConditionExpression({
}
}
/** Build a conjunction from every complete condition in the form editor. */
export function buildConditionExpressions(
conditions: ParsedConditionExpression[],
): string {
return conditions
.map((condition) => buildConditionExpression(condition))
.filter((condition): condition is string => condition !== null)
.join(" && ");
}
function unescapeEvalexprString(value: string): string {
return value.replaceAll(/\\(["\\])/g, "$1");
}
@@ -217,3 +227,72 @@ export function parseConditionExpression(
return null;
}
function splitTopLevelConjunctions(expression: string): string[] | null {
const parts: string[] = [];
let start = 0;
let parenthesisDepth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < expression.length; index += 1) {
const character = expression[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}
if (character === '"') {
inString = true;
} else if (character === "(") {
parenthesisDepth += 1;
} else if (character === ")") {
parenthesisDepth -= 1;
if (parenthesisDepth < 0) return null;
} else if (
character === "&" &&
expression[index + 1] === "&" &&
parenthesisDepth === 0
) {
const part = expression.slice(start, index).trim();
if (!part) return null;
parts.push(part);
index += 1;
start = index + 1;
}
}
if (inString || parenthesisDepth !== 0) return null;
const finalPart = expression.slice(start).trim();
if (!finalPart) return null;
parts.push(finalPart);
return parts;
}
/** Parse a form-generated `&&` filter back into independently editable fields. */
export function parseConditionExpressions(
expression: string,
triggerType: TriggerType,
): ParsedConditionExpression[] | null {
const trimmed = expression.trim();
if (!trimmed) return [];
const parts = splitTopLevelConjunctions(trimmed);
if (!parts) return null;
const parsed = parts.map((part) =>
parseConditionExpression(part, triggerType),
);
if (parsed.some((condition) => condition === null)) return null;
const conditions = parsed.filter(
(condition): condition is ParsedConditionExpression => condition !== null,
);
const fields = new Set(conditions.map((condition) => condition.field));
return fields.size === conditions.length ? conditions : null;
}
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { workflowTriggerDescription } from "./workflowTriggerDescription.ts";
test("describes selected trigger conditions on the workflow canvas", () => {
assert.equal(
workflowTriggerDescription(
{
on: "message_posted",
filter: `trigger_author == "${"a".repeat(64)}"`,
},
{ authorLabel: "Carl" },
),
"Message posted by Carl",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: 'str_contains(trigger_text, "deploy")',
}),
"Message posted containing “deploy”",
);
assert.equal(
workflowTriggerDescription({
on: "reaction_added",
filter: 'trigger_emoji == "🔥"',
}),
"🔥 reaction added",
);
assert.equal(
workflowTriggerDescription(
{
on: "reaction_added",
filter: `trigger_message_id == "${"b".repeat(64)}"`,
},
{ messageLoading: true },
),
"Reaction added to loading message",
);
assert.equal(
workflowTriggerDescription(
{
on: "reaction_added",
filter: `trigger_emoji == "👾" && trigger_author == "${"a".repeat(64)}" && trigger_message_id == "${"b".repeat(64)}"`,
},
{ authorLabel: "Carl", messageLabel: "hey yourself" },
),
"👾 reaction added by Carl to “hey yourself”",
);
});
test("keeps the base label for unfiltered and custom triggers", () => {
assert.equal(
workflowTriggerDescription({ on: "message_posted" }),
"Message Posted",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: "custom_variable == 1",
}),
"Message Posted",
);
});
@@ -0,0 +1,156 @@
import { truncatePubkey } from "@/shared/lib/pubkey";
import { parseConditionExpressions } from "./workflowConditionExpression";
import { TRIGGER_LABELS } from "./workflowFormTypes";
import type { ParsedConditionExpression } from "./workflowConditionExpression";
import type { TriggerConfig } from "./workflowFormTypes";
const EVENT_PHRASES = {
diff_posted: "Diff posted",
message_posted: "Message posted",
reaction_added: "Reaction added",
} as const;
export const TRIGGER_MESSAGE_LOADING_LABEL = "loading message";
function quotedValue(value: string): string {
const normalized = value.trim().replaceAll(/\s+/g, " ");
const abbreviated =
normalized.length > 36 ? `${normalized.slice(0, 33)}...` : normalized;
return `“${abbreviated}”`;
}
function messageReference(
condition: ParsedConditionExpression,
messageLabel?: string,
messageLoading?: boolean,
): string {
if (messageLoading) return TRIGGER_MESSAGE_LOADING_LABEL;
return messageLabel
? quotedValue(messageLabel)
: truncatePubkey(condition.value);
}
function textConditionDescription(
eventPhrase: string,
condition: ParsedConditionExpression,
): string {
const value = quotedValue(condition.value);
switch (condition.operator) {
case "contains":
return `${eventPhrase} containing ${value}`;
case "not_contains":
return `${eventPhrase} without ${value}`;
case "starts_with":
return `${eventPhrase} starting with ${value}`;
case "ends_with":
return `${eventPhrase} ending with ${value}`;
case "equals":
return `${eventPhrase} matching ${value}`;
case "not_equals":
return `${eventPhrase} not matching ${value}`;
case "is_not_empty":
return `Non-empty ${eventPhrase.toLowerCase()}`;
case "is_empty":
return `Empty ${eventPhrase.toLowerCase()}`;
}
}
/** Build the concise trigger summary rendered on the workflow canvas. */
export function workflowTriggerDescription(
trigger: TriggerConfig,
options: {
authorLabel?: string;
messageLabel?: string;
messageLoading?: boolean;
} = {},
): string {
const baseLabel = TRIGGER_LABELS[trigger.on];
const eventPhrase = EVENT_PHRASES[trigger.on as keyof typeof EVENT_PHRASES];
if (!eventPhrase) return baseLabel;
const conditions = trigger.filter
? parseConditionExpressions(trigger.filter, trigger.on)
: [];
if (!conditions || conditions.length === 0) {
return trigger.on === "reaction_added" && trigger.emoji
? `${trigger.emoji} reaction added`
: baseLabel;
}
const condition = conditions[0];
if (conditions.length > 1) {
const authorCondition = conditions.find(
({ field }) => field === "trigger_author",
);
const textCondition = conditions.find(
({ field }) => field === "trigger_text",
);
const emojiCondition = conditions.find(
({ field }) => field === "trigger_emoji",
);
const messageCondition = conditions.find(
({ field }) => field === "trigger_message_id",
);
let description = textCondition
? textConditionDescription(eventPhrase, textCondition)
: eventPhrase;
if (emojiCondition) {
description =
emojiCondition.operator === "not_equals"
? `Any reaction except ${emojiCondition.value} added`
: `${emojiCondition.value} reaction added`;
}
if (authorCondition) {
const author =
options.authorLabel ?? truncatePubkey(authorCondition.value);
description +=
authorCondition.operator === "not_equals"
? ` by anyone except ${author}`
: ` by ${author}`;
}
if (messageCondition) {
const message = messageReference(
messageCondition,
options.messageLabel,
options.messageLoading,
);
description +=
messageCondition.operator === "not_equals"
? ` anywhere except ${message}`
: ` to ${message}`;
}
return description;
}
if (condition.field === "trigger_author") {
const author = options.authorLabel ?? truncatePubkey(condition.value);
return condition.operator === "not_equals"
? `${eventPhrase} by anyone except ${author}`
: `${eventPhrase} by ${author}`;
}
if (condition.field === "trigger_text") {
return textConditionDescription(eventPhrase, condition);
}
if (condition.field === "trigger_emoji") {
return condition.operator === "not_equals"
? `Any reaction except ${condition.value} added`
: `${condition.value} reaction added`;
}
if (condition.field === "trigger_message_id") {
const message = messageReference(
condition,
options.messageLabel,
options.messageLoading,
);
return condition.operator === "not_equals"
? `Reaction added anywhere except ${message}`
: `Reaction added to ${message}`;
}
return baseLabel;
}
+59 -8
View File
@@ -288,7 +288,7 @@ 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", "Custom"]) {
for (const name of ["Author", "Custom"]) {
await expect(conditionFields.getByRole("button", { name })).toBeVisible();
}
for (const name of ["Channel ID", "Message ID"]) {
@@ -363,7 +363,7 @@ test("chooses a trigger author condition from live user search", async ({
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
await inspector.getByRole("button", { name: "Author pubkey" }).click();
await inspector.getByRole("button", { name: "Author" }).click();
const authorPicker = inspector.getByTestId("author-grid-picker");
await expect(authorPicker).toBeVisible();
await expect
@@ -438,6 +438,16 @@ test("chooses a trigger author condition from live user search", async ({
);
await selectedAuthor.click();
await expect(selectedAuthor).toHaveAttribute("aria-pressed", "true");
const triggerNode = dialog.getByRole("button", {
name: "Trigger: Message posted by Workflow member 59",
});
await expect(triggerNode).toBeVisible();
await expect(
triggerNode.getByTestId("workflow-trigger-icon-message_posted"),
).toBeVisible();
await expect(
triggerNode.locator('[data-testid^="workflow-trigger-author-avatar-"]'),
).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
@@ -456,9 +466,10 @@ test("chooses and clears a reaction trigger with the app emoji picker", async ({
const inspector = dialog.getByTestId("workflow-node-inspector");
await inspector.getByLabel("Trigger event").click();
await page.getByRole("menuitem", { name: "Reaction Added" }).click();
await inspector.getByRole("button", { name: "Reaction emoji" }).click();
const trigger = inspector.getByRole("button", {
name: "Choose emoji filter",
name: "Choose condition emoji",
});
await expect(trigger).toContainText("Choose a reaction");
await trigger.click();
@@ -468,15 +479,23 @@ test("chooses and clears a reaction trigger with the app emoji picker", async ({
await picker.getByRole("button", { name: ":buzz:" }).first().click();
await expect(trigger).toContainText(":buzz:");
await expect(
dialog.getByRole("button", {
name: "Trigger: :buzz: reaction added",
}),
).toBeVisible();
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 dialog.getByRole("button", { name: "Clear condition emoji" }).click();
await expect(
dialog.getByRole("button", { name: "Choose emoji filter" }),
dialog.getByRole("button", { name: "Choose condition emoji" }),
).toContainText("Choose a reaction");
await expect(
dialog.getByRole("button", { name: "Trigger: Reaction Added" }),
).toBeVisible();
});
test("chooses an add-reaction step emoji with the app emoji picker", async ({
@@ -565,7 +584,26 @@ test("chooses a reacted-to message from the workflow channel", async ({
const inspector = dialog.getByTestId("workflow-node-inspector");
await inspector.getByLabel("Trigger event").click();
await page.getByRole("menuitem", { name: "Reaction Added" }).click();
await inspector.getByRole("button", { name: "Message ID" }).click();
const emojiField = inspector.getByRole("button", {
name: "Reaction emoji",
});
const authorField = inspector.getByRole("button", { name: "Author" });
const messageField = inspector.getByRole("button", { name: "Message ID" });
await emojiField.click();
await inspector
.getByRole("button", { name: "Choose condition emoji" })
.click();
const emojiPicker = page.locator("em-emoji-picker");
await emojiPicker.locator("input[type='search']").fill("buzz");
await emojiPicker.getByRole("button", { name: ":buzz:" }).first().click();
await authorField.click();
await page
.getByTestId(`workflow-author-result-${TEST_IDENTITIES.alice.pubkey}`)
.click();
await messageField.click();
const search = inspector.getByLabel("Search messages or paste a message ID");
const messageList = inspector.getByTestId("message-id-picker-list");
@@ -582,9 +620,22 @@ test("chooses a reacted-to message from the workflow channel", async ({
await expect(targetMessage).toBeVisible();
await targetMessage.click();
for (const field of [emojiField, authorField, messageField]) {
await expect(field).toHaveAttribute("aria-pressed", "true");
}
await expect(
dialog.getByRole("button", {
name: "Trigger: :buzz: reaction added by alice to “React to me with a custom emoji”",
}),
).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/filter: trigger_message_id ==\s+"[0-9a-f]{64}"/,
const workflowYaml = dialog.getByLabel("Workflow YAML");
await expect(workflowYaml).toHaveValue(
/filter: trigger_emoji == ":buzz:" && trigger_author ==\s+"[0-9a-f]{64}" &&\s+trigger_message_id ==\s+"[0-9a-f]{64}"/,
);
await expect(workflowYaml).toHaveValue(
new RegExp(TEST_IDENTITIES.alice.pubkey),
);
});