mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Improve workflow trigger details
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user