Files
buzz/desktop/src/features/workflows/ui/WorkflowStepCard.tsx
Taylor Ho 21268599d6 Clarify workflow condition defaults
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-13 21:50:32 -07:00

449 lines
15 KiB
TypeScript

import { Trash2 } from "lucide-react";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelCombobox } from "./ChannelCombobox";
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
import type { StepFormState, TriggerType } from "./workflowFormTypes";
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
switch (action) {
case "send_dm":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: `send_dm` is not executed yet, so runs fail at this
step.
</p>
);
case "set_channel_topic":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: `set_channel_topic` is not executed yet, so runs fail at
this step.
</p>
);
case "request_approval":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: approval gates still stop runs with WF-08; approval
records are not persisted yet.
</p>
);
case "add_reaction":
return (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700">
Backend note: `add_reaction` is not executed yet, so runs fail at this
step.
</p>
);
default:
return null;
}
}
function StepConfigFields({
step,
prefix,
disabled,
channels,
previousSteps,
triggerType,
workflowChannelId,
onUpdate,
}: {
step: StepFormState;
prefix: string;
disabled?: boolean;
channels: Channel[];
previousSteps: StepFormState[];
triggerType: TriggerType;
workflowChannelId?: string | null;
onUpdate: (step: StepFormState) => void;
}) {
switch (step.action) {
case "delay":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-duration`}
onChange={(event) =>
onUpdate({ ...step, duration: event.target.value })
}
placeholder="e.g. 5s, 1m, 1h"
value={step.duration ?? ""}
/>
</div>
);
case "send_message":
return (
<div className="space-y-2">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<WorkflowTemplateTextarea
autoCapitalize="off"
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onValueChange={(text) => onUpdate({ ...step, text })}
placeholder="e.g. Deployment started by {{trigger.author}}"
previousSteps={previousSteps}
triggerType={triggerType}
value={step.text ?? ""}
/>
</div>
{!workflowChannelId ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<ChannelCombobox
allowEmpty
ariaLabel="Channel override (optional)"
channels={channels}
disabled={disabled}
emptyLabel="Use trigger channel"
id={`${prefix}-channel`}
onChange={(channel) => onUpdate({ ...step, channel })}
value={step.channel ?? ""}
variant="field"
/>
<p className="text-xs text-muted-foreground">
Defaults to the channel that triggered the workflow. Webhook and
manual triggers require a channel.
</p>
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
</p>
) : null}
</div>
) : null}
</div>
);
case "send_dm":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-to`}>To (pubkey)</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-to`}
onChange={(event) =>
onUpdate({ ...step, to: event.target.value })
}
placeholder="e.g. {{trigger.author}} or hex pubkey"
value={step.to ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
<Textarea
autoCapitalize="off"
className="min-h-[60px] resize-y text-xs"
disabled={disabled}
id={`${prefix}-text`}
onChange={(event) =>
onUpdate({ ...step, text: event.target.value })
}
placeholder="DM content"
value={step.text ?? ""}
/>
</div>
</div>
);
case "call_webhook":
return (
<div className="space-y-4">
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-url`}>Endpoint URL</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-url`}
onChange={(event) =>
onUpdate({ ...step, url: event.target.value })
}
placeholder="https://..."
value={step.url ?? ""}
/>
{step.url && !step.url.startsWith("https://") ? (
<p className="text-xs text-destructive">
URL must start with https://
</p>
) : null}
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-method`}>HTTP method</FieldLabel>
<FormSelect
disabled={disabled}
id={`${prefix}-method`}
onChange={(value) => onUpdate({ ...step, method: value })}
value={step.method ?? "POST"}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</FormSelect>
</div>
<WorkflowWebhookHeadersEditor
disabled={disabled}
headers={step.headers ?? []}
onChange={(headers) => onUpdate({ ...step, headers })}
stepId={step.id || prefix}
/>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-body`}>
Request body (optional)
</FieldLabel>
<Textarea
autoCapitalize="off"
className="min-h-[60px] resize-y font-mono text-xs"
disabled={disabled}
id={`${prefix}-body`}
onChange={(event) =>
onUpdate({ ...step, body: event.target.value })
}
placeholder='{"key": "{{trigger.text}}"}'
value={step.body ?? ""}
/>
</div>
</div>
);
case "request_approval":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-from`}>From (approver)</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-from`}
onChange={(event) =>
onUpdate({ ...step, from: event.target.value })
}
placeholder="Pubkey or role"
value={step.from ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-message`}>Message</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-message`}
onChange={(event) =>
onUpdate({ ...step, message: event.target.value })
}
placeholder="Approval request message"
value={step.message ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout`}>
Timeout (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-timeout`}
onChange={(event) =>
onUpdate({ ...step, timeout: event.target.value })
}
placeholder="e.g. 24h"
value={step.timeout ?? ""}
/>
</div>
</div>
);
case "add_reaction":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<FieldLabel htmlFor={`${prefix}-emoji`}>Emoji</FieldLabel>
<WorkflowEmojiField
ariaLabel="Choose reaction emoji"
disabled={disabled}
id={`${prefix}-emoji`}
onChange={(emoji) => onUpdate({ ...step, emoji })}
value={step.emoji ?? ""}
/>
</div>
);
case "set_channel_topic":
return (
<div className="space-y-2">
<BackendSupportHint action={step.action} />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-topic`}
onChange={(event) =>
onUpdate({ ...step, topic: event.target.value })
}
placeholder="New channel topic"
value={step.topic ?? ""}
/>
</div>
</div>
);
default:
return null;
}
}
function SectionHeading({ title }: { title: string }) {
return <h4 className="text-sm font-semibold text-foreground">{title}</h4>;
}
export function WorkflowStepCard({
bare = false,
channels = [],
showHeader = true,
index,
disabled,
onRemove,
onUpdate,
step,
previousSteps = [],
triggerType,
workflowChannelId,
}: {
bare?: boolean;
channels?: Channel[];
showHeader?: boolean;
index: number;
disabled?: boolean;
onRemove: () => void;
onUpdate: (step: StepFormState) => void;
step: StepFormState;
previousSteps?: StepFormState[];
triggerType: TriggerType;
workflowChannelId?: string | null;
}) {
const prefix = `wf-step-${index}`;
return (
<div
className={cn(
"space-y-0",
!bare && "rounded-lg border border-border/70 bg-muted/10 p-3",
)}
>
{showHeader ? (
<div className="mb-3 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Step {index + 1}
</span>
<Button
aria-label="Remove step"
className="h-7 w-7"
disabled={disabled}
onClick={onRemove}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
) : null}
<section className="space-y-4 pb-5">
<StepConfigFields
channels={channels}
disabled={disabled}
onUpdate={onUpdate}
prefix={prefix}
previousSteps={previousSteps}
step={step}
triggerType={triggerType}
workflowChannelId={workflowChannelId}
/>
</section>
<section className="space-y-4 border-t border-border/50 py-5">
<SectionHeading title="Run controls" />
<div>
<WorkflowConditionBuilder
channels={channels}
disabled={disabled}
idPrefix={`${prefix}-condition`}
matchAllLabel="Every run"
onChange={(condition) => onUpdate({ ...step, condition })}
triggerType={triggerType}
value={step.condition ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
Timeout (seconds)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-timeout-secs`}
inputMode="numeric"
onChange={(event) =>
onUpdate({ ...step, timeoutSecs: event.target.value })
}
placeholder="e.g. 300"
value={step.timeoutSecs ?? ""}
/>
<p className="text-xs text-muted-foreground">
Defaults to the workflow timeout.
</p>
</div>
</section>
<section className="space-y-4 border-t border-border/50 pt-5">
<SectionHeading title="Step details" />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-name`}>Name (optional)</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-name`}
onChange={(event) =>
onUpdate({ ...step, name: event.target.value })
}
placeholder="e.g. Notify deployment channel"
value={step.name ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-id`}
onChange={(event) => onUpdate({ ...step, id: event.target.value })}
placeholder="unique_step_id"
value={step.id}
/>
<p className="text-xs text-muted-foreground">
Used in configuration and run history.
</p>
</div>
</section>
</div>
);
}