feat(workflows): clarify workflow setup and activation (#6470)

**Category:** new-feature
**User Impact:** Workflow authors can build filtered, runtime-aware
automations, understand them at a glance, and get a clear warning before
turning on workflows likely to run often.
**Problem:** Workflow setup exposed raw configuration without enough
help composing message templates, filtering triggers, or understanding
saved behavior; activation could also make a broadly triggered workflow
live without explaining its likely frequency.
**Solution:** Batch 3 adds local, deterministic template variables,
trigger filters, and semantic summaries, then refines cards and
activation around configured behavior and a risk-aware warning boundary.
Scheduling remains the already-shipped implementation, advanced
expressions remain lossless, and network-backed identity/message
enrichment stays in Batch 4.

| Message inputs | Trigger filters |
| --- | --- |
| Caret-aware, keyboard-accessible suggestions expose trigger-local
values and safe prior-step outputs in `send_message.text`. | Structured
conditions and validated manual IDs block invalid submission while
preserving advanced expressions. |
| ![Message variable
autocomplete](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/message-variables.png)
| ![Structured trigger
filters](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/trigger-filters.png)
|

| Workflow cards | Risk-aware activation |
| --- | --- |
| Semantic labels, channel-first hierarchy, configured reaction/action
visuals, real step stacks, and compact status controls make behavior
scannable. | Broad message and frequent schedule triggers explain the
risk before **Turn on**; narrowly scoped triggers proceed without
unnecessary ceremony. |
| ![Semantic workflow
card](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/workflow-card.png)
| ![Activation
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/activation-choice-v2.png)
|

## Changes

<details>
<summary>File changes</summary>

**desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx**  
Separates direct card status controls from secondary actions while
retaining modal status actions.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**  
Adds semantic behavior, channel-first hierarchy, configured
reaction/action visuals, real subsequent-step stacks, status controls,
and reduced-motion-aware trigger feedback.

**desktop/src/features/workflows/ui/WorkflowDialog.tsx**  
Warns before activating broadly triggered workflows while allowing
narrowly scoped workflows to proceed directly.

**desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx**  
Connects structured trigger filters and template-aware step inputs while
preserving schedules, trigger transitions, and selected YAML authority.

**desktop/src/features/workflows/ui/WorkflowStepCard.tsx**  
Replaces generic labels with deterministic configured-step descriptions.

**desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx**  
Adds caret-aware variable suggestions with keyboard navigation and focus
restoration.

**desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx**  
Adds structured local filters, validated author/message IDs, and a
lossless advanced-expression fallback.

**desktop/src/features/workflows/ui/workflowActivationWarning.ts** and
**workflowActivationWarning.test.mjs**
Classify broad message and frequent schedule triggers for contextual
activation warnings.

**desktop/src/features/workflows/ui/workflowConditionExpression.ts** and
**workflowConditionExpression.test.mjs**
Model and cover parsing, serialization, validation, and
advanced-expression preservation.

**desktop/src/features/workflows/ui/workflowDefinition.ts** and
**workflowDefinition.test.mjs**
Preserve trigger/step configuration and derive deterministic card
metadata across YAML round trips.

**desktop/src/features/workflows/ui/workflowStepDescription.ts** and
**workflowStepDescription.test.mjs**
Generate and cover local step summaries.

**desktop/src/features/workflows/ui/workflowTemplateVariables.ts** and
**workflowTemplateVariables.test.mjs**
Define and cover trigger-specific, order-bounded variables and caret
insertion.

**desktop/src/features/workflows/ui/workflowTriggerDescription.ts** and
**workflowTriggerDescription.test.mjs**
Generate and cover semantic trigger summaries without network lookups.

**desktop/tests/e2e/workflow-local-controls.spec.ts** and snapshot  
Cover filters, IDs, advanced expressions, autocomplete, activation
choices, summaries, and YAML authority.

**desktop/tests/e2e/workflow-reaction-picker.spec.ts**  
Covers configured reaction emoji in workflow nodes and summaries.

**desktop/tests/e2e/workflows.spec.ts**  
Covers risk-aware activation warnings, direct safe creation,
duplication, and card status controls.

</details>

## Reproduction steps

1. Create a message-posted workflow in **Workflows**, add a Send message
step, and type `{{trig`; verify keyboard-selectable variables insert at
the caret.
2. Configure message-text and manual ID filters; verify malformed IDs
block submission and advanced expressions survive Form/YAML transitions.
3. Create a broad message workflow; verify **Back** persists nothing,
**Keep off** saves it disabled, and **Turn on** enables it. Confirm a
narrowly triggered webhook skips the warning.
4. Inspect the saved card; verify its channel, semantic behavior,
configured actions/reaction, real step stack, and status are
understandable without opening YAML.

## Validation

Validated at exact clean head `f99503819889b95ee3c61657c5c3850aae35481e`
on base `24ec6a468ec9d0d425ee58fbfc4d416412c446ad`.

- Focused workflow regressions passed 59/60 locally; the only local miss
was a 438-pixel macOS snapshot drift, while the checked-in Linux
baseline comes from the failing CI artifact. Repository pre-push gates
and E2E build/typecheck passed.
- A broader 36-test smoke invocation had 31 passes and five unrelated
pre-existing expectation/snapshot failures, so it is not claimed as
fully green. Adversarial fixes are recorded in [round
one](https://github.com/block/buzz/pull/6470#issuecomment-5366366670)
and [round
two](https://github.com/block/buzz/pull/6470#issuecomment-5366407535).

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-21 16:53:50 -07:00
committed by GitHub
parent b85d680fb1
commit 0e48ff2691
29 changed files with 3812 additions and 272 deletions

View File

@@ -26,6 +26,7 @@ type WorkflowActionsMenuProps = {
onEdit: () => void;
onToggleEnabled: () => void;
onTrigger: () => void;
showEnabledToggle?: boolean;
};
export function WorkflowActionsMenu({
@@ -36,6 +37,7 @@ export function WorkflowActionsMenu({
onEdit,
onToggleEnabled,
onTrigger,
showEnabledToggle = true,
}: WorkflowActionsMenuProps) {
return (
<DropdownMenu>
@@ -63,37 +65,39 @@ export function WorkflowActionsMenu({
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuCheckboxItem
checked={isEnabled}
className="gap-2 pl-2 [&>span:first-child]:hidden"
disabled={isTogglingEnabled}
onCheckedChange={(checked) => {
if (checked !== isEnabled) onToggleEnabled();
}}
onSelect={(event) => event.preventDefault()}
>
{isEnabled ? (
<Power className="mr-2 h-4 w-4 shrink-0" />
) : (
<PowerOff className="mr-2 h-4 w-4 shrink-0" />
)}
<span>Enable</span>
<span
aria-hidden="true"
className={
"ml-auto inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent transition-colors " +
(isEnabled ? "bg-primary" : "bg-input")
}
data-testid="workflow-enabled-switch-visual"
{showEnabledToggle ? (
<DropdownMenuCheckboxItem
checked={isEnabled}
className="gap-2 pl-2 [&>span:first-child]:hidden"
disabled={isTogglingEnabled}
onCheckedChange={(checked) => {
if (checked !== isEnabled) onToggleEnabled();
}}
onSelect={(event) => event.preventDefault()}
>
{isEnabled ? (
<Power className="mr-2 h-4 w-4 shrink-0" />
) : (
<PowerOff className="mr-2 h-4 w-4 shrink-0" />
)}
<span>Enable</span>
<span
aria-hidden="true"
className={
"block h-4 w-4 rounded-full bg-background transition-transform " +
(isEnabled ? "translate-x-4" : "translate-x-0")
"ml-auto inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent transition-colors " +
(isEnabled ? "bg-primary" : "bg-input")
}
/>
</span>
</DropdownMenuCheckboxItem>
data-testid="workflow-enabled-switch-visual"
>
<span
className={
"block h-4 w-4 rounded-full bg-background transition-transform " +
(isEnabled ? "translate-x-4" : "translate-x-0")
}
/>
</span>
</DropdownMenuCheckboxItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />

View File

@@ -6,25 +6,27 @@ import {
Hash,
MessageCircle,
MessageSquare,
Send,
SmilePlus,
Timer,
Webhook,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import type { Workflow } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Switch } from "@/shared/ui/switch";
import { WorkflowActionsMenu } from "./WorkflowActionsMenu";
import {
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowEnabled,
getWorkflowPrimaryAction,
getWorkflowTriggerSummary,
getWorkflowActionTiles,
getWorkflowCardLabel,
getWorkflowTriggerEmoji,
getWorkflowTriggerType,
} from "./workflowDefinition";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
type WorkflowCardProps = {
workflow: Workflow;
@@ -52,7 +54,7 @@ const ACTION_ICONS: Record<string, LucideIcon> = {
delay: Timer,
request_approval: CircleCheckBig,
send_dm: MessageCircle,
send_message: Send,
send_message: MessageSquare,
set_channel_topic: Hash,
};
@@ -64,15 +66,123 @@ const TRIGGER_ACCENTS: Record<string, string> = {
webhook: "border-orange-300/30 bg-orange-500 text-white",
};
function StatusBadge({ status }: { status: Workflow["status"] }) {
const ACTION_ACCENTS: Record<string, string> = {
add_reaction: "border-pink-400/30 bg-pink-600 text-white",
call_webhook: "border-orange-300/30 bg-orange-500 text-white",
delay: "border-sky-300/30 bg-sky-500 text-white",
request_approval: "border-emerald-300/30 bg-emerald-600 text-white",
send_dm: "border-indigo-300/30 bg-indigo-600 text-white",
send_message: "border-blue-300/30 bg-blue-600 text-white",
set_channel_topic: "border-violet-300/30 bg-violet-600 text-white",
};
function StatusToggle({
disabled,
enabled,
onToggle,
}: {
disabled: boolean;
enabled: boolean;
onToggle: () => void;
}) {
return (
<Switch
aria-label={enabled ? "Disable workflow" : "Enable workflow"}
checked={enabled}
disabled={disabled}
onCheckedChange={(checked) => {
if (checked !== enabled) onToggle();
}}
/>
);
}
function ActionTile({
action,
animationSequence,
className,
emoji,
index,
}: {
action: string;
animationSequence: number;
className?: string;
emoji: string | null;
index: number;
}) {
const ActionIcon = ACTION_ICONS[action];
const accent = ACTION_ACCENTS[action];
const reduceMotion = useReducedMotion();
return (
<span
aria-hidden="true"
className={cn(
"absolute inset-y-0 flex w-9 items-center justify-center rounded-xl border shadow-xs",
accent ?? "border-border/65 bg-background/80 text-muted-foreground",
className,
)}
>
<motion.span
animate={
animationSequence > 0 && !reduceMotion
? { scale: [1, 1.18, 0.94, 1], y: [0, -5, 1, 0] }
: undefined
}
className="flex items-center justify-center"
transition={{
delay: index * 0.11,
duration: 0.48,
ease: "easeOut",
}}
>
{emoji ? (
<StatusEmoji className="h-6 w-6 text-xl" value={emoji} />
) : ActionIcon ? (
<ActionIcon className="h-5 w-5" />
) : (
<Zap className="h-5 w-5" />
)}
</motion.span>
</span>
);
}
function ActionTileStack({
actions,
animationSequence = 0,
}: {
actions: Array<{ action: string; emoji: string | null; key: string }>;
animationSequence?: number;
}) {
const visibleActions = actions.slice(0, 3);
return (
<span
className={cn(
"rounded-full border border-border/65 bg-background/80 px-2 py-1 text-2xs font-semibold uppercase tracking-wider shadow-xs",
status === "active" ? "text-foreground" : "text-muted-foreground",
"relative h-9",
visibleActions.length === 1 && "w-9",
visibleActions.length === 2 && "w-[2.625rem]",
visibleActions.length > 2 && "w-12",
)}
data-testid="workflow-card-action-stack"
>
{status}
{visibleActions
.map((action, index) => (
<ActionTile
action={action.action}
animationSequence={animationSequence}
className={cn(
index === 0 && "left-0 z-10",
index === 1 && "left-1.5 z-[5] scale-90 opacity-60",
index === 2 && "left-3 scale-75 opacity-35",
)}
emoji={action.emoji}
index={index}
key={`${action.key}-${animationSequence}`}
/>
))
.reverse()}
</span>
);
}
@@ -88,19 +198,20 @@ export function WorkflowCard({
onDuplicate,
onDelete,
}: WorkflowCardProps) {
const displayStatus = getWorkflowDisplayStatus(workflow);
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
const description = getWorkflowDescription(workflow.definition);
const [triggerAnimationSequence, setTriggerAnimationSequence] =
React.useState(0);
const isEnabled = getWorkflowEnabled(workflow.definition);
const cardLabel = getWorkflowCardLabel(workflow.definition);
const triggerType = getWorkflowTriggerType(workflow.definition);
const actionType = getWorkflowPrimaryAction(workflow.definition);
const actionTiles = getWorkflowActionTiles(workflow.definition);
const triggerEmoji = getWorkflowTriggerEmoji(workflow.definition);
const TriggerIcon = triggerType ? TRIGGER_ICONS[triggerType] : undefined;
const ActionIcon = actionType ? ACTION_ICONS[actionType] : undefined;
const triggerAccent = triggerType ? TRIGGER_ACCENTS[triggerType] : undefined;
return (
<div
className={cn(
"group relative min-h-60 w-full overflow-hidden rounded-2xl bg-muted/50 p-5 text-left text-foreground shadow-xs transition-colors hover:bg-muted/65",
"group relative flex min-h-60 w-full flex-col overflow-hidden rounded-2xl bg-muted/50 p-5 text-left text-foreground shadow-xs transition-colors hover:bg-muted/65",
)}
data-testid={`workflow-card-${workflow.id}`}
>
@@ -112,7 +223,7 @@ export function WorkflowCard({
<span className="sr-only">View {workflow.name}</span>
</button>
<div className="pointer-events-none relative z-10 flex h-full min-h-48 flex-col">
<div className="pointer-events-none relative z-10 flex min-h-48 flex-1 flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2" aria-hidden="true">
<span
@@ -121,54 +232,74 @@ export function WorkflowCard({
triggerAccent ?? "border-slate-400/30 bg-slate-600 text-white",
)}
>
{TriggerIcon ? (
{triggerEmoji ? (
<StatusEmoji className="h-6 w-6 text-xl" value={triggerEmoji} />
) : TriggerIcon ? (
<TriggerIcon className="h-5 w-5" />
) : (
<Zap className="h-5 w-5" />
)}
</span>
{ActionIcon ? (
{actionTiles.length > 0 ? (
<>
<ArrowRight className="h-4 w-4 text-muted-foreground/60" />
<span className="flex h-9 w-9 items-center justify-center rounded-xl border border-border/65 bg-background/80 text-muted-foreground shadow-xs">
<ActionIcon className="h-5 w-5" />
</span>
<ActionTileStack
actions={actionTiles}
animationSequence={triggerAnimationSequence}
/>
</>
) : null}
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<StatusBadge status={displayStatus} />
<StatusToggle
disabled={isTogglingEnabled}
enabled={isEnabled}
onToggle={() => onToggleEnabled(workflow)}
/>
<WorkflowActionsMenu
isEnabled={getWorkflowEnabled(workflow.definition)}
isEnabled={isEnabled}
isTogglingEnabled={isTogglingEnabled}
onDelete={() => onDelete(workflow)}
onDuplicate={() => onDuplicate(workflow)}
onEdit={() => onEdit(workflow)}
onToggleEnabled={() => onToggleEnabled(workflow)}
onTrigger={() => onTrigger(workflow.id)}
onTrigger={() => {
setTriggerAnimationSequence((sequence) => sequence + 1);
onTrigger(workflow.id);
}}
showEnabledToggle={false}
/>
</div>
</div>
{triggerSummary ? (
<p className="mt-4 line-clamp-1 text-xs font-semibold text-muted-foreground">
{triggerSummary}
</p>
) : null}
<h3 className="mt-1 line-clamp-4 text-xl font-bold leading-tight tracking-tight">
{workflow.name}
<h3
className="mt-4 line-clamp-4 text-xl font-bold leading-tight tracking-tight"
data-testid="workflow-card-semantic-label"
>
{cardLabel}
</h3>
{description ? (
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
<div className="mt-auto flex min-w-0 items-end justify-between gap-3 pt-5 text-muted-foreground">
<p className="min-w-0 truncate text-2xs">
{channelName ? `#${channelName}` : "Channel workflow"}
</p>
<div className="min-w-0">
{channelName ? (
<p
className="truncate text-xs font-semibold text-foreground"
data-testid="workflow-card-channel"
>
#{channelName}
</p>
) : null}
<p
className={cn(
"truncate text-2xs text-muted-foreground",
channelName && "mt-0.5",
)}
data-testid="workflow-card-name"
>
{workflow.name}
</p>
</div>
<span className="shrink-0 text-2xs">
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>

View File

@@ -41,6 +41,7 @@ import {
type WorkflowFormBuilderHandle,
} from "./WorkflowFormBuilder";
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
import { getWorkflowActivationWarning } from "./workflowActivationWarning";
import { getWorkflowEnabled } from "./workflowDefinition";
import type { WorkflowEditorPane } from "./workflowEditorPane";
import {
@@ -49,6 +50,7 @@ import {
yamlToFormState,
} from "./workflowFormTypes";
import {
readWorkflowDocumentFields,
readWorkflowHeaderState,
yamlWithWorkflowEnabled,
yamlWithWorkflowName,
@@ -117,7 +119,7 @@ function WorkflowNameEditor({
generating: boolean;
name: string;
onCommit: (name: string) => boolean;
onEditingChange: (editing: boolean) => void;
onEditingChange?: (editing: boolean) => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(name);
@@ -134,7 +136,7 @@ function WorkflowNameEditor({
const changeEditing = React.useCallback(
(nextEditing: boolean) => {
setEditing(nextEditing);
onEditingChange(nextEditing);
onEditingChange?.(nextEditing);
},
[onEditingChange],
);
@@ -247,13 +249,10 @@ export function WorkflowDialog({
const [editorParseError, setEditorParseError] = React.useState<string | null>(
null,
);
const [workflowNameEditing, setWorkflowNameEditing] = React.useState(false);
const [historyOpen, setHistoryOpen] = React.useState(false);
const [channelAutoOpenPending, setChannelAutoOpenPending] = React.useState(
mode === "create" && !channelId,
);
const [nameLeadingElement, setNameLeadingElement] =
React.useState<HTMLDivElement | null>(null);
const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{
relayHttpUrl: string | null;
relayUrlError: string | null;
@@ -262,6 +261,12 @@ export function WorkflowDialog({
} | null>(null);
const [discardConfirmationOpen, setDiscardConfirmationOpen] =
React.useState(false);
const [activationConfirmationOpen, setActivationConfirmationOpen] =
React.useState(false);
const [pendingCreateYaml, setPendingCreateYaml] = React.useState<
string | null
>(null);
const [formValid, setFormValid] = React.useState(true);
const [secretConfirmationOpen, setSecretConfirmationOpen] =
React.useState(false);
const [generatingName, setGeneratingName] = React.useState(false);
@@ -299,6 +304,9 @@ export function WorkflowDialog({
let active = true;
setSavedWebhookInfo(null);
setDiscardConfirmationOpen(false);
setActivationConfirmationOpen(false);
setPendingCreateYaml(null);
setFormValid(true);
resetCreate();
resetUpdate();
@@ -338,6 +346,8 @@ export function WorkflowDialog({
resetCreate();
resetUpdate();
setDiscardConfirmationOpen(false);
setActivationConfirmationOpen(false);
setPendingCreateYaml(null);
onOpenChange(false);
}, [onOpenChange, resetCreate, resetUpdate]);
@@ -402,14 +412,12 @@ export function WorkflowDialog({
[closeDialog, isDirty, onOpenChange, savedWebhookInfo],
);
async function handleSubmit() {
if (!selectedChannelId || !yamlDefinition.trim()) return;
async function saveWorkflow(yaml: string) {
try {
const saved = await mutation.mutateAsync(yamlDefinition);
const saved = await mutation.mutateAsync(yaml);
initialValuesRef.current = {
channelId: selectedChannelId,
yaml: yamlDefinition,
yaml,
};
if (saved.webhookSecret) {
allowNavigationRef.current = false;
@@ -441,6 +449,37 @@ export function WorkflowDialog({
}
}
function handleSubmit() {
if (!selectedChannelId || !yamlDefinition.trim() || !formValid) return;
const documentEnabled = readWorkflowDocumentFields(yamlDefinition).enabled;
const savedEnabled = workflowSnapshot
? getWorkflowEnabled(workflowSnapshot.definition)
: null;
const enablesWorkflow =
documentEnabled !== false && (mode !== "edit" || savedEnabled === false);
if (enablesWorkflow && getWorkflowActivationWarning(yamlDefinition)) {
const disabledYaml = yamlWithWorkflowEnabled(yamlDefinition, false);
if (disabledYaml === null) return;
setPendingCreateYaml(disabledYaml);
setActivationConfirmationOpen(true);
return;
}
void saveWorkflow(yamlDefinition);
}
function handleCreateActivation(enabled: boolean) {
if (pendingCreateYaml === null) return;
const yaml = enabled
? yamlWithWorkflowEnabled(pendingCreateYaml, true)
: pendingCreateYaml;
if (yaml === null) return;
setActivationConfirmationOpen(false);
setPendingCreateYaml(null);
void saveWorkflow(yaml);
}
const handleEditorModeChange = React.useCallback(
(nextMode: string) => {
if (nextMode === editorMode) return;
@@ -504,6 +543,10 @@ export function WorkflowDialog({
setYamlDefinition(nextYaml);
}, [mutation.reset, workflowEnabled]);
const showChannelSelector = mode !== "edit";
const activationWarning =
pendingCreateYaml === null
? null
: getWorkflowActivationWarning(pendingCreateYaml);
return (
<>
@@ -539,17 +582,6 @@ export function WorkflowDialog({
generating={generatingName}
name={workflowName}
onCommit={handleWorkflowNameCommit}
onEditingChange={setWorkflowNameEditing}
/>
<div
className={
mode !== "edit" &&
editorMode === "form" &&
!workflowNameEditing
? "flex items-center"
: "hidden"
}
ref={setNameLeadingElement}
/>
</div>
</div>
@@ -634,13 +666,14 @@ export function WorkflowDialog({
channels={channels}
disabled={mutation.isPending}
mode={editorMode}
nameLeadingContainer={mode === "edit" ? null : nameLeadingElement}
nameLeadingContainer={null}
onChange={(yaml) => {
mutation.reset();
yamlDefinitionRef.current = yaml;
setYamlDefinition(yaml);
}}
onSelectedNodeChange={onEditorPaneChange}
onValidityChange={setFormValid}
parseError={editorParseError}
ref={formBuilderRef}
scopeField={
@@ -744,6 +777,7 @@ export function WorkflowDialog({
disabled={
!selectedChannelId ||
!yamlDefinition.trim() ||
!formValid ||
mutation.isPending
}
onClick={handleSubmit}
@@ -759,6 +793,48 @@ export function WorkflowDialog({
</DialogContent>
</Dialog>
<AlertDialog
onOpenChange={(nextOpen) => {
setActivationConfirmationOpen(nextOpen);
if (!nextOpen) setPendingCreateYaml(null);
}}
open={activationConfirmationOpen}
>
<AlertDialogContent data-testid="workflow-activation-confirmation">
<AlertDialogHeader>
<AlertDialogTitle>
{activationWarning?.title ?? "Turn on this workflow?"}
</AlertDialogTitle>
<AlertDialogDescription>
{activationWarning?.description ??
"Turn it on to let it run immediately, or keep it off until you’re ready."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="ghost">
Back
</Button>
</AlertDialogCancel>
<Button
onClick={() => handleCreateActivation(false)}
type="button"
variant="outline"
>
Keep off
</Button>
<AlertDialogAction asChild>
<Button
onClick={() => handleCreateActivation(true)}
type="button"
>
Turn on
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
onOpenChange={(nextOpen) => {
setDiscardConfirmationOpen(nextOpen);

View File

@@ -1,11 +1,15 @@
import {
ArrowDown,
CalendarClock,
Check,
ChevronDown,
GitPullRequest,
MessageSquare,
Plus,
SmilePlus,
Trash2,
Webhook,
X,
Zap,
} from "lucide-react";
import { FocusScope } from "@radix-ui/react-focus-scope";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@@ -13,6 +17,7 @@ import * as React from "react";
import { createPortal } from "react-dom";
import type { Channel } from "@/shared/api/types";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import {
@@ -21,15 +26,20 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEditor";
import { reactionConditionValue } from "./workflowReactionCondition";
import { WorkflowTriggerConditions } from "./WorkflowTriggerConditions";
import { workflowStepDescription } from "./workflowStepDescription";
import { workflowTriggerDescription } from "./workflowTriggerDescription";
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
import { WorkflowStepCard } from "./WorkflowStepCard";
import {
parseConditionExpressions,
conditionValueError,
type ParsedConditionExpression,
} from "./workflowConditionExpression";
import type { WorkflowEditorPane } from "./workflowEditorPane";
import { FieldLabel } from "./workflowFormPrimitives";
import {
DEFAULT_FORM_STATE,
ACTION_LABELS,
@@ -52,62 +62,39 @@ import type {
} from "./workflowFormTypes";
function TriggerConfigFields({
conditionDrafts,
disabled,
trigger,
onConditionDraftsChange,
onUpdate,
}: {
conditionDrafts: ParsedConditionExpression[] | null;
disabled?: boolean;
trigger: TriggerConfig;
onConditionDraftsChange: (drafts: ParsedConditionExpression[] | null) => void;
onUpdate: (trigger: TriggerConfig) => void;
}) {
switch (trigger.on) {
case "message_posted":
return (
<WorkflowMessageTextCondition
disabled={disabled}
onChange={(filter) => onUpdate({ ...trigger, filter })}
value={trigger.filter ?? ""}
/>
);
case "diff_posted":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-filter">
Condition (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id="wf-trigger-filter"
onChange={(event) =>
onUpdate({ ...trigger, filter: event.target.value })
}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={trigger.filter ?? ""}
/>
<p className="text-xs text-muted-foreground">
Evalexpr. Empty matches all events.
</p>
</div>
);
case "reaction_added":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-emoji">
Emoji filter (optional)
</FieldLabel>
<WorkflowEmojiField
ariaLabel="Choose trigger emoji"
clearAriaLabel="Clear trigger emoji"
disabled={disabled}
id="wf-trigger-emoji"
onChange={(emoji) => onUpdate({ ...trigger, emoji })}
value={trigger.emoji ?? ""}
/>
<p className="text-xs text-muted-foreground">
Empty matches any reaction.
</p>
</div>
<WorkflowTriggerConditions
key={trigger.on}
conditionDrafts={conditionDrafts}
disabled={disabled}
onConditionDraftsChange={onConditionDraftsChange}
onChange={(filter) =>
onUpdate({
...trigger,
emoji:
trigger.on === "reaction_added" ? undefined : trigger.emoji,
filter,
})
}
triggerType={trigger.on}
value={reactionConditionValue(trigger)}
/>
);
case "webhook":
return (
@@ -135,6 +122,7 @@ type WorkflowFormBuilderProps = {
mode: WorkflowEditorMode;
onChange: (yaml: string) => void;
onSelectedNodeChange: (pane: WorkflowEditorPane) => void;
onValidityChange?: (valid: boolean) => void;
parseError: string | null;
scopeField?: React.ReactNode;
selectedNode: WorkflowEditorPane;
@@ -275,6 +263,7 @@ function WorkflowNode({
isNumbered && "text-sm font-semibold",
)}
data-selected={selected}
data-testid="workflow-node-icon"
>
{isNumbered ? number : icon}
</span>
@@ -362,12 +351,13 @@ export const WorkflowFormBuilder = React.forwardRef<
WorkflowFormBuilderProps
>(function WorkflowFormBuilder(
{
channels: _channels,
channels,
disabled,
nameLeadingContainer,
mode,
onChange,
onSelectedNodeChange,
onValidityChange,
parseError,
scopeField,
selectedNode: selectedRouteNode,
@@ -383,6 +373,18 @@ export const WorkflowFormBuilder = React.forwardRef<
? initialParseRef.current.state
: DEFAULT_FORM_STATE,
);
const [triggerConditionDrafts, setTriggerConditionDrafts] = React.useState<
ParsedConditionExpression[] | null
>(null);
const conditionDraftsValid =
triggerConditionDrafts === null ||
triggerConditionDrafts.every(
(condition) => !conditionValueError(condition.field, condition.value),
);
React.useEffect(() => {
onValidityChange?.(mode !== "form" || conditionDraftsValid);
}, [conditionDraftsValid, mode, onValidityChange]);
const selectedNode =
selectedRouteNode?.type === "trigger" ||
(selectedRouteNode?.type === "step" &&
@@ -424,6 +426,7 @@ export const WorkflowFormBuilder = React.forwardRef<
previousModeRef.current = mode;
if (mode === "yaml") {
setTriggerConditionDrafts(null);
onSelectedNodeChange(null);
return;
}
@@ -595,6 +598,33 @@ export const WorkflowFormBuilder = React.forwardRef<
const selectedStepIndex = selectedStep
? formState.steps.findIndex((step) => step.id === selectedStep.id)
: -1;
const triggerEmoji = React.useMemo(() => {
if (formState.trigger.on !== "reaction_added") return undefined;
const legacyEmoji = formState.trigger.emoji?.trim();
if (legacyEmoji) return legacyEmoji;
if (!formState.trigger.filter) return undefined;
const conditions = parseConditionExpressions(
formState.trigger.filter,
"reaction_added",
);
return conditions
?.find(
({ field, operator }) =>
field === "trigger_emoji" && operator === "equals",
)
?.value.trim();
}, [formState.trigger]);
const triggerDescription = workflowTriggerDescription(formState.trigger);
const visibleTriggerDescription = triggerEmoji
? "Reaction added"
: triggerDescription;
const TriggerIcon = {
diff_posted: GitPullRequest,
message_posted: MessageSquare,
reaction_added: SmilePlus,
schedule: CalendarClock,
webhook: Webhook,
}[formState.trigger.on];
return (
<>
@@ -637,10 +667,19 @@ export const WorkflowFormBuilder = React.forwardRef<
{scopeField ? <div className="mb-3">{scopeField}</div> : null}
<ol aria-label="Workflow sequence">
<WorkflowNode
description={TRIGGER_LABELS[formState.trigger.on]}
description={visibleTriggerDescription}
disabled={disabled}
icon={<Zap className="h-4 w-4" />}
label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`}
icon={
triggerEmoji ? (
<StatusEmoji
className="h-6 w-6 text-xl"
value={triggerEmoji}
/>
) : (
<TriggerIcon className="h-4 w-4" />
)
}
label={`Trigger: ${triggerDescription}`}
onAddAfter={(action) => insertStep(0, action)}
onClick={() => selectNode({ type: "trigger" })}
selected={selectedNode?.type === "trigger"}
@@ -649,16 +688,39 @@ export const WorkflowFormBuilder = React.forwardRef<
/>
{formState.steps.map((step, index) => {
const stepName = step.name?.trim();
const actionLabel = ACTION_LABELS[step.action];
const nodeTitle = stepName || actionLabel;
const channelLabel = step.channel
? channels.find(
(channel) => channel.id === step.channel,
)?.name
: undefined;
const nodeDescription = workflowStepDescription(step, {
channelLabel,
});
const stepEmoji =
step.action === "add_reaction"
? step.emoji?.trim()
: undefined;
const visibleNodeDescription = stepEmoji
? actionLabel
: nodeDescription;
const showActionSubtitle =
!stepEmoji && nodeDescription !== actionLabel;
return (
<WorkflowNode
description={nodeTitle}
description={visibleNodeDescription}
disabled={disabled}
icon={
stepEmoji ? (
<StatusEmoji
className="h-6 w-6 text-xl"
value={stepEmoji}
/>
) : undefined
}
key={step.id}
label={`Step ${index + 1}: ${nodeTitle}`}
number={index + 1}
label={`Step ${index + 1}: ${nodeDescription}`}
number={stepEmoji ? undefined : index + 1}
onAddAfter={(action) => insertStep(index + 1, action)}
onClick={() =>
selectNode({ type: "step", stepId: step.id })
@@ -669,7 +731,9 @@ export const WorkflowFormBuilder = React.forwardRef<
selectedNode.stepId === step.id
}
showTitle={false}
subtitle={stepName ? actionLabel : undefined}
subtitle={
showActionSubtitle ? actionLabel : undefined
}
terminal={index === formState.steps.length - 1}
title={`Step ${index + 1}`}
/>
@@ -746,6 +810,7 @@ export const WorkflowFormBuilder = React.forwardRef<
disabled={disabled}
labels={TRIGGER_LABELS}
onChange={(triggerType) => {
setTriggerConditionDrafts(null);
const next = withTriggerType(
formState,
triggerType,
@@ -842,7 +907,11 @@ export const WorkflowFormBuilder = React.forwardRef<
{selectedNode.type === "trigger" ? (
<div>
<TriggerConfigFields
conditionDrafts={triggerConditionDrafts}
disabled={disabled}
onConditionDraftsChange={
setTriggerConditionDrafts
}
onUpdate={(trigger) =>
updateFormState({ ...formState, trigger })
}
@@ -858,6 +927,10 @@ export const WorkflowFormBuilder = React.forwardRef<
onUpdate={(updated) =>
updateStep(selectedStepIndex, updated)
}
previousSteps={formState.steps.slice(
0,
selectedStepIndex,
)}
showHeader={false}
step={selectedStep}
triggerType={formState.trigger.on}

View File

@@ -6,6 +6,7 @@ import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea";
import { WorkflowDurationField } from "./WorkflowDurationField";
import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEditor";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
@@ -109,6 +110,7 @@ function StepConfigFields({
step,
prefix,
disabled,
previousSteps,
triggerType,
workflowChannelId,
onUpdate,
@@ -116,6 +118,7 @@ function StepConfigFields({
step: StepFormState;
prefix: string;
disabled?: boolean;
previousSteps: StepFormState[];
triggerType: TriggerType;
workflowChannelId?: string | null;
onUpdate: (step: StepFormState) => void;
@@ -142,15 +145,15 @@ function StepConfigFields({
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
<WorkflowTemplateTextarea
autoCapitalize="off"
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
}
onValueChange={(text) => onUpdate({ ...step, text })}
placeholder="e.g. Deployment started by {{trigger.author}}"
previousSteps={previousSteps}
triggerType={triggerType}
value={step.text ?? ""}
/>
</div>
@@ -390,6 +393,7 @@ export function WorkflowStepCard({
onRemove,
onUpdate,
step,
previousSteps = [],
triggerType,
workflowChannelId,
}: {
@@ -400,6 +404,7 @@ export function WorkflowStepCard({
onRemove: () => void;
onUpdate: (step: StepFormState) => void;
step: StepFormState;
previousSteps?: StepFormState[];
triggerType: TriggerType;
workflowChannelId?: string | null;
}) {
@@ -443,6 +448,7 @@ export function WorkflowStepCard({
disabled={disabled}
onUpdate={onUpdate}
prefix={prefix}
previousSteps={previousSteps}
step={step}
triggerType={triggerType}
workflowChannelId={workflowChannelId}

View File

@@ -0,0 +1,188 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { Textarea } from "@/shared/ui/textarea";
import type { StepFormState, TriggerType } from "./workflowFormTypes";
import {
activeTemplateToken,
insertTemplateVariable,
workflowTemplateVariables,
} from "./workflowTemplateVariables";
import type {
ActiveTemplateToken,
WorkflowTemplateVariable,
} from "./workflowTemplateVariables";
export function WorkflowTemplateTextarea({
className,
disabled,
onValueChange,
previousSteps,
triggerType,
value,
...props
}: Omit<React.ComponentProps<typeof Textarea>, "onChange" | "value"> & {
onValueChange: (value: string) => void;
previousSteps: StepFormState[];
triggerType: TriggerType;
value: string;
}) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const generatedId = React.useId();
const textareaId = props.id ?? `workflow-template-${generatedId}`;
const listboxId = `${textareaId}-variables`;
const [token, setToken] = React.useState<ActiveTemplateToken | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const variables = React.useMemo(
() => workflowTemplateVariables(triggerType, previousSteps),
[previousSteps, triggerType],
);
const suggestions = React.useMemo(() => {
if (!token) return [];
const query = token.query.toLowerCase();
return variables.filter(
(variable) =>
variable.value.toLowerCase().includes(query) ||
variable.description.toLowerCase().includes(query),
);
}, [token, variables]);
const open = token !== null;
const selectedOptionId = suggestions[selectedIndex]
? `${listboxId}-option-${selectedIndex}`
: undefined;
const updateToken = React.useCallback((nextValue: string, caret: number) => {
setToken(activeTemplateToken(nextValue, caret));
setSelectedIndex(0);
}, []);
const selectVariable = React.useCallback(
(variable: WorkflowTemplateVariable) => {
if (!token) return;
const next = insertTemplateVariable(value, token, variable.value);
onValueChange(next.value);
setToken(null);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(next.caret, next.caret);
});
},
[onValueChange, token, value],
);
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) setToken(null);
}}
>
<PopoverAnchor asChild>
<Textarea
{...props}
aria-activedescendant={open ? selectedOptionId : undefined}
aria-autocomplete="list"
aria-controls={open ? listboxId : undefined}
aria-expanded={open}
aria-haspopup="listbox"
className={className}
disabled={disabled}
id={textareaId}
onChange={(event) => {
const nextValue = event.target.value;
onValueChange(nextValue);
updateToken(nextValue, event.target.selectionStart);
}}
onClick={(event) =>
updateToken(
event.currentTarget.value,
event.currentTarget.selectionStart,
)
}
onKeyDown={(event) => {
if (!open) return;
if (event.key === "Escape") {
event.preventDefault();
setToken(null);
return;
}
if (suggestions.length === 0) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const direction = event.key === "ArrowDown" ? 1 : -1;
setSelectedIndex(
(current) =>
(current + direction + suggestions.length) %
suggestions.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
selectVariable(suggestions[selectedIndex] ?? suggestions[0]);
}
}}
ref={textareaRef}
value={value}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-72 p-1.5"
onOpenAutoFocus={(event) => event.preventDefault()}
side="bottom"
sideOffset={6}
>
<div className="max-h-72 overflow-y-auto" id={listboxId} role="listbox">
{suggestions.length > 0 ? (
suggestions.map((variable, index) => {
const previousGroup = suggestions[index - 1]?.group;
return (
<React.Fragment key={variable.value}>
{variable.group !== previousGroup ? (
<p className="px-2 pb-1 pt-1.5 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
{variable.group}
</p>
) : null}
<button
aria-selected={index === selectedIndex}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left",
index === selectedIndex
? "bg-muted text-foreground"
: "text-foreground hover:bg-muted/60",
)}
id={`${listboxId}-option-${index}`}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectVariable(variable)}
role="option"
type="button"
>
<code className="min-w-0 truncate text-xs">
{`{{${variable.value}}}`}
</code>
<span className="shrink-0 text-2xs text-muted-foreground">
{variable.description}
</span>
</button>
</React.Fragment>
);
})
) : (
<p className="px-2 py-2 text-xs text-muted-foreground">
No matching variables
</p>
)}
</div>
{triggerType === "webhook" ? (
<p className="border-t border-border/50 px-2 pb-1 pt-2 text-2xs text-muted-foreground">
JSON fields are available as {"{{trigger.field_name}}"}.
</p>
) : null}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,327 @@
import { ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { FieldLabel } from "./workflowFormPrimitives";
import {
buildConditionExpressions,
conditionFieldsForTrigger,
conditionOperatorNeedsValue,
conditionOperatorsForField,
conditionValueError,
parseConditionExpressions,
type ConditionOperator,
type ParsedConditionExpression,
} from "./workflowConditionExpression";
import type { TriggerType } from "./workflowFormTypes";
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
equals: "is",
not_equals: "is not",
is_not_empty: "is not empty",
is_empty: "is empty",
};
function emptyCondition(field: string): ParsedConditionExpression {
return {
field,
operator: conditionOperatorsForField(field)[0],
value: "",
webhookField: "",
};
}
function compact(value: string): string {
const trimmed = value.trim();
return trimmed.length <= 20
? trimmed
: `${trimmed.slice(0, 11)}…${trimmed.slice(-6)}`;
}
function fieldPlaceholder(field: string): string {
if (field === "trigger_author") return "64-character hex pubkey";
if (field === "trigger_message_id") return "64-character hex event ID";
return "e.g. deploy";
}
export function WorkflowTriggerConditions({
conditionDrafts,
disabled,
onChange,
onConditionDraftsChange,
triggerType,
value,
}: {
conditionDrafts: ParsedConditionExpression[] | null;
disabled?: boolean;
onChange: (value: string) => void;
onConditionDraftsChange: (drafts: ParsedConditionExpression[] | null) => void;
triggerType: Extract<
TriggerType,
"message_posted" | "diff_posted" | "reaction_added"
>;
value: string;
}) {
const parsedValue = React.useMemo(
() => parseConditionExpressions(value, triggerType),
[triggerType, value],
);
const [mode, setMode] = React.useState<"basic" | "advanced">(() =>
value && parsedValue === null ? "advanced" : "basic",
);
const [expandedField, setExpandedField] = React.useState<string | null>(
() => conditionFieldsForTrigger(triggerType)[0]?.value ?? null,
);
const conditions = conditionDrafts ?? parsedValue ?? [];
const localValue = React.useRef(value);
React.useEffect(() => {
if (value === localValue.current) return;
localValue.current = value;
const parsed = parseConditionExpressions(value, triggerType);
if (parsed) {
onConditionDraftsChange(null);
setMode("basic");
} else if (value) {
setMode("advanced");
}
}, [onConditionDraftsChange, triggerType, value]);
const updateConditions = (next: ParsedConditionExpression[]) => {
onConditionDraftsChange(next);
if (
next.some((condition) =>
conditionValueError(condition.field, condition.value),
)
) {
return;
}
const expression = buildConditionExpressions(next);
localValue.current = expression;
onChange(expression);
};
const fields = conditionFieldsForTrigger(triggerType);
return (
<Tabs
className="space-y-3"
onValueChange={(next) => setMode(next as "basic" | "advanced")}
value={mode}
>
<TabsList
aria-label="Condition editor mode"
className="grid h-9 w-full grid-cols-2 p-0.5"
>
<TabsTrigger className="h-8" disabled={disabled} value="basic">
Basic
</TabsTrigger>
<TabsTrigger className="h-8" disabled={disabled} value="advanced">
Advanced
</TabsTrigger>
</TabsList>
{mode === "advanced" ? (
<div className="space-y-2">
<Input
aria-label="Advanced expression"
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
onChange={(event) => {
onConditionDraftsChange(null);
localValue.current = event.target.value;
onChange(event.target.value.trim());
}}
placeholder='e.g. trigger_author == "…"'
value={value}
/>
<p className="text-xs text-muted-foreground">
Use an evalexpr expression. Existing custom expressions stay
unchanged.
</p>
</div>
) : parsedValue === null && value.trim() ? (
<div className="space-y-3 rounded-md border border-border/70 p-3">
<p className="text-xs text-muted-foreground">
An advanced expression is active. Replacing it with basic filters
cannot be undone.
</p>
<button
className="text-sm font-medium text-destructive hover:underline"
disabled={disabled}
onClick={() => {
onConditionDraftsChange(null);
localValue.current = "";
onChange("");
}}
type="button"
>
Replace with basic filters
</button>
</div>
) : (
<div className="divide-y divide-border/50">
{fields.map((field) => {
const existing = conditions.find(
(condition) => condition.field === field.value,
);
const condition = existing ?? emptyCondition(field.value);
const expanded = expandedField === field.value;
const error = conditionValueError(field.value, condition.value);
const summary = existing
? `${OPERATOR_LABELS[condition.operator]}${condition.value ? ` ${compact(condition.value)}` : ""}`
: "Any";
return (
<div className={cn(expanded && "pb-4")} key={field.value}>
<button
aria-expanded={expanded}
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:opacity-50"
disabled={disabled}
onClick={() =>
setExpandedField(expanded ? null : field.value)
}
type="button"
>
<span className="min-w-0 flex-1 truncate text-base font-medium">
{field.label}
</span>
<span className="max-w-44 truncate font-mono text-xs text-muted-foreground">
{summary}
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
</button>
{expanded ? (
<div className="animate-in space-y-3 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
<fieldset>
<legend className="sr-only">Match</legend>
<div className="grid grid-cols-2 gap-2.5">
{conditionOperatorsForField(field.value).map(
(operator) => {
const selected = condition.operator === operator;
return (
<button
aria-pressed={selected}
className={cn(
"flex min-h-12 items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color] focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
selected
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
)}
disabled={disabled}
key={operator}
onClick={() => {
const next = { ...condition, operator };
updateConditions([
...conditions.filter(
(item) => item.field !== field.value,
),
next,
]);
}}
type="button"
>
{OPERATOR_LABELS[operator]}
</button>
);
},
)}
</div>
</fieldset>
{conditionOperatorNeedsValue(condition.operator) ? (
field.value === "trigger_emoji" ? (
<WorkflowEmojiField
ariaLabel="Choose trigger emoji"
clearAriaLabel="Clear trigger emoji"
disabled={disabled}
id="wf-trigger-emoji"
onChange={(emoji) =>
updateConditions([
...conditions.filter(
(item) => item.field !== field.value,
),
{ ...condition, value: emoji ?? "" },
])
}
value={condition.value}
/>
) : (
<div className="space-y-1.5">
<FieldLabel
htmlFor={`wf-trigger-${field.value}-value`}
>
{field.label}
</FieldLabel>
<Input
aria-describedby={
error
? `wf-trigger-${field.value}-error`
: undefined
}
aria-invalid={error ? true : undefined}
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`wf-trigger-${field.value}-value`}
onChange={(event) =>
updateConditions([
...conditions.filter(
(item) => item.field !== field.value,
),
{ ...condition, value: event.target.value },
])
}
placeholder={fieldPlaceholder(field.value)}
spellCheck={field.value === "trigger_text"}
value={condition.value}
/>
{error ? (
<p
className="text-xs text-destructive"
id={`wf-trigger-${field.value}-error`}
>
{error}
</p>
) : null}
</div>
)
) : null}
{existing ? (
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
disabled={disabled}
onClick={() =>
updateConditions(
conditions.filter(
(item) => item.field !== field.value,
),
)
}
type="button"
>
Clear filter
</button>
) : null}
</div>
) : null}
</div>
);
})}
</div>
)}
</Tabs>
);
}

View File

@@ -9,6 +9,7 @@ import {
workflowListFocusRefetchPolicy,
workflowQueryKey,
} from "@/features/workflows/hooks";
import { getWorkflowActivationWarning } from "@/features/workflows/ui/workflowActivationWarning";
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
import { WorkflowEditorHost } from "@/features/workflows/ui/WorkflowEditorHost";
@@ -25,6 +26,16 @@ import {
triggerWorkflow,
updateWorkflow,
} from "@/shared/api/tauriWorkflows";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { PageHeader } from "@/shared/ui/PageHeader";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -99,6 +110,8 @@ export function WorkflowsView({
onEditorPaneChange,
}: WorkflowsViewProps) {
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const [activationTarget, setActivationTarget] =
React.useState<Workflow | null>(null);
const queryClient = useQueryClient();
const editorWorkflowId =
@@ -175,6 +188,7 @@ export function WorkflowsView({
});
},
onSuccess: (_data, workflow) => {
setActivationTarget(null);
void queryClient.invalidateQueries({
queryKey: workflowQueryKey(workflow.id),
});
@@ -230,9 +244,21 @@ export function WorkflowsView({
const toggleEnabled = toggleEnabledMutation.mutate;
const handleToggleEnabled = React.useCallback(
(workflow: Workflow) => toggleEnabled(workflow),
(workflow: Workflow) => {
if (
!getWorkflowEnabled(workflow.definition) &&
getWorkflowActivationWarning(yamlStringify(workflow.definition))
) {
setActivationTarget(workflow);
return;
}
toggleEnabled(workflow);
},
[toggleEnabled],
);
const activationWarning = activationTarget
? getWorkflowActivationWarning(yamlStringify(activationTarget.definition))
: null;
const editorWorkflowHint = allWorkflows.find(
({ workflow }) => workflow.id === editorWorkflowId,
@@ -316,6 +342,40 @@ export function WorkflowsView({
workflowHint={editorWorkflowHint}
/>
<AlertDialog
onOpenChange={(open) => {
if (!open && !toggleEnabledMutation.isPending) {
setActivationTarget(null);
}
}}
open={activationTarget !== null}
>
<AlertDialogContent data-testid="workflow-activation-confirmation">
<AlertDialogHeader>
<AlertDialogTitle>
{activationWarning?.title ?? "Turn on this workflow?"}
</AlertDialogTitle>
<AlertDialogDescription>
{activationWarning?.description ??
"Turn it on to let it run immediately, or keep it off until you’re ready."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep off</AlertDialogCancel>
<AlertDialogAction
disabled={toggleEnabledMutation.isPending}
onClick={(event) => {
if (!activationTarget) return;
event.preventDefault();
toggleEnabled(activationTarget);
}}
>
Turn on
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<WorkflowDeleteDialog
error={
deleteMutation.error instanceof Error

View File

@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getWorkflowActivationWarning } from "./workflowActivationWarning.ts";
function workflowYaml(trigger) {
return `name: Test\ntrigger:\n${trigger}\nsteps:\n - id: notify\n action: send_message\n text: Hi\n`;
}
test("warns when a message trigger matches every message", () => {
assert.deepEqual(
getWorkflowActivationWarning(workflowYaml(" on: message_posted")),
{
description:
"It will run for every new message in this channel. Review the trigger before turning it on.",
title: "This workflow may run often",
},
);
});
test("does not warn when a message trigger is narrowed", () => {
assert.equal(
getWorkflowActivationWarning(
workflowYaml(' on: message_posted\n filter: text == "deploy"'),
),
null,
);
});
test("warns for hourly-or-faster interval schedules with concrete copy", () => {
assert.equal(
getWorkflowActivationWarning(
workflowYaml(" on: schedule\n interval: 15m"),
)?.description,
"It is scheduled to run every 15 minutes. Review the schedule before turning it on.",
);
assert.equal(
getWorkflowActivationWarning(
workflowYaml(" on: schedule\n interval: 2h"),
),
null,
);
});
test("warns for clear hourly-or-faster cron schedules", () => {
for (const cron of ["*/5 * * * *", "0 */5 * * * *", "0 */5 * * * * *"]) {
assert.equal(
getWorkflowActivationWarning(
workflowYaml(` on: schedule\n cron: "${cron}"`),
)?.description,
"It is scheduled to run every 5 minutes. Review the schedule before turning it on.",
);
}
assert.equal(
getWorkflowActivationWarning(
workflowYaml(' on: schedule\n cron: "*/10 * * * * *"'),
)?.description,
"It is scheduled to run multiple times a minute. Review the schedule before turning it on.",
);
assert.equal(
getWorkflowActivationWarning(
workflowYaml(' on: schedule\n cron: "0 9 * * *"'),
),
null,
);
});
test("warns for stepped, ranged, and listed hourly cron schedules", () => {
for (const cron of [
"0 0 */1 * * *",
"0 0 * * * *",
"0 0 0-23 * * *",
"0 0 0-11,12-23 * * *",
"0 0 */1 * * * *",
]) {
assert.equal(
getWorkflowActivationWarning(
workflowYaml(` on: schedule\n cron: "${cron}"`),
)?.description,
"It is scheduled to run every hour. Review the schedule before turning it on.",
cron,
);
}
assert.equal(
getWorkflowActivationWarning(
workflowYaml(' on: schedule\n cron: "*/5 0-23 * * *"'),
)?.description,
"It is scheduled to run every 5 minutes. Review the schedule before turning it on.",
);
for (const cron of ["0 */3 * * *", "0 0 0,12 * * *", "0 0 8-17 * * *"]) {
assert.equal(
getWorkflowActivationWarning(
workflowYaml(` on: schedule\n cron: "${cron}"`),
),
null,
cron,
);
}
});
test("returns no warning for malformed or unrelated definitions", () => {
assert.equal(getWorkflowActivationWarning("not: [valid"), null);
assert.equal(
getWorkflowActivationWarning(workflowYaml(" on: reaction_added")),
null,
);
});

View File

@@ -0,0 +1,160 @@
import { parse as yamlParse } from "yaml";
import {
formatDurationSecondsVerbose,
parseDurationSeconds,
} from "./workflowDuration";
type WorkflowActivationWarning = {
description: string;
title: string;
};
const FREQUENT_SCHEDULE_THRESHOLD_SECONDS = 60 * 60;
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function frequentIntervalDescription(interval: string): string | null {
const seconds = parseDurationSeconds(interval);
if (
seconds === null ||
seconds <= 0 ||
seconds > FREQUENT_SCHEDULE_THRESHOLD_SECONDS
) {
return null;
}
return `It is scheduled to run every ${formatDurationSecondsVerbose(seconds)}. Review the schedule before turning it on.`;
}
function normalizedCronFields(cron: string): string[] | null {
const fields = cron.trim().split(/\s+/);
if (fields.length === 5) return ["0", ...fields, "*"];
if (fields.length === 6) return [...fields, "*"];
return fields.length === 7 ? fields : null;
}
function repeatedFieldCount(field: string, maximum: number): number | null {
if (field === "*") return maximum + 1;
const step = /^\*\/(\d+)$/.exec(field);
if (step) {
const size = Number(step[1]);
return size >= 1 && size <= maximum + 1
? Math.ceil((maximum + 1) / size)
: null;
}
if (field.includes(",") || field.includes("-")) return 2;
return /^\d+$/.test(field) ? 1 : null;
}
/**
* Reports whether an hour field selects every hour of the day, so schedules
* written as `*`, `*\/1`, `0-23`, or an equivalent list still classify as
* hourly. Unrecognized fields are treated as not matching every hour.
*/
function matchesEveryHour(field: string): boolean {
const HOURS = 24;
const selected = new Set<number>();
for (const segment of field.split(",")) {
const [base, step] = segment.split("/");
if (step !== undefined && (!/^\d+$/.test(step) || Number(step) < 1)) {
return false;
}
const size = step === undefined ? 1 : Number(step);
let start: number, end: number;
if (base === "*") {
start = 0;
end = HOURS - 1;
} else {
const bounds = base.split("-");
if (bounds.length > 2 || bounds.some((part) => !/^\d+$/.test(part))) {
return false;
}
start = Number(bounds[0]);
end = bounds.length === 2 ? Number(bounds[1]) : start;
if (start > end || end >= HOURS) return false;
// A bare hour selects only itself; a stepped one runs to end of day.
if (bounds.length === 1) {
if (step === undefined) {
selected.add(start);
continue;
}
end = HOURS - 1;
}
}
for (let hour = start; hour <= end; hour += size) selected.add(hour);
}
return selected.size === HOURS;
}
function frequentCronDescription(cron: string): string | null {
const fields = normalizedCronFields(cron);
if (!fields) return null;
const [second, minute, hour] = fields;
const secondRuns = repeatedFieldCount(second, 59);
const minuteRuns = repeatedFieldCount(minute, 59);
if (secondRuns === null || minuteRuns === null) return null;
if (!matchesEveryHour(hour)) return null;
if (secondRuns > 1) {
return "It is scheduled to run multiple times a minute. Review the schedule before turning it on.";
}
if (minute === "*") {
return "It is scheduled to run every minute. Review the schedule before turning it on.";
}
const steppedMinute = /^\*\/(\d+)$/.exec(minute);
if (steppedMinute) {
const minutes = Number(steppedMinute[1]);
return `It is scheduled to run every ${minutes} minute${minutes === 1 ? "" : "s"}. Review the schedule before turning it on.`;
}
if (minuteRuns === 1) {
return "It is scheduled to run every hour. Review the schedule before turning it on.";
}
if (minuteRuns > 1) {
return "It is scheduled to run multiple times an hour. Review the schedule before turning it on.";
}
return null;
}
export function getWorkflowActivationWarning(
yaml: string,
): WorkflowActivationWarning | null {
let definition: Record<string, unknown> | null;
try {
definition = asRecord(yamlParse(yaml));
} catch {
return null;
}
const trigger = asRecord(definition?.trigger);
const triggerType = nonEmptyString(trigger?.on);
if (triggerType === "message_posted" && !nonEmptyString(trigger?.filter)) {
return {
description:
"It will run for every new message in this channel. Review the trigger before turning it on.",
title: "This workflow may run often",
};
}
if (triggerType === "schedule") {
const interval = nonEmptyString(trigger?.interval);
const cron = nonEmptyString(trigger?.cron);
const description = interval
? frequentIntervalDescription(interval)
: cron
? frequentCronDescription(cron)
: null;
if (description) {
return { description, title: "This workflow may run often" };
}
}
return null;
}

View File

@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildConditionExpression,
buildConditionExpressions,
conditionFieldsForTrigger,
conditionOperatorsForField,
conditionValueError,
parseConditionExpression,
parseConditionExpressions,
} from "./workflowConditionExpression.ts";
const AUTHOR = "a".repeat(64);
const MESSAGE_ID = "b".repeat(64);
test("builds and escapes supported text conditions", () => {
assert.equal(
buildConditionExpression({
field: "trigger_text",
operator: "contains",
value: 'deploy "buzz"\\path',
webhookField: "",
}),
'str_contains(trigger_text, "deploy \\"buzz\\"\\\\path")',
);
});
test("limits opaque identifiers to equality and validates 64-char hex", () => {
for (const field of [
"trigger_author",
"trigger_emoji",
"trigger_message_id",
"future_id",
]) {
assert.deepEqual(conditionOperatorsForField(field), [
"equals",
"not_equals",
]);
}
assert.equal(
conditionValueError("trigger_author", "abc"),
"Enter a 64-character hex pubkey.",
);
assert.equal(conditionValueError("trigger_author", AUTHOR), null);
assert.equal(
conditionValueError("trigger_message_id", "z".repeat(64)),
"Enter a 64-character hex event ID.",
);
assert.equal(conditionValueError("trigger_message_id", MESSAGE_ID), null);
});
test("normalizes generated hex identifiers to lowercase", () => {
const uppercaseAuthor = "A".repeat(64);
const uppercaseMessageId = "B".repeat(64);
assert.equal(
buildConditionExpressions([
{
field: "trigger_author",
operator: "equals",
value: uppercaseAuthor,
webhookField: "",
},
{
field: "trigger_message_id",
operator: "not_equals",
value: uppercaseMessageId,
webhookField: "",
},
]),
`trigger_author == "${AUTHOR}" && trigger_message_id != "${MESSAGE_ID}"`,
);
});
test("exposes only trigger-relevant local fields", () => {
assert.deepEqual(
conditionFieldsForTrigger("reaction_added").map(({ value }) => value),
["trigger_emoji", "trigger_author", "trigger_message_id"],
);
assert.deepEqual(
conditionFieldsForTrigger("message_posted").map(({ value }) => value),
["trigger_text", "trigger_author"],
);
for (const trigger of [
"message_posted",
"diff_posted",
"reaction_added",
"webhook",
"schedule",
]) {
assert.equal(
conditionFieldsForTrigger(trigger).some(
({ value }) => value === "trigger_channel_id",
),
false,
);
}
});
test("round-trips reaction emoji, author, and message ID conjunctions", () => {
const conditions = [
{
field: "trigger_emoji",
operator: "equals",
value: "👾",
webhookField: "",
},
{
field: "trigger_author",
operator: "not_equals",
value: AUTHOR,
webhookField: "",
},
{
field: "trigger_message_id",
operator: "equals",
value: MESSAGE_ID,
webhookField: "",
},
];
const expression = buildConditionExpressions(conditions);
assert.equal(
expression,
`trigger_emoji == "👾" && trigger_author != "${AUTHOR}" && trigger_message_id == "${MESSAGE_ID}"`,
);
assert.deepEqual(
parseConditionExpressions(expression, "reaction_added"),
conditions,
);
});
test("does not split conjunctions inside values and rejects duplicates", () => {
const expression = `str_contains(trigger_text, "one && two") && trigger_author == "${AUTHOR}"`;
assert.equal(
parseConditionExpressions(expression, "message_posted")?.length,
2,
);
assert.equal(
parseConditionExpressions(
`trigger_author == "${AUTHOR}" && trigger_author != "${MESSAGE_ID}"`,
"message_posted",
),
null,
);
});
test("keeps unsupported expressions in advanced mode", () => {
assert.equal(
parseConditionExpression("trigger_timestamp > 0", "message_posted"),
null,
);
assert.equal(
parseConditionExpression('trigger_emoji == "👍"', "message_posted"),
null,
);
assert.equal(
parseConditionExpression('trigger_author == "abc"', "message_posted"),
null,
);
assert.equal(
parseConditionExpression("str_len(trigger_author) == 0", "message_posted"),
null,
);
assert.equal(
parseConditionExpression(
"str_len(trigger_message_id) > 0",
"reaction_added",
),
null,
);
});

View File

@@ -0,0 +1,276 @@
import type { TriggerType } from "./workflowFormTypes";
export const CONDITION_OPERATORS = [
"contains",
"not_contains",
"starts_with",
"ends_with",
"equals",
"not_equals",
"is_not_empty",
"is_empty",
] as const;
export type ConditionOperator = (typeof CONDITION_OPERATORS)[number];
const EXACT_MATCH_OPERATORS = [
"equals",
"not_equals",
] as const satisfies readonly ConditionOperator[];
const HEX_ID_PATTERN = /^[0-9a-fA-F]{64}$/;
export type ConditionField = { label: string; value: string };
export type ParsedConditionExpression = {
field: string;
operator: ConditionOperator;
value: string;
webhookField: string;
};
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" },
AUTHOR_FIELD,
],
diff_posted: [{ label: "Diff text", value: "trigger_text" }, AUTHOR_FIELD],
reaction_added: [
{ label: "Reaction emoji", value: "trigger_emoji" },
AUTHOR_FIELD,
{ label: "Message event ID", value: "trigger_message_id" },
],
webhook: [],
schedule: [],
};
export function conditionFieldsForTrigger(
triggerType: TriggerType,
): ConditionField[] {
return FIELDS_BY_TRIGGER[triggerType];
}
export function conditionOperatorsForField(
field: string,
): readonly ConditionOperator[] {
return field === "trigger_author" ||
field === "trigger_emoji" ||
field.endsWith("_id")
? EXACT_MATCH_OPERATORS
: CONDITION_OPERATORS;
}
export function defaultConditionOperatorForField(
field: string,
): ConditionOperator {
return conditionOperatorsForField(field)[0];
}
export function conditionOperatorNeedsValue(
operator: ConditionOperator,
): boolean {
return operator !== "is_not_empty" && operator !== "is_empty";
}
export function conditionValueError(
field: string,
value: string,
): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (field === "trigger_author" && !HEX_ID_PATTERN.test(trimmed)) {
return "Enter a 64-character hex pubkey.";
}
if (field.endsWith("_id") && !HEX_ID_PATTERN.test(trimmed)) {
return "Enter a 64-character hex event ID.";
}
return null;
}
function escapeEvalexprString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
export function buildConditionExpression(
condition: ParsedConditionExpression,
): string | null {
const { field, operator } = condition;
if (operator === "is_not_empty") return `str_len(${field}) > 0`;
if (operator === "is_empty") return `str_len(${field}) == 0`;
const value = condition.value.trim();
if (!value || conditionValueError(field, value)) return null;
const normalizedValue =
field === "trigger_author" || field.endsWith("_id")
? value.toLowerCase()
: value;
const quoted = `"${escapeEvalexprString(normalizedValue)}"`;
switch (operator) {
case "contains":
return `str_contains(${field}, ${quoted})`;
case "not_contains":
return `!str_contains(${field}, ${quoted})`;
case "starts_with":
return `str_starts_with(${field}, ${quoted})`;
case "ends_with":
return `str_ends_with(${field}, ${quoted})`;
case "equals":
return `${field} == ${quoted}`;
case "not_equals":
return `${field} != ${quoted}`;
default:
return null;
}
}
export function buildConditionExpressions(
conditions: ParsedConditionExpression[],
): string {
return conditions
.map(buildConditionExpression)
.filter((value): value is string => value !== null)
.join(" && ");
}
function unescapeEvalexprString(value: string): string {
return value.replaceAll(/\\(["\\])/g, "$1");
}
function parseField(variable: string, triggerType: TriggerType): string | null {
return conditionFieldsForTrigger(triggerType).some(
(field) => field.value === variable,
)
? variable
: null;
}
export function parseConditionExpression(
expression: string,
triggerType: TriggerType,
): ParsedConditionExpression | null {
const trimmed = expression.trim();
const empty = /^str_len\(([A-Za-z_][A-Za-z0-9_]*)\) (>|==) 0$/.exec(trimmed);
if (empty && parseField(empty[1], triggerType)) {
const operator: ConditionOperator =
empty[2] === ">" ? "is_not_empty" : "is_empty";
if (!conditionOperatorsForField(empty[1]).includes(operator)) return null;
return {
field: empty[1],
operator,
value: "",
webhookField: "",
};
}
const literal = '"((?:\\\\["\\\\]|[^"\\\\])*)"';
const fn = new RegExp(
`^(!)?str_(contains|starts_with|ends_with)\\(([A-Za-z_][A-Za-z0-9_]*), ${literal}\\)$`,
).exec(trimmed);
if (fn && parseField(fn[3], triggerType)) {
const operator: ConditionOperator = fn[1]
? "not_contains"
: fn[2] === "starts_with"
? "starts_with"
: fn[2] === "ends_with"
? "ends_with"
: "contains";
const result = {
field: fn[3],
operator,
value: unescapeEvalexprString(fn[4]),
webhookField: "",
};
return conditionOperatorsForField(result.field).includes(result.operator)
? result
: null;
}
const equality = new RegExp(
`^([A-Za-z_][A-Za-z0-9_]*) (!=|==) ${literal}$`,
).exec(trimmed);
if (equality && parseField(equality[1], triggerType)) {
const result = {
field: equality[1],
operator:
equality[2] === "==" ? ("equals" as const) : ("not_equals" as const),
value: unescapeEvalexprString(equality[3]),
webhookField: "",
};
return conditionValueError(result.field, result.value) ? null : result;
}
return null;
}
function splitTopLevel(
expression: string,
operator: "&&" | "||",
): string[] | null {
const parts: string[] = [];
let start = 0,
depth = 0,
inString = false,
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 === "(") depth += 1;
else if (character === ")") {
depth -= 1;
if (depth < 0) return null;
} else if (
character === operator[0] &&
expression[index + 1] === operator[1] &&
depth === 0
) {
const part = expression.slice(start, index).trim();
if (!part) return null;
parts.push(part);
index += 1;
start = index + 1;
}
}
if (inString || depth !== 0) return null;
const last = expression.slice(start).trim();
if (!last) return null;
parts.push(last);
return parts;
}
function splitTopLevelConjunctions(expression: string): string[] | null {
return splitTopLevel(expression, "&&");
}
/**
* Reports whether `expression` needs parentheses before it can be combined
* with `&&`. Conjunction binds tighter than disjunction in evalexpr, so a
* top-level `||` would otherwise absorb the added conjunct. Structurally
* unparseable expressions are grouped defensively.
*/
export function needsConjunctionGrouping(expression: string): boolean {
const parts = splitTopLevel(expression, "||");
return parts === null || parts.length > 1;
}
export function parseConditionExpressions(
expression: string,
triggerType: TriggerType,
): ParsedConditionExpression[] | null {
if (!expression.trim()) return [];
const parts = splitTopLevelConjunctions(expression);
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,
);
return new Set(conditions.map((condition) => condition.field)).size ===
conditions.length
? conditions
: null;
}

View File

@@ -2,8 +2,14 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
getWorkflowActionTiles,
getWorkflowCardLabel,
getWorkflowDisplayStatus,
getWorkflowPrimaryAction,
getWorkflowPrimaryActionEmoji,
getWorkflowStepCount,
getWorkflowTriggerEmoji,
getWorkflowTriggerSummary,
getWorkflowTriggerType,
withWorkflowEnabled,
} from "./workflowDefinition.ts";
@@ -23,19 +29,244 @@ test("reads only direct trigger and first-action types for card icons", () => {
assert.equal(getWorkflowPrimaryAction({ steps: [null] }), null);
});
test("updates enabled state without mutating the workflow definition", () => {
test("builds a plain-language workflow card label", () => {
assert.equal(
getWorkflowCardLabel({
trigger: { on: "message_posted" },
steps: [{ action: "send_message", text: "Deploying now" }],
}),
"When a message is posted, send “Deploying now”",
);
assert.equal(
getWorkflowCardLabel({
trigger: { on: "reaction_added", emoji: "🔥" },
steps: [
{ action: "delay", duration: "5m" },
{ action: "add_reaction", emoji: "✅" },
],
}),
"When someone reacts with 🔥, wait 5 minutes, then 1 more step",
);
assert.equal(
getWorkflowCardLabel(
{
trigger: {
on: "reaction_added",
filter: 'trigger_emoji == "🫠"',
},
steps: [{ action: "add_reaction", emoji: "👍" }],
},
{ triggerReaction: "🫠" },
),
"When someone reacts with 🫠, add a 👍 reaction",
);
assert.equal(
getWorkflowCardLabel({
trigger: {
on: "reaction_added",
filter: 'trigger_emoji == "🫠"',
},
steps: [{ action: "add_reaction", emoji: "👍" }],
}),
"When someone reacts with 🫠, add a 👍 reaction",
);
assert.equal(
getWorkflowCardLabel(
{
trigger: { on: "message_posted" },
steps: [{ action: "send_message", text: "Deploying now" }],
},
{ actionChannelLabel: "releases" },
),
"When a message is posted, send “Deploying now” in #releases",
);
assert.equal(
getWorkflowCardLabel(
{
trigger: {
on: "message_posted",
filter: `trigger_author == "${"a".repeat(64)}"`,
},
steps: [{ action: "send_message", text: "Deploying now" }],
},
{ triggerDescription: "Message posted by Carl" },
),
"When a message is posted by Carl, send “Deploying now”",
);
assert.equal(
getWorkflowCardLabel(
{
trigger: {
on: "message_posted",
filter: 'str_contains(trigger_text, "deploy")',
},
steps: [{ action: "call_webhook" }],
},
{ triggerDescription: "Message contains “deploy”" },
),
"When a message contains “deploy”, call a webhook",
);
assert.equal(
getWorkflowCardLabel({
trigger: {
on: "message_posted",
filter: 'str_contains(trigger_text, "deploy")',
},
steps: [{ action: "call_webhook" }],
}),
"When a message contains “deploy”, call a webhook",
);
assert.equal(
getWorkflowCardLabel({
trigger: {
on: "reaction_added",
filter: `trigger_message_id == "${"b".repeat(64)}"`,
},
steps: [{ action: "add_reaction", emoji: "👍" }],
}),
"When a reaction is added, add a 👍 reaction",
);
assert.equal(
getWorkflowCardLabel(
{
trigger: {
on: "message_posted",
filter: `trigger_text == "FUCK" && trigger_author == "${"a".repeat(64)}"`,
},
steps: [{ action: "send_message", text: "{{trigger.text}} yourself" }],
},
{
triggerDescription: "Message “FUCK” is posted by Carl",
},
),
"When “FUCK” is posted by Carl, send “{{trigger.text}} yourself”",
);
});
test("builds a concise semantic trigger label for workflow cards", () => {
assert.equal(
getWorkflowTriggerSummary({
trigger: {
on: "message_posted",
filter: 'str_contains(trigger_text, "deploy")',
},
}),
"Message contains “deploy”",
);
assert.equal(
getWorkflowTriggerSummary({
trigger: {
on: "reaction_added",
filter: `trigger_emoji == "🔥" && trigger_author == "${"a".repeat(64)}" && trigger_message_id == "${"b".repeat(64)}"`,
},
}),
"🔥 reaction added",
);
assert.equal(
getWorkflowTriggerSummary({
trigger: { on: "schedule", interval: "15m" },
}),
"Schedule",
);
});
test("counts configured steps for card stack presentation", () => {
assert.equal(getWorkflowStepCount({}), 0);
assert.equal(
getWorkflowStepCount({
steps: [{ action: "send_message" }, { action: "add_reaction" }],
}),
2,
);
});
test("returns configured reaction emoji for rich card rendering", () => {
assert.equal(
getWorkflowTriggerEmoji({
trigger: {
on: "reaction_added",
filter: 'trigger_emoji == "👀"',
},
}),
"👀",
);
assert.equal(
getWorkflowTriggerEmoji({
trigger: { on: "reaction_added", emoji: ":blob-wave:" },
}),
":blob-wave:",
);
assert.equal(
getWorkflowTriggerEmoji({
trigger: {
on: "reaction_added",
filter: 'trigger_emoji != "👀"',
},
}),
null,
);
assert.equal(
getWorkflowTriggerEmoji({
trigger: {
on: "message_posted",
filter: 'trigger_emoji == "👀"',
},
}),
null,
);
assert.equal(
getWorkflowPrimaryActionEmoji({
steps: [{ action: "add_reaction", emoji: ":blob-wave:" }],
}),
":blob-wave:",
);
assert.equal(
getWorkflowPrimaryActionEmoji({
steps: [{ action: "send_message", emoji: ":blob-wave:" }],
}),
null,
);
assert.deepEqual(
getWorkflowActionTiles({
steps: [
{ action: "add_reaction", emoji: "🐥" },
{ action: "send_message", text: "hello" },
{ action: "delay", duration: "5m" },
],
}),
[
{ action: "add_reaction", emoji: "🐥", key: "step-0" },
{ action: "send_message", emoji: null, key: "step-1" },
{ action: "delay", emoji: null, key: "step-2" },
],
);
});
test("toggles workflow enabled state without mutating its definition", () => {
const definition = {
name: "deploy",
enabled: false,
trigger: { on: "message_posted" },
future_field: { keep: true },
};
const disabled = withWorkflowEnabled(definition, false);
assert.deepEqual(disabled, { ...definition, enabled: false });
assert.deepEqual(definition, {
assert.deepEqual(withWorkflowEnabled(definition, true), {
name: "deploy",
trigger: { on: "message_posted" },
future_field: { keep: true },
});
assert.deepEqual(withWorkflowEnabled(disabled, true), definition);
assert.deepEqual(withWorkflowEnabled(definition, false), definition);
assert.equal(definition.enabled, false);
});
test("shows a disabled definition as disabled while preserving other statuses", () => {
@@ -55,3 +286,38 @@ test("shows a disabled definition as disabled while preserving other statuses",
"archived",
);
});
test("summarizes common and custom schedules", () => {
assert.equal(
getWorkflowCardLabel({
trigger: { on: "schedule", interval: "15m" },
steps: [{ action: "call_webhook" }],
}),
"Every 15 minutes, call a webhook",
);
assert.equal(
getWorkflowCardLabel({
trigger: { on: "schedule", cron: "30 9 * * *" },
steps: [{ action: "request_approval" }],
}),
"Every day at 09:30 UTC, request approval",
);
assert.equal(
getWorkflowCardLabel({
trigger: { on: "schedule", cron: "*/5 8-17 * * 1-5" },
steps: [],
}),
"On a custom schedule",
);
});
test("gracefully labels definitions with future trigger and action types", () => {
assert.equal(
getWorkflowCardLabel({
trigger: { on: "issue_closed" },
steps: [{ action: "archive_issue" }],
}),
"When issue closed happens, archive issue",
);
assert.equal(getWorkflowCardLabel({}), "When this workflow starts");
});

View File

@@ -1,6 +1,22 @@
import type { Workflow } from "@/shared/api/types";
import { TRIGGER_LABELS } from "./workflowFormTypes";
import type { TriggerType } from "./workflowFormTypes";
import {
scheduleFormFromTrigger,
SCHEDULE_FREQUENCY_LABELS,
} from "./workflowSchedule";
import {
ACTION_LABELS,
TRIGGER_LABELS,
TRIGGER_TYPES,
} from "./workflowFormTypes";
import type {
ActionType,
StepFormState,
TriggerConfig,
TriggerType,
} from "./workflowFormTypes";
import { workflowStepDescription } from "./workflowStepDescription";
import { parseConditionExpressions } from "./workflowConditionExpression";
import { workflowTriggerDescription } from "./workflowTriggerDescription";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -9,25 +25,389 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function humanizeIdentifier(value: string): string {
return value.replaceAll("_", " ").replace(/\s+/g, " ").trim();
}
function getWorkflowSteps(
definition: Record<string, unknown>,
): Record<string, unknown>[] {
return Array.isArray(definition.steps)
? definition.steps.map(asRecord).filter((step) => step !== null)
: [];
}
export function getWorkflowTriggerType(
definition: Record<string, unknown>,
): string | null {
const trigger = asRecord(definition.trigger);
return typeof trigger?.on === "string" && trigger.on.trim().length > 0
? trigger.on
: null;
return nonEmptyString(asRecord(definition.trigger)?.on);
}
export function getWorkflowPrimaryAction(
definition: Record<string, unknown>,
): string | null {
const firstStep = Array.isArray(definition.steps)
? asRecord(definition.steps[0])
return nonEmptyString(getWorkflowSteps(definition)[0]?.action);
}
export function getWorkflowPrimaryActionEmoji(
definition: Record<string, unknown>,
): string | null {
return getWorkflowActionTiles(definition)[0]?.emoji ?? null;
}
export function getWorkflowActionTiles(
definition: Record<string, unknown>,
): Array<{ action: string; emoji: string | null; key: string }> {
return getWorkflowSteps(definition).map((step, index) => ({
action: nonEmptyString(step.action) ?? "",
emoji: step.action === "add_reaction" ? nonEmptyString(step.emoji) : null,
key: nonEmptyString(step.id) ?? `step-${index}`,
}));
}
export function getWorkflowTriggerEmoji(
definition: Record<string, unknown>,
): string | null {
const trigger = asRecord(definition.trigger);
if (trigger?.on !== "reaction_added") return null;
const legacyEmoji = nonEmptyString(trigger.emoji);
if (legacyEmoji) return legacyEmoji;
const filter = nonEmptyString(trigger.filter);
if (!filter) return null;
const conditions = parseConditionExpressions(filter, "reaction_added");
const emojiCondition = conditions?.find(
({ field, operator }) => field === "trigger_emoji" && operator === "equals",
);
return nonEmptyString(emojiCondition?.value);
}
function getWorkflowTriggerConfig(
definition: Record<string, unknown>,
): TriggerConfig | null {
const trigger = asRecord(definition.trigger);
const triggerType = nonEmptyString(trigger?.on);
if (
!trigger ||
!triggerType ||
!TRIGGER_TYPES.includes(triggerType as TriggerType)
) {
return null;
}
return {
on: triggerType as TriggerType,
filter: nonEmptyString(trigger.filter) ?? undefined,
emoji: nonEmptyString(trigger.emoji) ?? undefined,
cron: nonEmptyString(trigger.cron) ?? undefined,
interval: nonEmptyString(trigger.interval) ?? undefined,
};
}
function getScheduleCardClause(trigger: Record<string, unknown>): string {
const schedule = scheduleFormFromTrigger({
on: "schedule",
cron: nonEmptyString(trigger.cron) ?? undefined,
interval: nonEmptyString(trigger.interval) ?? undefined,
});
switch (schedule.frequency) {
case "daily":
return `Every day at ${schedule.time} UTC`;
case "weekly":
return `Every week at ${schedule.time} UTC`;
case "monthly":
return `Every month at ${schedule.time} UTC`;
case "custom_interval":
return schedule.customInterval
? `Every ${schedule.customInterval}`
: "On a schedule";
case "custom_cron":
return "On a custom schedule";
default:
return SCHEDULE_FREQUENCY_LABELS[schedule.frequency];
}
}
function getTriggerCardClause(
definition: Record<string, unknown>,
presentedReaction?: string,
): string {
const trigger = asRecord(definition.trigger);
const triggerType = nonEmptyString(trigger?.on);
if (!trigger || !triggerType) return "When this workflow starts";
switch (triggerType) {
case "message_posted":
return nonEmptyString(trigger.filter)
? "When a matching message is posted"
: "When a message is posted";
case "reaction_added": {
const emoji = nonEmptyString(presentedReaction ?? trigger.emoji);
return emoji
? `When someone reacts with ${emoji}`
: "When someone adds a reaction";
}
case "diff_posted":
return nonEmptyString(trigger.filter)
? "When a matching diff is posted"
: "When a diff is posted";
case "webhook":
return "When a webhook arrives";
case "schedule":
return getScheduleCardClause(trigger);
default:
return `When ${humanizeIdentifier(triggerType)} happens`;
}
}
function eventDescriptionCardClause(
description: string,
event: "message" | "diff",
): string | null {
const subject = event === "message" ? "Message" : "Diff";
const eventPhrase = `${subject} posted`;
if (!description.startsWith(eventPhrase)) return null;
const detail = description.slice(eventPhrase.length);
if (!detail) return `When a ${event} is posted`;
if (detail.startsWith(" is ") || detail.startsWith(" is not ")) {
return `When a ${event}${detail}`;
}
if (
detail.startsWith(" containing ") ||
detail.startsWith(" without ") ||
detail.startsWith(" starting with ") ||
detail.startsWith(" ending with ") ||
detail === " with text"
) {
return `When a ${event}${detail} is posted`;
}
return `When a ${event} is posted${detail}`;
}
function textDescriptionCardClause(
description: string,
event: "message" | "diff",
): string | null {
const subject = event === "message" ? "Message" : "Diff";
if (!description.startsWith(`${subject} `)) return null;
return `When a ${event}${description.slice(subject.length)}`;
}
function postedValueCardClause(description: string): string | null {
const postedIndex = description.indexOf(" posted");
if (postedIndex < 1) return null;
const value = description.slice(0, postedIndex);
if (!value.startsWith("“") && !value.startsWith("Anything except ")) {
return null;
}
const normalizedValue = value.startsWith("Anything")
? `anything${value.slice("Anything".length)}`
: value;
return `When ${normalizedValue} is posted${description.slice(postedIndex + " posted".length)}`;
}
function qualifiedPostedEventCardClause(
description: string,
event: "message" | "diff",
): string | null {
const subject = event === "message" ? "Message" : "Diff";
const postedIndex = description.indexOf(" posted");
if (postedIndex < 1) return null;
const suffix = description.slice(postedIndex + " posted".length);
const otherTextPrefix = `${subject} with text other than `;
if (description.startsWith(otherTextPrefix)) {
const value = description.slice(otherTextPrefix.length, postedIndex);
return `When a ${event} with text other than ${value} is posted${suffix}`;
}
if (description.startsWith(`${subject} with text posted`)) {
return `When a ${event} with text is posted${suffix}`;
}
if (description.startsWith(`${subject} without text posted`)) {
return `When a ${event} without text is posted${suffix}`;
}
return null;
}
function exactValueEventCardClause(
description: string,
event: "message" | "diff",
): string | null {
const subject = event === "message" ? "Message" : "Diff";
const prefix = `${subject} “`;
const postedMarker = " is posted";
if (!description.startsWith(prefix)) return null;
const postedIndex = description.indexOf(postedMarker);
if (postedIndex < prefix.length) return null;
const value = description.slice(subject.length + 1, postedIndex);
const suffix = description.slice(postedIndex + postedMarker.length);
return event === "message"
? `When ${value} is posted${suffix}`
: `When a diff ${value} is posted${suffix}`;
}
function getPresentedTriggerCardClause(description?: string): string | null {
if (!description) return null;
const eventClause =
exactValueEventCardClause(description, "message") ??
exactValueEventCardClause(description, "diff") ??
qualifiedPostedEventCardClause(description, "message") ??
qualifiedPostedEventCardClause(description, "diff") ??
postedValueCardClause(description) ??
eventDescriptionCardClause(description, "message") ??
eventDescriptionCardClause(description, "diff") ??
textDescriptionCardClause(description, "message") ??
textDescriptionCardClause(description, "diff");
if (eventClause) return eventClause;
if (description.startsWith("Reaction added")) {
return `When a reaction is added${description.slice("Reaction added".length)}`;
}
if (description.endsWith(" reaction added")) {
return `When someone reacts with ${description.slice(0, -" reaction added".length)}`;
}
if (
description.startsWith("Any reaction ") &&
description.endsWith(" added")
) {
return `When ${description.slice(0, -" added".length).toLocaleLowerCase()} is added`;
}
return null;
}
function getActionCardClause(
step: Record<string, unknown>,
channelLabel?: string,
): string | null {
const action = nonEmptyString(step.action);
if (!action) return null;
const actionLabel = ACTION_LABELS[action as ActionType];
const parsedStep: StepFormState | null = actionLabel
? {
action: action as ActionType,
id: nonEmptyString(step.id) ?? "step_1",
duration: nonEmptyString(step.duration) ?? undefined,
emoji: nonEmptyString(step.emoji) ?? undefined,
from: nonEmptyString(step.from) ?? undefined,
message: nonEmptyString(step.message) ?? undefined,
method: nonEmptyString(step.method) ?? undefined,
name: nonEmptyString(step.name) ?? undefined,
text: nonEmptyString(step.text) ?? undefined,
to: nonEmptyString(step.to) ?? undefined,
topic: nonEmptyString(step.topic) ?? undefined,
url: nonEmptyString(step.url) ?? undefined,
}
: null;
return typeof firstStep?.action === "string" &&
firstStep.action.trim().length > 0
? firstStep.action
const detail = parsedStep
? workflowStepDescription(parsedStep, {
channelLabel,
includeName: false,
})
: null;
const configuredDetail =
detail &&
detail !== actionLabel &&
detail !==
(actionLabel
? `${actionLabel[0]}${actionLabel.slice(1).toLocaleLowerCase()}`
: null)
? detail
: null;
switch (action) {
case "delay": {
return configuredDetail
? `wait ${configuredDetail}`
: "wait for a moment";
}
case "send_message":
if (!configuredDetail) return "send a channel message";
return nonEmptyString(step.text)
? `send ${configuredDetail}`
: `send a message in ${configuredDetail}`;
case "call_webhook":
return configuredDetail ? `call ${configuredDetail}` : "call a webhook";
case "send_dm":
return configuredDetail
? `send ${configuredDetail}`
: "send a direct message";
case "request_approval":
return configuredDetail
? `request approval: ${configuredDetail}`
: "request approval";
case "add_reaction": {
return configuredDetail
? `add a ${configuredDetail} reaction`
: "add a reaction";
}
case "set_channel_topic":
return configuredDetail
? `set the channel topic to ${configuredDetail}`
: "update the channel topic";
default: {
return (actionLabel ?? humanizeIdentifier(action)).toLocaleLowerCase();
}
}
}
export function getWorkflowStepCount(
definition: Record<string, unknown>,
): number {
return getWorkflowSteps(definition).length;
}
/** Build a short plain-language label from the workflow's trigger and steps. */
export function getWorkflowCardLabel(
definition: Record<string, unknown>,
options: {
actionChannelLabel?: string;
triggerDescription?: string;
triggerReaction?: string;
} = {},
): string {
const triggerConfig = getWorkflowTriggerConfig(definition);
const triggerClause =
getPresentedTriggerCardClause(options.triggerDescription) ??
getPresentedTriggerCardClause(
triggerConfig?.filter && !options.triggerReaction
? workflowTriggerDescription(triggerConfig, {
omitUnresolvedReferences: true,
})
: undefined,
) ??
getTriggerCardClause(definition, options.triggerReaction);
const steps = getWorkflowSteps(definition);
const firstAction = steps[0]
? getActionCardClause(steps[0], options.actionChannelLabel)
: null;
if (!firstAction) return triggerClause;
const remainingStepCount = steps.length - 1;
if (remainingStepCount === 0) return `${triggerClause}, ${firstAction}`;
return `${triggerClause}, ${firstAction}, then ${remainingStepCount} more ${
remainingStepCount === 1 ? "step" : "steps"
}`;
}
export function getWorkflowDescription(
definition: Record<string, unknown>,
): string | null {
return nonEmptyString(definition.description);
}
export function getWorkflowEnabled(
@@ -59,49 +439,18 @@ export function getWorkflowDisplayStatus(
return getWorkflowEnabled(workflow.definition) ? workflow.status : "disabled";
}
export function getWorkflowDescription(
definition: Record<string, unknown>,
): string | null {
const description = definition.description;
return typeof description === "string" && description.trim().length > 0
? description.trim()
: null;
}
export function getWorkflowTriggerSummary(
definition: Record<string, unknown>,
): string | null {
const trigger = asRecord(definition.trigger);
if (!trigger) return null;
const trigger = getWorkflowTriggerConfig(definition);
if (trigger)
return workflowTriggerDescription(trigger, {
omitUnresolvedReferences: true,
});
const on = trigger.on;
if (typeof on !== "string") return null;
const label = TRIGGER_LABELS[on as TriggerType] ?? on;
switch (on) {
case "message_posted":
case "diff_posted":
return typeof trigger.filter === "string" &&
trigger.filter.trim().length > 0
? `${label} · ${trigger.filter}`
: label;
case "reaction_added":
return typeof trigger.emoji === "string" &&
trigger.emoji.trim().length > 0
? `${label} · ${trigger.emoji}`
: label;
case "schedule":
if (typeof trigger.cron === "string" && trigger.cron.trim().length > 0) {
return `${label} · ${trigger.cron}`;
}
if (
typeof trigger.interval === "string" &&
trigger.interval.trim().length > 0
) {
return `${label} · ${trigger.interval}`;
}
return label;
default:
return label;
}
const rawTrigger = asRecord(definition.trigger);
const triggerType = nonEmptyString(rawTrigger?.on);
return triggerType
? (TRIGGER_LABELS[triggerType as TriggerType] ?? triggerType)
: null;
}

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { reactionConditionValue } from "./workflowReactionCondition.ts";
test("merges a legacy reaction emoji with a separate structured filter", () => {
const author = "a".repeat(64);
assert.equal(
reactionConditionValue({
on: "reaction_added",
emoji: "🔥",
filter: `trigger_author == "${author}"`,
}),
`trigger_emoji == "🔥" && trigger_author == "${author}"`,
);
});
test("groups a legacy filter that has top-level alternatives", () => {
const a = "a".repeat(64);
const b = "b".repeat(64);
assert.equal(
reactionConditionValue({
on: "reaction_added",
emoji: "✅",
filter: `trigger_author == "${a}" || trigger_author == "${b}"`,
}),
`trigger_emoji == "✅" && (trigger_author == "${a}" || trigger_author == "${b}")`,
);
});
test("does not add redundant grouping to an already-parenthesized filter", () => {
const a = "a".repeat(64);
const b = "b".repeat(64);
assert.equal(
reactionConditionValue({
on: "reaction_added",
emoji: "✅",
filter: `(trigger_author == "${a}" || trigger_author == "${b}")`,
}),
`trigger_emoji == "✅" && (trigger_author == "${a}" || trigger_author == "${b}")`,
);
});
test("leaves non-reaction filters unchanged", () => {
assert.equal(
reactionConditionValue({ on: "message_posted", filter: "trigger_text" }),
"trigger_text",
);
});

View File

@@ -0,0 +1,15 @@
import { needsConjunctionGrouping } from "./workflowConditionExpression";
import type { TriggerConfig } from "./workflowFormTypes";
/** Presents legacy reaction emoji constraints alongside structured filters. */
export function reactionConditionValue(trigger: TriggerConfig): string {
const filter = trigger.filter?.trim() ?? "";
const emoji = trigger.on === "reaction_added" ? trigger.emoji?.trim() : null;
if (!emoji) return filter;
const emojiCondition = `trigger_emoji == ${JSON.stringify(emoji)}`;
if (!filter) return emojiCondition;
// The emoji constraint and the filter are ANDed at runtime, so a filter with
// top-level alternatives must be grouped to preserve its original meaning.
const guarded = needsConjunctionGrouping(filter) ? `(${filter})` : filter;
return `${emojiCondition} && ${guarded}`;
}

View File

@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import test from "node:test";
import { workflowStepDescription } from "./workflowStepDescription.ts";
test("describes configured workflow steps on the canvas", () => {
assert.equal(
workflowStepDescription({
id: "message",
action: "send_message",
name: "say hello",
text: "hey yourself",
}),
"say hello · “hey yourself”",
);
assert.equal(
workflowStepDescription(
{
id: "channel-message",
action: "send_message",
text: "deploying now",
channel: "channel-id",
},
{ channelLabel: "deployments" },
),
"“deploying now” in #deployments",
);
assert.equal(
workflowStepDescription({
id: "delay",
action: "delay",
duration: "5m",
}),
"5 minutes",
);
assert.equal(
workflowStepDescription({
id: "long-delay",
action: "delay",
duration: "4w",
}),
"4 weeks",
);
assert.equal(
workflowStepDescription({
id: "webhook",
action: "call_webhook",
method: "PATCH",
url: "https://example.com/deploy",
}),
"PATCH https://example.com/deploy",
);
assert.equal(
workflowStepDescription({
id: "dm",
action: "send_dm",
text: "Build finished",
to: "team-on-call",
}),
"“Build finished” to team-on-call",
);
assert.equal(
workflowStepDescription({
id: "approval",
action: "request_approval",
message: "Ship the release?",
from: "release-managers",
}),
"“Ship the release?” from release-managers",
);
assert.equal(
workflowStepDescription({
id: "reaction",
action: "add_reaction",
emoji: ":buzz:",
}),
":buzz:",
);
assert.equal(
workflowStepDescription({
id: "topic",
action: "set_channel_topic",
topic: "Shipping this week",
}),
"“Shipping this week”",
);
});
test("falls back to the action label until a step is configured", () => {
assert.equal(
workflowStepDescription({ id: "message", action: "send_message" }),
"Send Message",
);
});
test("can omit a custom step name when composing an index label", () => {
assert.equal(
workflowStepDescription(
{
id: "message",
action: "send_message",
name: "say hello",
text: "hey yourself",
},
{ includeName: false },
),
"“hey yourself”",
);
});

View File

@@ -0,0 +1,83 @@
import { truncatePubkey } from "@/shared/lib/pubkey";
import {
formatDurationSecondsVerbose,
parseDurationSeconds,
} from "./workflowDuration";
import { ACTION_LABELS } from "./workflowFormTypes";
import type { StepFormState } from "./workflowFormTypes";
const MAX_DETAIL_LENGTH = 42;
function compact(value: string): string {
const normalized = value.trim().replaceAll(/\s+/g, " ");
return normalized.length > MAX_DETAIL_LENGTH
? `${normalized.slice(0, MAX_DETAIL_LENGTH - 3)}...`
: normalized;
}
function quoted(value: string | undefined): string | null {
const normalized = value ? compact(value) : "";
return normalized ? `“${normalized}”` : null;
}
function destination(value: string | undefined): string | null {
const normalized = value?.trim();
if (!normalized) return null;
return /^[0-9a-f]{64}$/i.test(normalized)
? truncatePubkey(normalized)
: compact(normalized);
}
function configuredStepDetail(
step: StepFormState,
channelLabel?: string,
): string | null {
switch (step.action) {
case "delay": {
const duration = step.duration?.trim();
if (!duration) return null;
const seconds = parseDurationSeconds(duration);
return compact(
seconds === null ? duration : formatDurationSecondsVerbose(seconds),
);
}
case "send_message": {
const text = quoted(step.text);
const channel = channelLabel ? `#${channelLabel}` : null;
if (text && channel) return `${text} in ${channel}`;
return text ?? channel;
}
case "send_dm": {
const text = quoted(step.text);
const recipient = destination(step.to);
if (text && recipient) return `${text} to ${recipient}`;
return text ?? recipient;
}
case "call_webhook": {
const url = step.url?.trim();
if (!url) return null;
return `${step.method?.trim() || "POST"} ${compact(url)}`;
}
case "request_approval": {
const message = quoted(step.message);
const approver = destination(step.from);
if (message && approver) return `${message} from ${approver}`;
return message ?? approver;
}
case "add_reaction":
return step.emoji?.trim() || null;
case "set_channel_topic":
return quoted(step.topic);
}
}
/** Build the concise action summary rendered on a workflow step node. */
export function workflowStepDescription(
step: StepFormState,
options: { channelLabel?: string; includeName?: boolean } = {},
): string {
const name = options.includeName === false ? undefined : step.name?.trim();
const detail = configuredStepDetail(step, options.channelLabel);
if (name && detail) return `${name} · ${detail}`;
return name || detail || ACTION_LABELS[step.action];
}

View File

@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
activeTemplateToken,
insertTemplateVariable,
workflowTemplateVariables,
} from "./workflowTemplateVariables.ts";
test("finds the active template token at the caret", () => {
assert.deepEqual(activeTemplateToken("hello {{trig", 12), {
start: 6,
end: 12,
query: "trig",
});
assert.equal(activeTemplateToken("hello {{trigger.text}}", 22), null);
assert.equal(activeTemplateToken("hello {{trigger ", 16), null);
});
test("inserts a selected variable and preserves surrounding text", () => {
const token = activeTemplateToken("before {{tri after", 12);
assert.ok(token);
assert.deepEqual(
insertTemplateVariable("before {{tri after", token, "trigger.text"),
{
value: "before {{trigger.text}} after",
caret: 23,
},
);
});
test("replaces the whole template when editing inside an existing token", () => {
const token = activeTemplateToken("{{trigger.text}}", 9);
assert.ok(token);
assert.deepEqual(
insertTemplateVariable("{{trigger.text}}", token, "trigger.author"),
{
value: "{{trigger.author}}",
caret: 18,
},
);
});
test("offers trigger fields and known previous-step outputs", () => {
const variables = workflowTemplateVariables("message_posted", [
{ id: "notify", action: "send_message" },
{ id: "pause", action: "delay" },
]).map(({ value }) => value);
assert.ok(variables.includes("trigger.text"));
assert.ok(variables.includes("trigger.author"));
assert.ok(variables.includes("steps.notify.output.event_id"));
assert.ok(variables.includes("steps.pause.output.slept_secs"));
});
test("offers only outputs available in every runtime shape", () => {
const variables = workflowTemplateVariables("reaction_added", [
{ id: "react", action: "add_reaction" },
]).map(({ value }) => value);
assert.ok(variables.includes("trigger.emoji"));
assert.ok(variables.includes("steps.react.output.added"));
assert.ok(!variables.includes("steps.react.output.status"));
assert.ok(!variables.includes("steps.react.output.response"));
});
test("keeps prior-step ordering and ignores unsafe step IDs", () => {
const variables = workflowTemplateVariables("webhook", [
{ id: "first", action: "delay" },
{ id: "unsafe-id", action: "send_message" },
{ id: "second", action: "send_message" },
]).map(({ value }) => value);
assert.deepEqual(variables, [
"trigger.channel_id",
"steps.first.output.slept_secs",
"steps.second.output.sent",
"steps.second.output.event_id",
]);
});
test("only offers values populated by the selected trigger", () => {
const variables = workflowTemplateVariables("schedule", []).map(
({ value }) => value,
);
assert.deepEqual(variables, ["trigger.channel_id", "trigger.timestamp"]);
});

View File

@@ -0,0 +1,147 @@
import type {
ActionType,
StepFormState,
TriggerType,
} from "./workflowFormTypes";
export type WorkflowTemplateVariable = {
description: string;
group: "Trigger" | "Previous steps";
value: string;
};
export type ActiveTemplateToken = {
end: number;
query: string;
start: number;
};
const COMMON_EVENT_VARIABLES: WorkflowTemplateVariable[] = [
{
value: "trigger.author",
description: "Author pubkey",
group: "Trigger",
},
{
value: "trigger.channel_id",
description: "Channel UUID",
group: "Trigger",
},
{
value: "trigger.timestamp",
description: "Unix timestamp",
group: "Trigger",
},
{
value: "trigger.message_id",
description: "Message event ID",
group: "Trigger",
},
];
const STEP_OUTPUTS: Partial<Record<ActionType, Record<string, string>>> = {
delay: { slept_secs: "Seconds elapsed" },
send_message: {
sent: "Whether the message was sent",
event_id: "Sent message ID",
},
call_webhook: { status: "HTTP response status", body: "HTTP response body" },
add_reaction: {
added: "Whether the reaction was added",
},
};
function triggerVariables(
triggerType: TriggerType,
): WorkflowTemplateVariable[] {
switch (triggerType) {
case "message_posted":
return [
{
value: "trigger.text",
description: "Message text",
group: "Trigger",
},
...COMMON_EVENT_VARIABLES,
];
case "diff_posted":
return [
{ value: "trigger.text", description: "Diff text", group: "Trigger" },
...COMMON_EVENT_VARIABLES,
];
case "reaction_added":
return [
{
value: "trigger.emoji",
description: "Reaction emoji",
group: "Trigger",
},
...COMMON_EVENT_VARIABLES,
];
case "schedule":
return COMMON_EVENT_VARIABLES.filter(
({ value }) =>
value === "trigger.channel_id" || value === "trigger.timestamp",
);
case "webhook":
return [
{
value: "trigger.channel_id",
description: "Workflow channel UUID",
group: "Trigger",
},
];
}
}
export function workflowTemplateVariables(
triggerType: TriggerType,
previousSteps: StepFormState[],
): WorkflowTemplateVariable[] {
const priorOutputs = previousSteps.flatMap((step) => {
const outputs = STEP_OUTPUTS[step.action];
if (!outputs || !/^[A-Za-z0-9_]+$/.test(step.id)) return [];
return Object.entries(outputs).map(([field, description]) => ({
value: `steps.${step.id}.output.${field}`,
description,
group: "Previous steps" as const,
}));
});
return [...triggerVariables(triggerType), ...priorOutputs];
}
/** Find an unfinished `{{variable` token immediately before the caret. */
export function activeTemplateToken(
value: string,
caret: number,
): ActiveTemplateToken | null {
const beforeCaret = value.slice(0, caret);
const start = beforeCaret.lastIndexOf("{{");
if (start < 0) return null;
const query = beforeCaret.slice(start + 2);
if (query.includes("}}") || !/^[A-Za-z0-9._]*$/.test(query)) return null;
// If the caret is inside an existing template, replace the complete token
// instead of leaving its old suffix behind after a suggestion is chosen.
const closingBraces = value.indexOf("}}", caret);
const nestedOpening = value.indexOf("{{", start + 2);
const end =
closingBraces >= 0 && (nestedOpening < 0 || closingBraces < nestedOpening)
? closingBraces + 2
: caret;
return { start, end, query };
}
export function insertTemplateVariable(
value: string,
token: ActiveTemplateToken,
variable: string,
): { caret: number; value: string } {
const insertion = `{{${variable}}}`;
return {
value: `${value.slice(0, token.start)}${insertion}${value.slice(token.end)}`,
caret: token.start + insertion.length,
};
}

View File

@@ -0,0 +1,158 @@
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: `trigger_author == "${"a".repeat(64)}"`,
},
{ authorLoading: true },
),
"Message posted by loading author",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: 'str_contains(trigger_text, "deploy")',
}),
"Message contains “deploy”",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: '!str_contains(trigger_text, "deploy")',
}),
"Message doesn’t contain “deploy”",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: 'str_starts_with(trigger_text, "deploy")',
}),
"Message starts with “deploy”",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: 'str_ends_with(trigger_text, "done")',
}),
"Message ends with “done”",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: 'trigger_text == "deploy"',
}),
"Message “deploy” is posted",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: 'trigger_text != "deploy"',
}),
"Message with text other than “deploy” posted",
);
assert.equal(
workflowTriggerDescription(
{
on: "message_posted",
filter: `trigger_text == "FUCK" && trigger_author == "${"a".repeat(64)}"`,
},
{ authorLabel: "Carl" },
),
"Message “FUCK” is posted by Carl",
);
assert.equal(
workflowTriggerDescription(
{
on: "message_posted",
filter: `str_contains(trigger_text, "deploy") && trigger_author == "${"a".repeat(64)}"`,
},
{ authorLabel: "Carl" },
),
"Message by Carl contains “deploy”",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: "str_len(trigger_text) == 0",
}),
"Message without text posted",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
filter: "str_len(trigger_text) > 0",
}),
"Message with text posted",
);
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",
);
});

View File

@@ -0,0 +1,209 @@
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";
export const TRIGGER_AUTHOR_LOADING_LABEL = "loading author";
function authorReference(
condition: ParsedConditionExpression,
authorLabel?: string,
authorLoading?: boolean,
): string {
if (authorLoading) return TRIGGER_AUTHOR_LOADING_LABEL;
return authorLabel ?? truncatePubkey(condition.value);
}
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 subject = eventPhrase.replace(/ posted$/, "");
const value = quotedValue(condition.value);
switch (condition.operator) {
case "contains":
return `${subject} contains ${value}`;
case "not_contains":
return `${subject} doesn’t contain ${value}`;
case "starts_with":
return `${subject} starts with ${value}`;
case "ends_with":
return `${subject} ends with ${value}`;
case "equals":
return `${subject} ${value} is posted`;
case "not_equals":
return `${subject} with text other than ${value} posted`;
case "is_not_empty":
return `${subject} with text posted`;
case "is_empty":
return `${subject} without text posted`;
}
}
/** Build the concise trigger summary rendered on the workflow canvas. */
export function workflowTriggerDescription(
trigger: TriggerConfig,
options: {
authorLoading?: boolean;
authorLabel?: string;
messageLabel?: string;
messageLoading?: boolean;
omitUnresolvedReferences?: boolean;
} = {},
): string {
const baseLabel =
EVENT_PHRASES[trigger.on as keyof typeof EVENT_PHRASES] ??
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
? eventPhrase
: 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 &&
(!options.omitUnresolvedReferences ||
options.authorLabel ||
options.authorLoading)
) {
const author = authorReference(
authorCondition,
options.authorLabel,
options.authorLoading,
);
const attribution =
authorCondition.operator === "not_equals"
? ` by anyone except ${author}`
: ` by ${author}`;
const subject = eventPhrase.replace(/ posted$/, "");
description =
textCondition &&
description.startsWith(subject) &&
!/ (?:is )?posted$/.test(description)
? `${subject}${attribution}${description.slice(subject.length)}`
: `${description}${attribution}`;
}
if (
messageCondition &&
(!options.omitUnresolvedReferences ||
options.messageLabel ||
options.messageLoading)
) {
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") {
if (
options.omitUnresolvedReferences &&
!options.authorLabel &&
!options.authorLoading
) {
return baseLabel;
}
const author = authorReference(
condition,
options.authorLabel,
options.authorLoading,
);
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") {
if (
options.omitUnresolvedReferences &&
!options.messageLabel &&
!options.messageLoading
) {
return baseLabel;
}
const message = messageReference(
condition,
options.messageLabel,
options.messageLoading,
);
return condition.operator === "not_equals"
? `Reaction added anywhere except ${message}`
: `Reaction added to ${message}`;
}
return baseLabel;
}

View File

@@ -62,6 +62,18 @@ async function createWorkflow(
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog.getByRole("button", { name: "Create" }).click();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await Promise.race([
activationConfirmation.waitFor({ state: "visible" }),
dialog.waitFor({ state: "hidden" }),
]);
if (await activationConfirmation.isVisible()) {
await activationConfirmation
.getByRole("button", { name: "Turn on" })
.click();
}
await expect(dialog).not.toBeVisible();
}

View File

@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { parse as parseYaml } from "yaml";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
@@ -39,11 +40,10 @@ async function selectTrigger(
async function openTriggerInspector(
dialog: import("@playwright/test").Locator,
trigger: string,
) {
const menu = dialog.getByRole("button", { name: "Trigger event" });
if (!(await menu.isVisible())) {
await dialog.getByRole("button", { name: `Trigger: ${trigger}` }).click();
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
}
await expect(menu).toBeVisible();
}
@@ -57,6 +57,19 @@ async function addMessageStep(
await dialog.getByLabel("Message text").fill("Workflow notification");
}
async function createEnabled(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
) {
await dialog.getByRole("button", { name: "Create" }).click();
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
if (await confirmation.isVisible()) {
await confirmation.getByRole("button", { name: "Turn on" }).click();
}
}
async function reopenWorkflow(
page: import("@playwright/test").Page,
name: string,
@@ -69,6 +82,111 @@ async function reopenWorkflow(
return page.getByRole("dialog", { name: "Edit workflow" });
}
test("confirms activation after create and preserves a safe disabled path", async ({
page,
}) => {
const name = `activation_confirmation_${Date.now()}`;
const dialog = await openCreateWorkflow(page, name);
await addMessageStep(page, dialog);
await expect(
dialog.getByRole("switch", { name: "Enable workflow" }),
).toHaveCount(0);
await dialog.getByRole("button", { name: "Create" }).click();
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await expect(confirmation).toBeVisible();
await expect(
confirmation.getByRole("button", { name: "Back" }),
).toBeFocused();
await confirmation.getByRole("button", { name: "Back" }).click();
await expect(confirmation).toBeHidden();
await expect(dialog).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
).length,
),
)
.toBe(0);
await dialog.getByRole("button", { name: "Create" }).click();
await confirmation.getByRole("button", { name: "Keep off" }).click();
await expect(dialog).toBeHidden();
const yaml = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((candidate) => candidate.command === "create_workflow");
return (call?.payload as { yamlDefinition?: string } | undefined)
?.yamlDefinition;
});
expect(parseYaml(yaml ?? "").enabled).toBe(false);
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
await expect(
card.getByRole("switch", { name: "Enable workflow" }),
).not.toBeChecked();
});
test("inserts template variables with keyboard control and restores the caret", async ({
page,
}) => {
const dialog = await openCreateWorkflow(page, "template_variables_keyboard");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
const textarea = dialog.getByLabel("Message text");
const listbox = page.getByRole("listbox");
await textarea.fill("Hello {{trig");
await expect(listbox).toBeVisible();
await expect(listbox.getByRole("option")).toHaveCount(5);
await waitForAnimations(page);
expect(await page.locator("body").screenshot()).toMatchSnapshot(
"workflow-template-variable-autocomplete.png",
);
await textarea.press("ArrowUp");
await expect(textarea).toHaveAttribute(
"aria-activedescendant",
/-variables-option-4$/,
);
await textarea.press("ArrowDown");
await expect(textarea).toHaveAttribute(
"aria-activedescendant",
/-variables-option-0$/,
);
await textarea.press("Enter");
await expect(textarea).toHaveValue("Hello {{trigger.text}}");
await expect(textarea).toBeFocused();
await expect
.poll(() =>
textarea.evaluate((element) =>
element instanceof HTMLTextAreaElement ? element.selectionStart : -1,
),
)
.toBe("Hello {{trigger.text}}".length);
await textarea.fill("Keep {{auth");
await expect(listbox).toBeVisible();
await textarea.press("Escape");
await expect(listbox).toBeHidden();
await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue("Keep {{auth");
await textarea.fill("By {{auth");
await textarea.press("Tab");
await expect(textarea).toHaveValue("By {{trigger.author}}");
await expect(textarea).toBeFocused();
});
test("round-trips schedule presets and saves a custom UTC cron", async ({
page,
}) => {
@@ -86,7 +204,7 @@ test("round-trips schedule presets and saves a custom UTC cron", async ({
expect(definition.trigger).toEqual({ on: "schedule", interval: "15m" });
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await openTriggerInspector(dialog);
await expect(
dialog.getByRole("radio", { name: "Every 15 minutes" }),
).toBeChecked();
@@ -114,7 +232,7 @@ test("round-trips schedule presets and saves a custom UTC cron", async ({
cron: "5 */2 * * 2-4",
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await openTriggerInspector(dialog);
await expect(
dialog.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
@@ -123,9 +241,9 @@ test("round-trips schedule presets and saves a custom UTC cron", async ({
).toHaveValue("2-4");
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened, "Schedule");
await openTriggerInspector(reopened);
await expect(
reopened.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
@@ -145,17 +263,41 @@ test("round-trips and reopens structured message-text conditions", async ({
const expression = 'str_ends_with(trigger_text, "deploy \\"buzz\\"\\\\path")';
const dialog = await openCreateWorkflow(page, name);
await dialog.getByText("ends with", { exact: true }).click();
await dialog.getByLabel("Text to match").fill(text);
await dialog
.getByRole("group", { name: "Match" })
.getByRole("button", {
name: "ends with",
})
.click();
await dialog.getByLabel("Message text").fill(text);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger.filter).toBe(expression);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Message Posted");
await expect(dialog.getByText("ends with", { exact: true })).toBeVisible();
await expect(dialog.getByLabel("Text to match")).toHaveValue(text);
await openTriggerInspector(dialog);
const matchControls = dialog.getByRole("group", { name: "Match" });
const operatorButtons = matchControls.getByRole("button");
const firstOperatorBox = await operatorButtons.nth(0).boundingBox();
const secondOperatorBox = await operatorButtons.nth(1).boundingBox();
const thirdOperatorBox = await operatorButtons.nth(2).boundingBox();
expect(firstOperatorBox).not.toBeNull();
expect(secondOperatorBox).not.toBeNull();
expect(thirdOperatorBox).not.toBeNull();
expect(secondOperatorBox?.x).toBeGreaterThan(firstOperatorBox?.x ?? 0);
expect(
Math.abs((secondOperatorBox?.y ?? 0) - (firstOperatorBox?.y ?? 0)),
).toBeLessThan(1);
expect(thirdOperatorBox?.y).toBeGreaterThan(firstOperatorBox?.y ?? 0);
await waitForAnimations(page);
await matchControls.screenshot({
path: "test-results/workflow-message-condition-operators.png",
});
await expect(
matchControls.getByRole("button", { name: "ends with" }),
).toHaveAttribute("aria-pressed", "true");
await expect(dialog.getByLabel("Message text")).toHaveValue(text);
await dialog.getByRole("tab", { name: "Advanced" }).click();
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(
expression,
@@ -163,11 +305,196 @@ test("round-trips and reopens structured message-text conditions", async ({
await dialog.getByRole("tab", { name: "Basic" }).click();
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened, "Message Posted");
await expect(reopened.getByText("ends with", { exact: true })).toBeVisible();
await expect(reopened.getByLabel("Text to match")).toHaveValue(text);
await openTriggerInspector(reopened);
await expect(
reopened
.getByRole("group", { name: "Match" })
.getByRole("button", { name: "ends with" }),
).toHaveAttribute("aria-pressed", "true");
await expect(reopened.getByLabel("Message text")).toHaveValue(text);
});
test("renders deterministic trigger, step, and workflow-card summaries", async ({
page,
}) => {
const name = `semantic_summaries_${Date.now()}`;
await page.addInitScript((workflowName) => {
const positions: number[] = [];
Object.defineProperty(window, "__WORKFLOW_METADATA_POSITIONS__", {
configurable: true,
value: positions,
});
const findTarget = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid="workflow-card-name"]',
),
).find((element) => element.textContent === workflowName);
if (!target) {
requestAnimationFrame(findTarget);
return;
}
let remainingFrames = 12;
const sample = () => {
positions.push(target.getBoundingClientRect().y);
remainingFrames -= 1;
if (remainingFrames > 0) requestAnimationFrame(sample);
};
sample();
};
requestAnimationFrame(findTarget);
}, name);
const dialog = await openCreateWorkflow(page, name);
await dialog.getByLabel("Message text").fill("deploy");
const triggerNode = dialog.getByRole("button", {
name: "Trigger: Message contains “deploy”",
});
await expect(triggerNode).toContainText("Trigger");
await expect(triggerNode).toContainText("Message contains “deploy”");
await addMessageStep(page, dialog);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
definition.steps[0].channel = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
await yamlEditor.fill(stringifyYaml(definition));
await dialog.getByRole("tab", { name: "Form" }).click();
const stepNode = dialog.getByRole("button", {
name: "Step 1: “Workflow notification” in #agents",
});
await expect(stepNode).toContainText("Send Message");
await expect(stepNode).toContainText("“Workflow notification” in #agents");
await createEnabled(page, dialog);
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
const semanticLabel = card.getByTestId("workflow-card-semantic-label");
const workflowName = card.getByTestId("workflow-card-name");
const channelName = card.getByTestId("workflow-card-channel");
await expect(card.getByTestId("workflow-card-trigger-summary")).toHaveCount(
0,
);
await expect(semanticLabel).toHaveText(
"When a message contains “deploy”, send “Workflow notification”",
);
await expect(workflowName).toHaveText(name);
await expect(channelName).toHaveText("#agents");
const semanticBox = await semanticLabel.boundingBox();
const nameBox = await workflowName.boundingBox();
const channelBox = await channelName.boundingBox();
expect(semanticBox).not.toBeNull();
expect(nameBox).not.toBeNull();
expect(channelBox).not.toBeNull();
expect(semanticBox?.y).toBeLessThan(nameBox?.y ?? 0);
expect(channelBox?.y).toBeLessThan(nameBox?.y ?? 0);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as typeof window & {
__WORKFLOW_METADATA_POSITIONS__?: number[];
}
).__WORKFLOW_METADATA_POSITIONS__?.length ?? 0,
),
)
.toBe(12);
const metadataPositions = await page.evaluate(
() =>
(
window as typeof window & {
__WORKFLOW_METADATA_POSITIONS__?: number[];
}
).__WORKFLOW_METADATA_POSITIONS__ ?? [],
);
expect(
Math.max(...metadataPositions) - Math.min(...metadataPositions),
).toBeLessThan(0.5);
});
test("round-trips manual author and reaction message IDs through save and reopen", async ({
page,
}) => {
const name = `manual_trigger_ids_${Date.now()}`;
const author = "a".repeat(64);
const messageId = "b".repeat(64);
const dialog = await openCreateWorkflow(page, name);
await selectTrigger(page, dialog, "Reaction Added");
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
await openTriggerInspector(dialog);
await dialog.getByRole("button", { name: "Author pubkey" }).click();
await dialog.getByLabel("Author pubkey").fill("not-hex");
await expect(
dialog.getByText("Enter a 64-character hex pubkey."),
).toBeVisible();
await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
).length,
),
)
.toBe(0);
await dialog.getByLabel("Author pubkey").fill(author);
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
await dialog.getByLabel("Author pubkey").fill("still-not-hex");
await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled();
await dialog.getByRole("tab", { name: "YAML" }).click();
const correctionEditor = dialog.getByRole("textbox", {
name: "Workflow YAML",
});
const correctedDefinition = parseYaml(await correctionEditor.inputValue());
delete correctedDefinition.trigger.filter;
await correctionEditor.fill(stringifyYaml(correctedDefinition));
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await dialog.getByRole("button", { name: "Author pubkey" }).click();
await expect(dialog.getByLabel("Author pubkey")).toHaveValue("");
await dialog.getByLabel("Author pubkey").fill(author);
await dialog
.getByRole("group", { name: "Match" })
.getByRole("button", { name: "is not", exact: true })
.click();
await dialog.getByRole("button", { name: "Message event ID" }).click();
await dialog.getByLabel("Message event ID").fill(messageId);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
let definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({
on: "reaction_added",
filter: `trigger_author != "${author}" && trigger_message_id == "${messageId}"`,
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened);
await reopened.getByRole("button", { name: "Author pubkey" }).click();
await expect(reopened.getByLabel("Author pubkey")).toHaveValue(author);
await reopened.getByRole("button", { name: "Message event ID" }).click();
await expect(reopened.getByLabel("Message event ID")).toHaveValue(messageId);
await reopened.getByRole("tab", { name: "YAML" }).click();
definition = parseYaml(
await reopened.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
);
expect(definition.trigger.filter).toContain(messageId);
});
test("hides and clears message-text step conditions for schedule triggers", async ({
@@ -181,9 +508,9 @@ test("hides and clears message-text step conditions for schedule triggers", asyn
await dialog.getByRole("button", { name: "Run controls" }).click();
await dialog.getByLabel("Text to match").fill("deploy");
await dialog.getByRole("button", { name: "Trigger: Message Posted" }).click();
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
await selectTrigger(page, dialog, "Schedule");
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
await dialog.getByRole("button", { name: "Run controls" }).click();
await expect(dialog.getByLabel("Text to match")).toHaveCount(0);
@@ -212,7 +539,7 @@ test("keeps advanced and malformed definitions lossless", async ({ page }) => {
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Message Posted");
await openTriggerInspector(dialog);
await expect(dialog.getByRole("tab", { name: "Advanced" })).toHaveAttribute(
"data-state",
"active",
@@ -223,7 +550,9 @@ test("keeps advanced and malformed definitions lossless", async ({ page }) => {
dialog.getByText(/advanced expression is active/i),
).toBeVisible();
await dialog.getByRole("switch", { name: "Enable workflow" }).click();
await expect(
dialog.getByRole("switch", { name: "Enable workflow" }),
).toHaveCount(0);
await dialog.getByRole("tab", { name: "YAML" }).click();
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
advanced,

View File

@@ -73,15 +73,60 @@ test("reaction trigger uses the emoji picker instead of a free-text field", asyn
await expect(
dialog.getByRole("button", { name: "Clear trigger emoji" }),
).toHaveCount(0);
await expect(
dialog
.getByRole("button", { name: "Trigger: Reaction Added" })
.getByTestId("workflow-node-icon")
.locator("svg"),
).toHaveCount(1);
await pickEmoji(page, dialog, "rocket", EMOJI);
const triggerNode = dialog.getByRole("button", {
name: "Trigger: 🚀 reaction added",
});
await expect(triggerNode.getByTestId("workflow-node-icon")).toHaveText(EMOJI);
await expect(triggerNode).toContainText("Reaction added");
await expect(triggerNode).not.toContainText(`${EMOJI} reaction added`);
await expect(
triggerNode.getByTestId("workflow-node-icon").locator("svg"),
).toHaveCount(0);
await waitForAnimations(page);
await triggerNode.screenshot({
path: "test-results/workflow-reaction-trigger-node.png",
});
// Selecting closes the popover and shows the chosen reaction by name.
await expect(page.locator("em-emoji-picker")).toHaveCount(0);
await expect(pickerButton).toContainText(EMOJI_SHORTCODE);
await expect(pickerButton).not.toContainText("Choose a reaction");
});
test("add-reaction step replaces its sequence number with the configured emoji", async ({
page,
}) => {
const dialog = await openReactionTrigger(page);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
await yamlEditor.fill(
(await yamlEditor.inputValue()).replace(
"steps: []",
"steps:\n - id: step_1\n action: add_reaction\n emoji: ✅",
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
const stepNode = dialog.getByRole("button", { name: "Step 1: ✅" });
await expect(stepNode.getByTestId("workflow-node-icon")).toHaveText("✅");
await expect(stepNode).toContainText("Add Reaction");
await expect(stepNode).not.toContainText("✅✅");
await expect(stepNode.getByTestId("workflow-node-icon")).not.toHaveText("1");
await waitForAnimations(page);
await stepNode.screenshot({
path: "test-results/workflow-add-reaction-step-node.png",
});
});
test("picked reaction persists into canonical YAML and clears back out", async ({
page,
}) => {
@@ -92,15 +137,17 @@ test("picked reaction persists into canonical YAML and clears back out", async (
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const yaml = await yamlEditor.inputValue();
expect(yaml).toContain("on: reaction_added");
// Stored verbatim as the reaction content the executor compares against.
expect(yaml).toContain(`emoji: ${EMOJI}`);
// Stored verbatim in the local filter expression the executor compares.
expect(yaml).toContain(`trigger_emoji == "${EMOJI}"`);
// Clearing the optional filter removes the key entirely rather than
// writing an empty string, so the trigger matches any reaction again.
await dialog.getByRole("tab", { name: "Form" }).click();
// Switching editor modes drops the inspector selection, so re-open the
// trigger node before reaching for its fields.
await dialog.getByRole("button", { name: "Trigger: Reaction Added" }).click();
await dialog
.getByRole("button", { name: `Trigger: ${EMOJI} reaction added` })
.click();
await dialog.getByRole("button", { name: "Clear trigger emoji" }).click();
await expect(
dialog.getByRole("button", { name: "Choose trigger emoji" }),
@@ -109,7 +156,7 @@ test("picked reaction persists into canonical YAML and clears back out", async (
await dialog.getByRole("tab", { name: "YAML" }).click();
const clearedYaml = await yamlEditor.inputValue();
expect(clearedYaml).toContain("on: reaction_added");
expect(clearedYaml).not.toContain("emoji:");
expect(clearedYaml).not.toContain("trigger_emoji");
});
test("legacy free-text emoji values survive a trip through the picker", async ({
@@ -164,7 +211,7 @@ test("saved reaction filter round-trips back into the picker", async ({
const editDialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(editDialog).toBeVisible();
await editDialog
.getByRole("button", { name: "Trigger: Reaction Added" })
.getByRole("button", { name: `Trigger: ${EMOJI} reaction added` })
.click();
// Reopening rehydrates the stored glyph into the picker, not an empty field.

View File

@@ -62,6 +62,20 @@ async function addStepAfterTrigger(page: Page, dialog: Locator) {
await page.getByRole("menuitem", { name: "Send Message" }).click();
}
async function finishRiskyActivationIfShown(page: Page, dialog: Locator) {
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await Promise.race([
confirmation.waitFor({ state: "visible" }),
dialog.waitFor({ state: "hidden" }),
]);
if (await confirmation.isVisible()) {
await confirmation.getByRole("button", { name: "Turn on" }).click();
}
await expect(dialog).toBeHidden();
}
async function createNamedWorkflow(page: Page, name: string) {
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
@@ -73,8 +87,17 @@ async function createNamedWorkflow(page: Page, name: string) {
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await dialog.getByRole("button", { name: "Add first step" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const yaml = await yamlEditor.inputValue();
await yamlEditor.fill(
yaml.replace(
" action: send_message",
' action: send_message\n text: "Workflow notification"',
),
);
await dialog.getByRole("button", { name: "Create workflow" }).click();
await finishRiskyActivationIfShown(page, dialog);
await expect(page.getByTestId("workflows-view")).toContainText(name);
}
@@ -154,10 +177,14 @@ test("renames stay visible across pane navigation before the step is filled in",
// Completing the step re-serializes the definition from the builder's form
// state, which must have picked the rename up rather than writing back the
// generated name it was holding when the rename happened.
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog
.getByTestId("workflow-node-inspector")
.getByLabel("Message text")
.fill("Workflow notification");
expect(await readWorkflowName(dialog)).toBe(name);
await dialog.getByRole("button", { name: "Create workflow" }).click();
await finishRiskyActivationIfShown(page, dialog);
await expect(page.getByTestId("workflows-view")).toContainText(name);
});
@@ -174,6 +201,7 @@ test("keeps a header disable across a later form edit", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
await dialog.getByRole("tab", { name: "Form" }).click();
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
// The open menu aria-hides the dialog behind it, so it has to be dismissed
@@ -223,6 +251,7 @@ test("keeps the saved name while editing an existing workflow", async ({
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
await dialog.getByRole("tab", { name: "Form" }).click();
expect(await readWorkflowName(dialog)).toBe(name);
await openTriggerPane(dialog);
@@ -259,6 +288,7 @@ test("keeps the copy name while duplicating an existing workflow", async ({
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
await expect(dialog).toBeVisible();
await dialog.getByRole("tab", { name: "Form" }).click();
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
await openStepPane(dialog, 1);

View File

@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { parse as parseYaml } from "yaml";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
@@ -74,9 +75,6 @@ async function createWorkflow(
);
await dialog.getByRole("tab", { name: "Form" }).click();
}
if (options?.enabled === false) {
await dialog.getByRole("switch", { name: "Enable" }).click();
}
if (options?.trigger) {
await dialog
.getByRole("button", { name: "Trigger: Message Posted" })
@@ -116,6 +114,16 @@ async function createWorkflow(
}
await dialog.getByRole("button", { name: "Create" }).click();
if (!options?.trigger || options.trigger === "message_posted") {
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await activationConfirmation
.getByRole("button", {
name: options?.enabled === false ? "Keep off" : "Turn on",
})
.click();
}
await expect(
page.getByRole("heading", { name: "Create Workflow" }),
@@ -131,6 +139,27 @@ test("navigates to workflows view and shows the empty create tile", async ({
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
});
test("creates a narrowly triggered workflow without an activation warning", async ({
page,
}) => {
await navigateToWorkflows(page);
await createWorkflow(page, `safe_webhook_${Date.now()}`, {
trigger: "webhook",
});
await expect(
page.getByTestId("workflow-activation-confirmation"),
).toHaveCount(0);
const yaml = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((candidate) => candidate.command === "create_workflow");
return (call?.payload as { yamlDefinition?: string } | undefined)
?.yamlDefinition;
});
expect(parseYaml(yaml ?? "").enabled).not.toBe(false);
});
test("creation reveals the trigger pane only after a one-shot channel pick", async ({
page,
}) => {
@@ -211,6 +240,10 @@ test("accepts the generated name through the first form mutation", async ({
await dialog.getByRole("button", { name: "Add first step" }).click();
await dialog.getByRole("button", { name: "Create workflow" }).click();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await activationConfirmation.getByRole("button", { name: "Turn on" }).click();
await expect(page.getByTestId("workflows-view")).toContainText(generatedName);
});
@@ -249,9 +282,9 @@ test("shows executable guidance for diff trigger conditions", async ({
.getByRole("menuitem", { name: "Diff Posted", exact: true })
.click();
await expect(dialog.getByLabel("Condition (optional)")).toHaveAttribute(
await expect(dialog.getByLabel("Diff text")).toHaveAttribute(
"placeholder",
'e.g. str_contains(trigger_text, "deploy")',
"e.g. deploy",
);
});
@@ -300,11 +333,9 @@ test("captures workflow library across responsive viewports", async ({
test("captures disabled diff workflows in the list UI", async ({ page }) => {
const workflowName = `diff_workflow_${Date.now()}`;
const description = "Watches diff events for src/ changes";
await navigateToWorkflows(page);
await createWorkflow(page, workflowName, {
description,
enabled: false,
trigger: "diff_posted",
stepName: "Notify reviewers",
@@ -316,13 +347,18 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await expect(card.getByText("Diff Posted", { exact: true })).toBeVisible();
await expect(card.locator("h3")).toHaveText(workflowName);
await expect(card.getByText(description, { exact: true })).toBeVisible();
await expect(card).toContainText("disabled");
await expect(card.locator("h3")).toHaveText(
"When a diff is posted, send “Workflow notification”",
);
await expect(card).toContainText(workflowName);
await expect(
card.getByRole("switch", { name: "Disable workflow" }),
).toBeChecked();
});
test("enables and disables a workflow from its card menu", async ({ page }) => {
test("enables and disables a workflow from its card status toggle", async ({
page,
}) => {
const workflowName = `toggle_workflow_${Date.now()}`;
await navigateToWorkflows(page);
@@ -333,26 +369,32 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
const workflowActions = () =>
workflowCard().getByRole("button", { name: "Workflow actions" });
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
const disable = workflowCard().getByRole("switch", {
name: "Disable workflow",
});
await expect(disable).toBeChecked();
await disable.click();
const enable = workflowCard().getByRole("switch", {
name: "Enable workflow",
});
await expect(enable).not.toBeChecked();
await enable.click();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await expect(activationConfirmation).toBeVisible();
await activationConfirmation.getByRole("button", { name: "Turn on" }).click();
await expect(
workflowCard().getByRole("switch", { name: "Disable workflow" }),
).toBeChecked();
await workflowActions().click();
await expect(enableItem).toHaveAttribute("aria-checked", "true");
await expect(enableItem.locator("button")).toHaveCount(0);
await workflowCard()
.getByRole("button", { name: "Workflow actions" })
.click();
await expect(
enableItem.getByTestId("workflow-enabled-switch-visual"),
).toHaveAttribute("aria-hidden", "true");
await enableItem.click();
await expect(
workflowCard().getByText("disabled", { exact: true }),
).toBeVisible();
await enableItem.click();
await expect(
workflowCard().getByText("active", { exact: true }),
).toBeVisible();
page.getByRole("menuitemcheckbox", { name: "Enable" }),
).toHaveCount(0);
});
test("rejects a stale card toggle without overwriting a newer edit", async ({
@@ -395,8 +437,7 @@ test("rejects a stale card toggle without overwriting a newer edit", async ({
});
}, workflowName);
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
await workflowCard.getByRole("switch", { name: "Disable workflow" }).click();
await expect(
page
@@ -436,14 +477,15 @@ test("reports a rejected workflow status change", async ({ page }) => {
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
await workflowCard.getByRole("switch", { name: "Disable workflow" }).click();
const errorToast = page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: "Couldn’t change workflow status" });
await expect(errorToast).toContainText("relay refused the update");
await expect(workflowCard.getByText("active", { exact: true })).toBeVisible();
await expect(
workflowCard.getByRole("switch", { name: "Disable workflow" }),
).toBeChecked();
});
test("shows the webhook secret dialog after saving a webhook workflow", async ({
@@ -528,8 +570,37 @@ test("duplicates a workflow", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
await selectWorkflowChannel(page, dialog);
await dialog.getByRole("button", { name: "Create copy" }).click();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await expect(activationConfirmation).toBeVisible();
await activationConfirmation.getByRole("button", { name: "Back" }).click();
await expect(dialog).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
).length,
),
)
.toBe(1);
await dialog.getByRole("button", { name: "Create copy" }).click();
await activationConfirmation
.getByRole("button", { name: "Keep off" })
.click();
await expect(page.getByRole("dialog")).not.toBeVisible();
const copiedYaml = await page.evaluate(() => {
const calls = (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
);
return (calls.at(-1)?.payload as { yamlDefinition?: string } | undefined)
?.yamlDefinition;
});
expect(parseYaml(copiedYaml ?? "").enabled).toBe(false);
// Both the original and copy should exist
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
});
@@ -742,7 +813,7 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async
await dialog.getByLabel("Duration").fill("5m");
await expect(page).toHaveURL(/pane=step%3Astep_2/);
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
await expect(page).toHaveURL(/pane=step%3Astep_1/);
await dialog
.getByTestId("workflow-node-inspector")
@@ -757,7 +828,7 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async
const yaml = await yamlEditor.inputValue();
await yamlEditor.fill(yaml.replace("duration: 5m", "duration: 10m"));
await dialog.getByRole("tab", { name: "Form" }).click();
await dialog.getByRole("button", { name: "Step 1: Delay" }).click();
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
await expect(dialog.getByLabel("Duration")).toHaveValue("10m");
});