mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
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. | |  |  | | 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. | |  |  | ## 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:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
188
desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx
Normal file
188
desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx
Normal file
327
desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
160
desktop/src/features/workflows/ui/workflowActivationWarning.ts
Normal file
160
desktop/src/features/workflows/ui/workflowActivationWarning.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
276
desktop/src/features/workflows/ui/workflowConditionExpression.ts
Normal file
276
desktop/src/features/workflows/ui/workflowConditionExpression.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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”",
|
||||
);
|
||||
});
|
||||
83
desktop/src/features/workflows/ui/workflowStepDescription.ts
Normal file
83
desktop/src/features/workflows/ui/workflowStepDescription.ts
Normal 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];
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
147
desktop/src/features/workflows/ui/workflowTemplateVariables.ts
Normal file
147
desktop/src/features/workflows/ui/workflowTemplateVariables.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
209
desktop/src/features/workflows/ui/workflowTriggerDescription.ts
Normal file
209
desktop/src/features/workflows/ui/workflowTriggerDescription.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user