mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Polish workflow message inputs
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -12,18 +12,28 @@ function ChannelPrivacyIcon({ channel }: { channel: Channel }) {
|
||||
}
|
||||
|
||||
type ChannelComboboxProps = {
|
||||
allowEmpty?: boolean;
|
||||
ariaLabel?: string;
|
||||
channels: Channel[];
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
id?: string;
|
||||
isChannelDisabled?: (channel: Channel) => boolean;
|
||||
onChange: (value: string) => void;
|
||||
variant?: "header" | "field";
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function ChannelCombobox({
|
||||
allowEmpty = false,
|
||||
ariaLabel = "Channel",
|
||||
channels,
|
||||
disabled,
|
||||
emptyLabel = "Choose a channel",
|
||||
id,
|
||||
isChannelDisabled,
|
||||
onChange,
|
||||
variant = "header",
|
||||
value,
|
||||
}: ChannelComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -42,6 +52,10 @@ export function ChannelCombobox({
|
||||
c.id.toLowerCase().includes(q),
|
||||
);
|
||||
}, [channels, query]);
|
||||
const selectable = React.useMemo(
|
||||
() => filtered.filter((channel) => !isChannelDisabled?.(channel)),
|
||||
[filtered, isChannelDisabled],
|
||||
);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next);
|
||||
@@ -57,22 +71,24 @@ export function ChannelCombobox({
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (filtered.length === 0) return;
|
||||
if (selectable.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown": {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i + 1) % filtered.length);
|
||||
setHighlightedIndex((i) => (i + 1) % selectable.length);
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
||||
setHighlightedIndex(
|
||||
(i) => (i - 1 + selectable.length) % selectable.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightedIndex];
|
||||
const target = selectable[highlightedIndex];
|
||||
if (target) selectChannel(target.id);
|
||||
break;
|
||||
}
|
||||
@@ -88,10 +104,14 @@ export function ChannelCombobox({
|
||||
<Popover onOpenChange={handleOpenChange} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label="Channel"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-center rounded-lg border-0 bg-transparent px-3 py-2 text-lg font-semibold focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"group flex w-full items-center rounded-lg px-3 py-2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "header" &&
|
||||
"justify-center border-0 bg-transparent text-lg font-semibold",
|
||||
variant === "field" &&
|
||||
"justify-between border border-input/40 bg-background text-sm font-normal",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -99,19 +119,28 @@ export function ChannelCombobox({
|
||||
role="combobox"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center justify-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2",
|
||||
variant === "header" && "justify-center",
|
||||
)}
|
||||
>
|
||||
{selected ? <ChannelPrivacyIcon channel={selected} /> : null}
|
||||
<span className="truncate">
|
||||
{selected ? selected.name : "Choose a channel"}
|
||||
{selected
|
||||
? selected.name
|
||||
: value
|
||||
? "Unavailable channel"
|
||||
: emptyLabel}
|
||||
</span>
|
||||
<ChevronDown className="ml-1 h-5 w-5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</span>
|
||||
<ChevronDown className="ml-1 h-5 w-5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
portalled={false}
|
||||
portalled={variant === "field"}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -135,38 +164,62 @@ export function ChannelCombobox({
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
data-testid="channel-combobox-list"
|
||||
>
|
||||
{allowEmpty && !query ? (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
!value && "bg-accent/50",
|
||||
)}
|
||||
onClick={() => selectChannel("")}
|
||||
type="button"
|
||||
>
|
||||
<span className="h-5 w-5 shrink-0" />
|
||||
<span className="truncate">{emptyLabel}</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
!value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No channels found.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((channel, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
channel.id === value && "bg-accent/50",
|
||||
index === highlightedIndex &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
key={channel.id}
|
||||
onClick={() => selectChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChannelPrivacyIcon channel={channel} />
|
||||
<span className="truncate">
|
||||
{channel.name}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
· {channel.channelType}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
filtered.map((channel) => {
|
||||
const optionDisabled = isChannelDisabled?.(channel) ?? false;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
channel.id === value ? "opacity-100" : "opacity-0",
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent disabled:hover:text-foreground",
|
||||
channel.id === value && "bg-accent/50",
|
||||
channel.id === selectable[highlightedIndex]?.id &&
|
||||
!optionDisabled &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))
|
||||
disabled={optionDisabled}
|
||||
key={channel.id}
|
||||
onClick={() => selectChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChannelPrivacyIcon channel={channel} />
|
||||
<span className="truncate">
|
||||
{channel.name}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
· {channel.channelType}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
channel.id === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -256,6 +256,7 @@ export function WorkflowDialog({
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkflowFormBuilder
|
||||
channels={channels}
|
||||
disabled={mutation.isPending}
|
||||
footerLeadingContainer={footerLeadingElement}
|
||||
mode={editorMode}
|
||||
@@ -291,6 +292,7 @@ export function WorkflowDialog({
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
workflowChannelId={selectedChannelId || null}
|
||||
yaml={yamlDefinition}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
@@ -106,6 +107,7 @@ function TriggerConfigFields({
|
||||
}
|
||||
|
||||
type WorkflowFormBuilderProps = {
|
||||
channels: Channel[];
|
||||
disabled?: boolean;
|
||||
footerLeadingContainer?: HTMLElement | null;
|
||||
mode: WorkflowEditorMode;
|
||||
@@ -113,6 +115,7 @@ type WorkflowFormBuilderProps = {
|
||||
parseError: string | null;
|
||||
scopeField?: React.ReactNode;
|
||||
yaml: string;
|
||||
workflowChannelId?: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowEditorMode = "form" | "yaml";
|
||||
@@ -323,6 +326,7 @@ function WorkflowNode({
|
||||
}
|
||||
|
||||
export function WorkflowFormBuilder({
|
||||
channels,
|
||||
disabled,
|
||||
footerLeadingContainer,
|
||||
mode,
|
||||
@@ -330,6 +334,7 @@ export function WorkflowFormBuilder({
|
||||
parseError,
|
||||
scopeField,
|
||||
yaml,
|
||||
workflowChannelId,
|
||||
}: WorkflowFormBuilderProps) {
|
||||
// Parse once on mount instead of calling yamlToFormState three times
|
||||
const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null);
|
||||
@@ -672,15 +677,21 @@ export function WorkflowFormBuilder({
|
||||
) : selectedStep ? (
|
||||
<WorkflowStepCard
|
||||
bare
|
||||
channels={channels}
|
||||
disabled={disabled}
|
||||
index={selectedNode.index}
|
||||
onRemove={() => removeStep(selectedNode.index)}
|
||||
onUpdate={(updated) =>
|
||||
updateStep(selectedNode.index, updated)
|
||||
}
|
||||
previousSteps={formState.steps.slice(
|
||||
0,
|
||||
selectedNode.index,
|
||||
)}
|
||||
showHeader={false}
|
||||
step={selectedStep}
|
||||
triggerType={formState.trigger.on}
|
||||
workflowChannelId={workflowChannelId}
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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";
|
||||
@@ -49,13 +52,19 @@ 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) {
|
||||
@@ -80,15 +89,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>
|
||||
@@ -96,21 +105,32 @@ function StepConfigFields({
|
||||
<FieldLabel htmlFor={`${prefix}-channel`}>
|
||||
Channel override (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
<ChannelCombobox
|
||||
allowEmpty
|
||||
ariaLabel="Channel override (optional)"
|
||||
channels={channels}
|
||||
disabled={disabled}
|
||||
id={`${prefix}-channel`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, channel: event.target.value })
|
||||
emptyLabel={
|
||||
workflowChannelId
|
||||
? "Use workflow channel"
|
||||
: "Use trigger channel"
|
||||
}
|
||||
placeholder="Channel UUID"
|
||||
id={`${prefix}-channel`}
|
||||
isChannelDisabled={(channel) =>
|
||||
Boolean(workflowChannelId && channel.id !== workflowChannelId)
|
||||
}
|
||||
onChange={(channel) => onUpdate({ ...step, channel })}
|
||||
value={step.channel ?? ""}
|
||||
variant="field"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to the trigger channel. Webhook and manual triggers
|
||||
require a channel.
|
||||
{workflowChannelId
|
||||
? "Defaults to this workflow's channel. Cross-channel posting is not permitted."
|
||||
: "Defaults to the trigger channel. Webhook and manual triggers require a channel."}
|
||||
</p>
|
||||
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
|
||||
{triggerType === "webhook" &&
|
||||
!workflowChannelId &&
|
||||
!(step.channel ?? "").trim() ? (
|
||||
<p className="text-xs text-amber-700">
|
||||
This step will fail for webhook-triggered runs until a channel
|
||||
override is set.
|
||||
@@ -303,22 +323,28 @@ function SectionHeading({ title }: { title: string }) {
|
||||
|
||||
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}`;
|
||||
|
||||
@@ -350,11 +376,14 @@ export function WorkflowStepCard({
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
179
desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx
Normal file
179
desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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 [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 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-autocomplete="list"
|
||||
aria-controls={open ? `${props.id}-template-variables` : undefined}
|
||||
aria-expanded={open}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
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"
|
||||
id={`${props.id}-template-variables`}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div className="max-h-72 overflow-y-auto" 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",
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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("only offers values populated by the selected trigger", () => {
|
||||
const variables = workflowTemplateVariables("schedule", []).map(
|
||||
({ value }) => value,
|
||||
);
|
||||
assert.deepEqual(variables, ["trigger.channel_id", "trigger.timestamp"]);
|
||||
});
|
||||
149
desktop/src/features/workflows/ui/workflowTemplateVariables.ts
Normal file
149
desktop/src/features/workflows/ui/workflowTemplateVariables.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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",
|
||||
status: "HTTP response status",
|
||||
response: "Relay response",
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -447,6 +447,39 @@ test("scrolls the channel list with the mouse wheel", async ({ page }) => {
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("selects a message destination from the channel lookup", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await selectFirstChannel(dialog);
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
|
||||
const channelOverride = dialog.getByRole("combobox", {
|
||||
name: "Channel override (optional)",
|
||||
});
|
||||
await expect(channelOverride).toContainText("Use workflow channel");
|
||||
await channelOverride.click();
|
||||
|
||||
const search = page.getByPlaceholder("Search channels...");
|
||||
await search.fill("random");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "random · stream" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await search.fill("agents");
|
||||
await page.getByRole("button", { name: "agents · stream" }).click();
|
||||
await expect(channelOverride).toContainText("agents");
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||||
/channel: [0-9a-f-]{36}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens node configuration in a contextual inspector", async ({ page }) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user