workflow builder, first edition

This commit is contained in:
2026-02-23 22:51:49 -06:00
parent 53a3fbb6b1
commit 4c81ba1de8
7 changed files with 583 additions and 359 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useMemo, useRef } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQueries } from "@tanstack/react-query";
import {
ArrowLeft,
Save,
@@ -7,6 +8,7 @@ import {
FileCode,
Code,
LayoutDashboard,
X,
} from "lucide-react";
import yaml from "js-yaml";
import type { WorkflowYamlDefinition } from "@/types/workflow";
@@ -15,6 +17,7 @@ import WorkflowCanvas from "@/components/workflows/WorkflowCanvas";
import type { EdgeHoverInfo } from "@/components/workflows/WorkflowEdges";
import TaskInspector from "@/components/workflows/TaskInspector";
import { useActions } from "@/hooks/useActions";
import { ActionsService } from "@/api";
import { usePacks } from "@/hooks/usePacks";
import { useWorkflow } from "@/hooks/useWorkflows";
import {
@@ -35,6 +38,8 @@ import {
validateWorkflow,
addTransitionTarget,
removeTaskFromTransitions,
renameTaskInTransitions,
findStartingTaskIds,
} from "@/types/workflow";
const INITIAL_STATE: WorkflowBuilderState = {
@@ -82,28 +87,20 @@ export default function WorkflowBuilderPage() {
taskId: string;
transitionIndex: number;
} | null>(null);
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const handleEdgeHover = useCallback(
// Start-node warning toast state
const [startWarningVisible, setStartWarningVisible] = useState(false);
const [startWarningDismissed, setStartWarningDismissed] = useState(false);
const [showSaveConfirm, setShowSaveConfirm] = useState(false);
const [prevWarningKey, setPrevWarningKey] = useState<string | null>(null);
const [justInitialized, setJustInitialized] = useState(false);
const handleEdgeClick = useCallback(
(info: EdgeHoverInfo | null) => {
// Clear any pending auto-clear timeout
if (highlightTimeoutRef.current) {
clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
}
if (info) {
// Select the source task so TaskInspector opens for it
setSelectedTaskId(info.taskId);
setHighlightedTransition(info);
// Auto-clear highlight after 2 seconds so the flash animation plays once
highlightTimeoutRef.current = setTimeout(() => {
setHighlightedTransition(null);
highlightTimeoutRef.current = null;
}, 2000);
} else {
setHighlightedTransition(null);
}
@@ -138,6 +135,7 @@ export default function WorkflowBuilderPage() {
);
setState(builderState);
setInitialized(true);
setJustInitialized(true);
}
}
@@ -149,8 +147,6 @@ export default function WorkflowBuilderPage() {
label: string;
description?: string;
pack_ref: string;
param_schema?: Record<string, unknown> | null;
out_schema?: Record<string, unknown> | null;
}>;
return actions.map((a) => ({
id: a.id,
@@ -158,19 +154,42 @@ export default function WorkflowBuilderPage() {
label: a.label,
description: a.description || "",
pack_ref: a.pack_ref,
param_schema: a.param_schema || null,
out_schema: a.out_schema || null,
}));
}, [actionsData]);
// Build action schema map for stripping defaults during serialization
// Fetch full action details for every unique action ref used in the workflow.
// React Query caches each response, so repeated refs don't cause extra requests.
const uniqueActionRefs = useMemo(() => {
const refs = new Set<string>();
for (const task of state.tasks) {
if (task.action) refs.add(task.action);
}
return [...refs];
}, [state.tasks]);
const actionDetailQueries = useQueries({
queries: uniqueActionRefs.map((ref) => ({
queryKey: ["actions", ref],
queryFn: () => ActionsService.getAction({ ref }),
staleTime: 30_000,
enabled: !!ref,
})),
});
// Build action schema map from individually-fetched action details
const actionSchemaMap = useMemo(() => {
const map = new Map<string, Record<string, unknown> | null>();
for (const action of paletteActions) {
map.set(action.ref, action.param_schema);
for (let i = 0; i < uniqueActionRefs.length; i++) {
const query = actionDetailQueries[i];
if (query.data?.data) {
map.set(
uniqueActionRefs[i],
(query.data.data.param_schema as Record<string, unknown>) || null,
);
}
}
return map;
}, [paletteActions]);
}, [uniqueActionRefs, actionDetailQueries]);
const packs = useMemo(() => {
return (packsData?.data || []) as Array<{
@@ -190,6 +209,65 @@ export default function WorkflowBuilderPage() {
[state.tasks],
);
const startingTaskIds = useMemo(
() => findStartingTaskIds(state.tasks),
[state.tasks],
);
const startNodeWarning = useMemo(() => {
if (state.tasks.length === 0) return null;
const count = startingTaskIds.size;
if (count === 0)
return {
level: "error" as const,
message:
"No starting tasks found — every task is a target of another transition, so the workflow has no entry point.",
};
if (count > 1)
return {
level: "warn" as const,
message: `${count} starting tasks found (${state.tasks
.filter((t) => startingTaskIds.has(t.id))
.map((t) => `"${t.name}"`)
.join(", ")}). Workflows typically have a single entry point.`,
};
return null;
}, [state.tasks, startingTaskIds]);
// Render-phase state adjustment: detect warning key changes for immediate
// show/hide without refs or synchronous setState inside effects.
const warningKey = startNodeWarning
? `${startNodeWarning.level}:${startingTaskIds.size}`
: null;
if (warningKey !== prevWarningKey) {
setPrevWarningKey(warningKey);
if (!warningKey) {
// Condition resolved → immediately hide and allow future warnings
if (startWarningVisible) setStartWarningVisible(false);
if (startWarningDismissed) setStartWarningDismissed(false);
} else if (justInitialized) {
// Loaded from persistent storage with a problem → show immediately
if (!startWarningVisible) setStartWarningVisible(true);
setJustInitialized(false);
}
}
// Debounce timer: starts a 15-second countdown for non-initial warning
// appearances. The timer callback is async so it satisfies React's rules.
useEffect(() => {
if (!startNodeWarning || startWarningVisible || startWarningDismissed) {
return;
}
const timer = setTimeout(() => {
setStartWarningVisible(true);
}, 15_000);
return () => clearTimeout(timer);
}, [startNodeWarning, startWarningVisible, startWarningDismissed]);
// State updaters
const updateMetadata = useCallback(
(updates: Partial<WorkflowBuilderState>) => {
@@ -214,20 +292,11 @@ export default function WorkflowBuilderPage() {
}
}
// Pre-populate input from action's param_schema
const input: Record<string, unknown> = {};
if (action.param_schema && typeof action.param_schema === "object") {
for (const [key, param] of Object.entries(action.param_schema)) {
const meta = param as { default?: unknown };
input[key] = meta?.default !== undefined ? meta.default : "";
}
}
const newTask: WorkflowTask = {
id: generateTaskId(),
name,
action: action.ref,
input,
input: {},
position: {
x: 300,
y: state.tasks.length === 0 ? 60 : maxY + 160,
@@ -254,12 +323,49 @@ export default function WorkflowBuilderPage() {
const handleUpdateTask = useCallback(
(taskId: string, updates: Partial<WorkflowTask>) => {
setState((prev) => ({
...prev,
tasks: prev.tasks.map((t) =>
t.id === taskId ? { ...t, ...updates } : t,
),
}));
setState((prev) => {
// Detect a name change so we can propagate it to transitions
const oldName =
updates.name !== undefined
? prev.tasks.find((t) => t.id === taskId)?.name
: undefined;
const newName = updates.name;
const isRename =
oldName !== undefined && newName !== undefined && oldName !== newName;
return {
...prev,
tasks: prev.tasks.map((t) => {
if (t.id === taskId) {
// Apply the updates, then also fix self-referencing transitions
const merged = { ...t, ...updates };
if (isRename) {
const updatedNext = renameTaskInTransitions(
merged.next,
oldName,
newName,
);
if (updatedNext !== merged.next) {
return { ...merged, next: updatedNext };
}
}
return merged;
}
// Update transition `do` lists that reference the old name
if (isRename) {
const updatedNext = renameTaskInTransitions(
t.next,
oldName,
newName,
);
if (updatedNext !== t.next) {
return { ...t, next: updatedNext };
}
}
return t;
}),
};
});
setSaveSuccess(false);
},
[],
@@ -310,7 +416,7 @@ export default function WorkflowBuilderPage() {
[],
);
const handleSave = useCallback(async () => {
const doSave = useCallback(async () => {
// Validate
const errors = validateWorkflow(state);
setValidationErrors(errors);
@@ -381,6 +487,18 @@ export default function WorkflowBuilderPage() {
actionSchemaMap,
]);
const handleSave = useCallback(() => {
// If there's a start-node problem, show the toast immediately and
// require confirmation before saving
if (startNodeWarning) {
setStartWarningVisible(true);
setStartWarningDismissed(false);
setShowSaveConfirm(true);
return;
}
doSave();
}, [startNodeWarning, doSave]);
// YAML preview — generate proper YAML from builder state
const yamlPreview = useMemo(() => {
if (!showYamlPreview) return "";
@@ -640,13 +758,12 @@ export default function WorkflowBuilderPage() {
<WorkflowCanvas
tasks={state.tasks}
selectedTaskId={selectedTaskId}
availableActions={paletteActions}
onSelectTask={setSelectedTaskId}
onUpdateTask={handleUpdateTask}
onDeleteTask={handleDeleteTask}
onAddTask={handleAddTask}
onSetConnection={handleSetConnection}
onEdgeHover={handleEdgeHover}
onEdgeClick={handleEdgeClick}
/>
{/* Right: Task Inspector */}
@@ -667,6 +784,122 @@ export default function WorkflowBuilderPage() {
</>
)}
</div>
{/* Floating start-node warning toast */}
{startNodeWarning && startWarningVisible && (
<div
className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fade-in"
style={{
animation: "fadeInDown 0.25s ease-out both",
}}
>
<div
className={`flex items-center gap-2.5 px-4 py-2.5 rounded-lg shadow-lg border ${
startNodeWarning.level === "error"
? "bg-red-50 border-red-300 text-red-800"
: "bg-amber-50 border-amber-300 text-amber-800"
}`}
style={{ maxWidth: 520 }}
>
<AlertTriangle
className={`w-4 h-4 flex-shrink-0 ${
startNodeWarning.level === "error"
? "text-red-500"
: "text-amber-500"
}`}
/>
<p className="text-xs font-medium flex-1">
{startNodeWarning.message}
</p>
<button
onClick={() => {
setStartWarningVisible(false);
setStartWarningDismissed(true);
}}
className={`p-0.5 rounded hover:bg-black/5 flex-shrink-0 ${
startNodeWarning.level === "error"
? "text-red-400 hover:text-red-600"
: "text-amber-400 hover:text-amber-600"
}`}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation modal for saving with start-node warnings */}
{showSaveConfirm && startNodeWarning && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowSaveConfirm(false)}
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-xl border border-gray-200 p-6 max-w-md w-full mx-4">
<div className="flex items-start gap-3">
<div
className={`p-2 rounded-full flex-shrink-0 ${
startNodeWarning.level === "error"
? "bg-red-100"
: "bg-amber-100"
}`}
>
<AlertTriangle
className={`w-5 h-5 ${
startNodeWarning.level === "error"
? "text-red-600"
: "text-amber-600"
}`}
/>
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-gray-900 mb-1">
{startNodeWarning.level === "error"
? "No starting tasks"
: "Multiple starting tasks"}
</h3>
<p className="text-xs text-gray-600 mb-4">
{startNodeWarning.message} Are you sure you want to save this
workflow?
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setShowSaveConfirm(false)}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={() => {
setShowSaveConfirm(false);
doSave();
}}
className="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Save Anyway
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Inline style for fade-in animation */}
<style>{`
@keyframes fadeInDown {
from {
opacity: 0;
transform: translate(-50%, -8px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
`}</style>
</div>
);
}