import {
ArrowDown,
Check,
ChevronDown,
Plus,
Trash2,
TriangleAlert,
X,
Zap,
} from "lucide-react";
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 {
DropdownMenu,
DropdownMenuContent,
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 { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
import { WorkflowStepCard } from "./WorkflowStepCard";
import { buildConditionExpression } from "./workflowConditionExpression";
import { FieldLabel } from "./workflowFormPrimitives";
import {
DEFAULT_FORM_STATE,
ACTION_LABELS,
ACTION_TYPES,
TRIGGER_LABELS,
TRIGGER_TYPES,
UNSUPPORTED_ACTION_TYPES,
formStateToYaml,
nextStepId,
yamlToFormState,
} from "./workflowFormTypes";
import type {
ActionType,
StepFormState,
TriggerConfig,
WorkflowFormState,
} from "./workflowFormTypes";
import { defaultScheduleTrigger } from "./workflowSchedule";
function TriggerConfigFields({
channels,
disabled,
trigger,
onUpdate,
}: {
channels: Channel[];
disabled?: boolean;
trigger: TriggerConfig;
onUpdate: (trigger: TriggerConfig) => void;
}) {
switch (trigger.on) {
case "message_posted":
case "diff_posted":
return (
onUpdate({ ...trigger, filter })}
triggerType={trigger.on}
value={trigger.filter ?? ""}
/>
);
case "reaction_added":
return (
onUpdate({ ...trigger, emoji: undefined, filter })
}
triggerType={trigger.on}
value={
trigger.filter ??
buildConditionExpression({
field: "trigger_emoji",
operator: "equals",
value: trigger.emoji ?? "",
}) ??
""
}
/>
);
case "webhook":
return (
A unique URL is generated after creation.
);
case "schedule":
return (
);
default:
return null;
}
}
type WorkflowFormBuilderProps = {
channels: Channel[];
disabled?: boolean;
footerLeadingContainer?: HTMLElement | null;
mode: WorkflowEditorMode;
onChange: (yaml: string) => void;
parseError: string | null;
scopeField?: React.ReactNode;
yaml: string;
workflowChannelId?: string | null;
};
export type WorkflowEditorMode = "form" | "yaml";
type SelectedNode =
| { type: "trigger" }
| { type: "step"; index: number }
| null;
function nodePosition(node: Exclude): number {
return node.type === "trigger" ? 0 : node.index + 1;
}
const inspectorContentVariants = {
enter: (direction: number) => ({
opacity: 0,
y: direction < 0 ? 12 : -12,
}),
center: { opacity: 1, y: 0 },
exit: (direction: number) => ({
opacity: 0,
y: direction < 0 ? -12 : 12,
}),
};
function InspectorTypeMenu({
ariaLabel,
disabled,
labels,
onChange,
options,
warningOptions = [],
value,
}: {
ariaLabel: string;
disabled?: boolean;
labels: Record;
onChange: (value: T) => void;
options: readonly T[];
warningOptions?: readonly T[];
value: T;
}) {
return (
{options.map((option) => (
onChange(option)}>
{labels[option]}
{warningOptions.includes(option) ? : null}
))}
);
}
function UnsupportedActionIcon() {
return (
);
}
function WorkflowNode({
description,
disabled,
icon,
label,
number,
onAddAfter,
onClick,
onRemove,
selected,
showTitle = true,
subtitle,
terminal,
title,
}: {
description: string;
disabled?: boolean;
icon?: React.ReactNode;
label: string;
number?: number;
onAddAfter: (action: ActionType) => void;
onClick: () => void;
onRemove?: () => void;
selected: boolean;
showTitle?: boolean;
subtitle?: string;
terminal: boolean;
title: string;
}) {
const isNumbered = number !== undefined;
const [addMenuOpen, setAddMenuOpen] = React.useState(false);
return (
{onRemove ? (
) : null}
{terminal ? null : (
)}
{ACTION_TYPES.map((action) => (
onAddAfter(action)}
>
{ACTION_LABELS[action]}
{UNSUPPORTED_ACTION_TYPES.includes(action) ? (
) : null}
))}
);
}
export function WorkflowFormBuilder({
channels,
disabled,
footerLeadingContainer,
mode,
onChange,
parseError,
scopeField,
yaml,
workflowChannelId,
}: WorkflowFormBuilderProps) {
// Parse once on mount instead of calling yamlToFormState three times
const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null);
const [formState, setFormState] = React.useState(
initialParseRef.current?.ok
? initialParseRef.current.state
: DEFAULT_FORM_STATE,
);
const [selectedNode, setSelectedNode] = React.useState({
type: "trigger",
});
const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1);
const shouldReduceMotion = useReducedMotion();
const previousModeRef = React.useRef(mode);
const updateFormState = React.useCallback(
(next: WorkflowFormState) => {
setFormState(next);
onChange(formStateToYaml(next));
},
[onChange],
);
React.useEffect(() => {
if (previousModeRef.current === mode) return;
previousModeRef.current = mode;
if (mode === "yaml") {
setSelectedNode(null);
return;
}
const result = yamlToFormState(yaml);
if (result.ok) setFormState(result.state);
setSelectedNode({ type: "trigger" });
}, [mode, yaml]);
const selectNode = React.useCallback(
(nextNode: Exclude) => {
if (selectedNode) {
const currentPosition = nodePosition(selectedNode);
const nextPosition = nodePosition(nextNode);
if (nextPosition !== currentPosition) {
setSelectionDirection(nextPosition < currentPosition ? -1 : 1);
}
}
setSelectedNode(nextNode);
},
[selectedNode],
);
const insertStep = React.useCallback(
(index: number, action: ActionType) => {
const nextSteps = [...formState.steps];
const newStep: StepFormState = {
id: nextStepId(formState.steps),
action,
};
if (action === "call_webhook") {
newStep.method = "POST";
}
nextSteps.splice(index, 0, newStep);
updateFormState({
...formState,
steps: nextSteps,
});
selectNode({ type: "step", index });
},
[formState, selectNode, updateFormState],
);
const removeStep = React.useCallback(
(index: number) => {
updateFormState({
...formState,
steps: formState.steps.filter((_, i) => i !== index),
});
if (selectedNode?.type !== "step") return;
if (selectedNode.index === index) {
setSelectionDirection(-1);
setSelectedNode(
index === 0
? { type: "trigger" }
: { type: "step", index: index - 1 },
);
} else if (selectedNode.index > index) {
setSelectionDirection(-1);
setSelectedNode({ type: "step", index: selectedNode.index - 1 });
}
},
[formState, selectedNode, updateFormState],
);
const updateStep = React.useCallback(
(index: number, step: StepFormState) => {
const next = [...formState.steps];
next[index] = step;
updateFormState({ ...formState, steps: next });
},
[formState, updateFormState],
);
const selectedStep =
selectedNode?.type === "step"
? formState.steps[selectedNode.index]
: undefined;
return (
<>
{parseError ? (
Cannot switch to form view: {parseError}
) : null}
{mode === "yaml" ? (
) : (
{scopeField ?
{scopeField}
: null}
}
label={`Trigger: ${TRIGGER_LABELS[formState.trigger.on]}`}
onAddAfter={(action) => insertStep(0, action)}
onClick={() => selectNode({ type: "trigger" })}
selected={selectedNode?.type === "trigger"}
terminal={formState.steps.length === 0}
title="Trigger"
/>
{formState.steps.map((step, index) => {
const stepName = step.name?.trim();
const actionLabel = ACTION_LABELS[step.action];
const nodeTitle = stepName || actionLabel;
return (
insertStep(index + 1, action)}
onClick={() => selectNode({ type: "step", index })}
onRemove={() => removeStep(index)}
selected={
selectedNode?.type === "step" &&
selectedNode.index === index
}
showTitle={false}
subtitle={stepName ? actionLabel : undefined}
terminal={index === formState.steps.length - 1}
title={`Step ${index + 1}`}
/>
);
})}
{selectedNode ? (
{selectedNode.type === "trigger"
? "Trigger"
: `Step ${selectedNode.index + 1}`}
{selectedNode.type === "trigger" ? (
updateFormState({
...formState,
trigger:
triggerType === "schedule"
? defaultScheduleTrigger()
: { on: triggerType },
})
}
options={TRIGGER_TYPES}
value={formState.trigger.on}
/>
) : selectedStep ? (
{
const next = { ...selectedStep, action };
if (action === "call_webhook" && !next.method) {
next.method = "POST";
}
updateStep(selectedNode.index, next);
}}
options={ACTION_TYPES}
warningOptions={UNSUPPORTED_ACTION_TYPES}
value={selectedStep.action}
/>
) : null}
{selectedNode.type === "step" && selectedStep ? (
) : null}
{selectedNode.type === "trigger" ? (
updateFormState({ ...formState, trigger })
}
trigger={formState.trigger}
/>
) : selectedStep ? (
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}
) : null}
)}
{mode === "form" && footerLeadingContainer
? createPortal(
updateFormState({ ...formState, enabled: checked })
}
/>
,
footerLeadingContainer,
)
: null}
>
);
}