workflow builder, first edition
This commit is contained in:
@@ -68,6 +68,12 @@ interface ParamSchemaFormProps {
|
||||
* at enforcement time rather than set to literal values.
|
||||
*/
|
||||
allowTemplates?: boolean;
|
||||
/**
|
||||
* When true, hides the amber "Template expressions" info banner at the top
|
||||
* while still enabling template-mode inputs. Useful when template syntax is
|
||||
* supported but the contextual hint (rule-specific namespaces) doesn't apply.
|
||||
*/
|
||||
hideTemplateHint?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +146,7 @@ export default function ParamSchemaForm({
|
||||
disabled = false,
|
||||
className = "",
|
||||
allowTemplates = false,
|
||||
hideTemplateHint = false,
|
||||
}: ParamSchemaFormProps) {
|
||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -450,7 +457,7 @@ export default function ParamSchemaForm({
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{allowTemplates && (
|
||||
{allowTemplates && !hideTemplateHint && (
|
||||
<div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-xs text-amber-800">
|
||||
<span className="font-semibold">Template expressions</span> are
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GripVertical,
|
||||
ArrowRight,
|
||||
Palette,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
WorkflowTask,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
import {
|
||||
PRESET_WHEN,
|
||||
PRESET_LABELS,
|
||||
PRESET_COLORS,
|
||||
classifyTransitionWhen,
|
||||
transitionLabel,
|
||||
} from "@/types/workflow";
|
||||
@@ -26,6 +28,7 @@ import ParamSchemaForm, {
|
||||
extractProperties,
|
||||
type ParamSchema,
|
||||
} from "@/components/common/ParamSchemaForm";
|
||||
import { useAction } from "@/hooks/useActions";
|
||||
|
||||
/** Preset color swatches for quick transition color selection */
|
||||
const TRANSITION_COLOR_SWATCHES = [
|
||||
@@ -67,6 +70,10 @@ export default function TaskInspector({
|
||||
onClose,
|
||||
highlightTransitionIndex,
|
||||
}: TaskInspectorProps) {
|
||||
// Fetch full action details (including param_schema) on demand
|
||||
const { data: actionDetail, isLoading: actionLoading } = useAction(
|
||||
task.action || "",
|
||||
);
|
||||
const transitionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const [flashIndex, setFlashIndex] = useState<number | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
@@ -187,6 +194,8 @@ export default function TaskInspector({
|
||||
if (preset) {
|
||||
const whenExpr = PRESET_WHEN[preset];
|
||||
if (whenExpr) newTransition.when = whenExpr;
|
||||
newTransition.label = PRESET_LABELS[preset];
|
||||
newTransition.color = PRESET_COLORS[preset];
|
||||
}
|
||||
next.push(newTransition);
|
||||
update({ next });
|
||||
@@ -263,11 +272,12 @@ export default function TaskInspector({
|
||||
[task.next, update],
|
||||
);
|
||||
|
||||
// Get the selected action's param schema
|
||||
// Get the selected action's param schema from fetched action detail
|
||||
const selectedAction = availableActions.find((a) => a.ref === task.action);
|
||||
const fetchedAction = actionDetail?.data;
|
||||
const actionParamSchema: ParamSchema = useMemo(
|
||||
() => (selectedAction?.param_schema as ParamSchema) || {},
|
||||
[selectedAction?.param_schema],
|
||||
() => (fetchedAction?.param_schema as ParamSchema) || {},
|
||||
[fetchedAction?.param_schema],
|
||||
);
|
||||
const schemaProperties = useMemo(
|
||||
() => extractProperties(actionParamSchema),
|
||||
@@ -275,30 +285,6 @@ export default function TaskInspector({
|
||||
);
|
||||
const hasSchema = Object.keys(schemaProperties).length > 0;
|
||||
|
||||
// Separate task inputs into schema-driven and extra custom params
|
||||
const schemaKeys = useMemo(
|
||||
() => new Set(Object.keys(schemaProperties)),
|
||||
[schemaProperties],
|
||||
);
|
||||
const schemaValues = useMemo(() => {
|
||||
const vals: Record<string, unknown> = {};
|
||||
for (const key of schemaKeys) {
|
||||
if (task.input[key] !== undefined) {
|
||||
vals[key] = task.input[key];
|
||||
}
|
||||
}
|
||||
return vals;
|
||||
}, [task.input, schemaKeys]);
|
||||
const extraParams = useMemo(() => {
|
||||
const extras: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(task.input)) {
|
||||
if (!schemaKeys.has(key)) {
|
||||
extras[key] = value;
|
||||
}
|
||||
}
|
||||
return extras;
|
||||
}, [task.input, schemaKeys]);
|
||||
|
||||
const otherTaskNames = allTaskNames.filter((n) => n !== task.name);
|
||||
|
||||
return (
|
||||
@@ -375,14 +361,20 @@ export default function TaskInspector({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedAction?.description && (
|
||||
{(fetchedAction?.description || selectedAction?.description) && (
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
{selectedAction.description}
|
||||
{fetchedAction?.description || selectedAction?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Parameters — schema-driven form */}
|
||||
{task.action && actionLoading && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 py-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Loading parameters…
|
||||
</div>
|
||||
)}
|
||||
{hasSchema && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
||||
@@ -390,130 +382,21 @@ export default function TaskInspector({
|
||||
</label>
|
||||
<ParamSchemaForm
|
||||
schema={actionParamSchema}
|
||||
values={schemaValues}
|
||||
values={task.input}
|
||||
onChange={(newValues) => {
|
||||
// Merge schema-driven values back with extra params
|
||||
update({ input: { ...newValues, ...extraParams } });
|
||||
update({ input: newValues });
|
||||
}}
|
||||
allowTemplates
|
||||
hideTemplateHint
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra custom parameters (not in schema) */}
|
||||
{hasSchema && Object.keys(extraParams).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Custom Parameters
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(extraParams).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="border border-gray-200 rounded p-2 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-mono text-xs font-medium text-gray-800">
|
||||
{key}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newInput = { ...task.input };
|
||||
delete newInput[key];
|
||||
update({ input: newInput });
|
||||
}}
|
||||
className="p-0.5 text-gray-400 hover:text-red-500"
|
||||
title="Remove parameter"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value ?? "")
|
||||
}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
input: { ...task.input, [key]: e.target.value },
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded text-xs font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="{{ parameters.value }} or literal"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{task.action && !actionLoading && !hasSchema && (
|
||||
<p className="text-[10px] text-gray-400 italic">
|
||||
This action has no declared parameters.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* No schema — free-form input editing */}
|
||||
{!hasSchema && task.action && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Input Parameters
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(task.input).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="border border-gray-200 rounded p-2 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-mono text-xs font-medium text-gray-800">
|
||||
{key}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newInput = { ...task.input };
|
||||
delete newInput[key];
|
||||
update({ input: newInput });
|
||||
}}
|
||||
className="p-0.5 text-gray-400 hover:text-red-500"
|
||||
title="Remove parameter"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value ?? "")
|
||||
}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
input: { ...task.input, [key]: e.target.value },
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded text-xs font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="{{ parameters.value }} or literal"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add custom parameter button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = prompt("Parameter name:");
|
||||
if (key && key.trim()) {
|
||||
update({
|
||||
input: { ...task.input, [key.trim()]: "" },
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add custom parameter
|
||||
</button>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -850,48 +733,50 @@ export default function TaskInspector({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{task.with_items && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={task.batch_size || ""}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
batch_size: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Process all at once"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Concurrency
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={task.concurrency || ""}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
concurrency: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="No limit"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
className={`block text-xs font-medium mb-1 ${localWithItems ? "text-gray-700" : "text-gray-400"}`}
|
||||
>
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={task.batch_size || ""}
|
||||
disabled={!localWithItems}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
batch_size: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
placeholder="Process all at once"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={`block text-xs font-medium mb-1 ${localWithItems ? "text-gray-700" : "text-gray-400"}`}
|
||||
>
|
||||
Concurrency
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={task.concurrency || ""}
|
||||
disabled={!localWithItems}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
concurrency: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
placeholder="No limit"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { Trash2, Settings, GripVertical } from "lucide-react";
|
||||
import { Trash2, GripVertical, Play, Octagon } from "lucide-react";
|
||||
import type { WorkflowTask, TransitionPreset } from "@/types/workflow";
|
||||
import {
|
||||
PRESET_LABELS,
|
||||
@@ -12,6 +12,7 @@ export type { TransitionPreset };
|
||||
interface TaskNodeProps {
|
||||
task: WorkflowTask;
|
||||
isSelected: boolean;
|
||||
isStartNode: boolean;
|
||||
allTaskNames: string[];
|
||||
onSelect: (taskId: string) => void;
|
||||
onDelete: (taskId: string) => void;
|
||||
@@ -95,6 +96,7 @@ function transitionSummary(task: WorkflowTask): string | null {
|
||||
function TaskNodeInner({
|
||||
task,
|
||||
isSelected,
|
||||
isStartNode,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onPositionChange,
|
||||
@@ -107,7 +109,6 @@ function TaskNodeInner({
|
||||
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
|
||||
null,
|
||||
);
|
||||
const [isInputHandleHovered, setIsInputHandleHovered] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -147,9 +148,9 @@ function TaskNodeInner({
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (connectingFrom && connectingFrom.taskId !== task.id) {
|
||||
if (connectingFrom) {
|
||||
onCompleteConnection(task.id);
|
||||
} else if (!connectingFrom) {
|
||||
} else {
|
||||
onSelect(task.id);
|
||||
}
|
||||
},
|
||||
@@ -173,18 +174,7 @@ function TaskNodeInner({
|
||||
[task.id, onStartConnection],
|
||||
);
|
||||
|
||||
const handleInputHandleMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (connectingFrom && connectingFrom.taskId !== task.id) {
|
||||
onCompleteConnection(task.id);
|
||||
}
|
||||
},
|
||||
[task.id, connectingFrom, onCompleteConnection],
|
||||
);
|
||||
|
||||
const isConnectionTarget =
|
||||
connectingFrom !== null && connectingFrom.taskId !== task.id;
|
||||
const isConnectionTarget = connectingFrom !== null;
|
||||
|
||||
const borderColor = isSelected
|
||||
? "border-blue-500 ring-2 ring-blue-200"
|
||||
@@ -195,6 +185,12 @@ function TaskNodeInner({
|
||||
const hasAction = task.action && task.action.length > 0;
|
||||
const summary = transitionSummary(task);
|
||||
|
||||
// A stop node has no outgoing transitions to other tasks
|
||||
const isStopNode =
|
||||
!task.next ||
|
||||
task.next.length === 0 ||
|
||||
task.next.every((t) => !t.do || t.do.length === 0);
|
||||
|
||||
// Count custom transitions (those not matching any preset)
|
||||
const customTransitionCount = (task.next || []).filter((t) => {
|
||||
const ct = classifyTransitionWhen(t.when);
|
||||
@@ -212,55 +208,35 @@ function TaskNodeInner({
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
onMouseUp={(e) => {
|
||||
if (connectingFrom) {
|
||||
e.stopPropagation();
|
||||
onCompleteConnection(task.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Input handle (top center) — drop target */}
|
||||
<div
|
||||
data-handle
|
||||
className="absolute left-1/2 -translate-x-1/2 -top-[7px] z-20"
|
||||
onMouseUp={handleInputHandleMouseUp}
|
||||
onMouseEnter={() => setIsInputHandleHovered(true)}
|
||||
onMouseLeave={() => setIsInputHandleHovered(false)}
|
||||
>
|
||||
<div
|
||||
className="transition-all duration-150 rounded-full border-2 border-white shadow-sm"
|
||||
style={{
|
||||
width:
|
||||
isConnectionTarget && isInputHandleHovered
|
||||
? 16
|
||||
: isConnectionTarget
|
||||
? 14
|
||||
: 10,
|
||||
height:
|
||||
isConnectionTarget && isInputHandleHovered
|
||||
? 16
|
||||
: isConnectionTarget
|
||||
? 14
|
||||
: 10,
|
||||
backgroundColor:
|
||||
isConnectionTarget && isInputHandleHovered
|
||||
? "#8b5cf6"
|
||||
: isConnectionTarget
|
||||
? "#a78bfa"
|
||||
: "#9ca3af",
|
||||
boxShadow:
|
||||
isConnectionTarget && isInputHandleHovered
|
||||
? "0 0 0 4px rgba(139, 92, 246, 0.3), 0 1px 3px rgba(0,0,0,0.2)"
|
||||
: isConnectionTarget
|
||||
? "0 0 0 3px rgba(167, 139, 250, 0.3), 0 1px 2px rgba(0,0,0,0.15)"
|
||||
: "0 1px 2px rgba(0,0,0,0.1)",
|
||||
cursor: isConnectionTarget ? "pointer" : "default",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`bg-white rounded-lg border-2 shadow-sm transition-colors ${borderColor}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-t-md bg-blue-500 bg-opacity-10 border-b border-gray-100">
|
||||
<GripVertical className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-t-md border-b ${
|
||||
isStartNode
|
||||
? "bg-green-500 bg-opacity-15 border-green-200"
|
||||
: "bg-blue-500 bg-opacity-10 border-gray-100"
|
||||
}`}
|
||||
>
|
||||
{isStartNode ? (
|
||||
<Play className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<GripVertical className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-xs text-gray-900 truncate">
|
||||
<div
|
||||
className={`font-semibold text-xs truncate ${
|
||||
isStartNode ? "text-green-900" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{task.name}
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,35 +298,40 @@ function TaskNodeInner({
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center justify-end px-2 py-1.5 border-t border-gray-100 bg-gray-50 rounded-b-md">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
data-action-button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(task.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-blue-100 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Configure task"
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
data-action-button
|
||||
onClick={handleDelete}
|
||||
className="p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Delete task"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center px-2 py-1.5 border-t rounded-b-md ${
|
||||
isStopNode
|
||||
? "border-red-200 bg-red-50"
|
||||
: "border-gray-100 bg-gray-50"
|
||||
} ${isStopNode ? "justify-between" : "justify-end"}`}
|
||||
>
|
||||
{isStopNode && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Octagon
|
||||
className="w-3.5 h-3.5 text-red-500"
|
||||
fill="currentColor"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
<span className="text-[10px] font-medium text-red-600">Stop</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
data-action-button
|
||||
onClick={handleDelete}
|
||||
className="p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Delete task"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection target overlay */}
|
||||
{isConnectionTarget && (
|
||||
<div className="absolute inset-0 rounded-lg bg-purple-100 bg-opacity-20 pointer-events-none flex items-center justify-center">
|
||||
<div className="text-xs font-medium text-purple-600 bg-white px-2 py-1 rounded shadow-sm">
|
||||
Drop to connect
|
||||
{connectingFrom?.taskId === task.id
|
||||
? "Drop to self-loop"
|
||||
: "Drop to connect"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,15 +3,12 @@ import TaskNode from "./TaskNode";
|
||||
import type { TransitionPreset } from "./TaskNode";
|
||||
import WorkflowEdges from "./WorkflowEdges";
|
||||
import type { EdgeHoverInfo } from "./WorkflowEdges";
|
||||
import type {
|
||||
WorkflowTask,
|
||||
PaletteAction,
|
||||
WorkflowEdge,
|
||||
} from "@/types/workflow";
|
||||
import type { WorkflowTask, WorkflowEdge } from "@/types/workflow";
|
||||
import {
|
||||
deriveEdges,
|
||||
generateUniqueTaskName,
|
||||
generateTaskId,
|
||||
findStartingTaskIds,
|
||||
PRESET_LABELS,
|
||||
} from "@/types/workflow";
|
||||
import { Plus } from "lucide-react";
|
||||
@@ -19,7 +16,6 @@ import { Plus } from "lucide-react";
|
||||
interface WorkflowCanvasProps {
|
||||
tasks: WorkflowTask[];
|
||||
selectedTaskId: string | null;
|
||||
availableActions: PaletteAction[];
|
||||
onSelectTask: (taskId: string | null) => void;
|
||||
onUpdateTask: (taskId: string, updates: Partial<WorkflowTask>) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
@@ -29,7 +25,7 @@ interface WorkflowCanvasProps {
|
||||
preset: TransitionPreset,
|
||||
toTaskName: string,
|
||||
) => void;
|
||||
onEdgeHover?: (info: EdgeHoverInfo | null) => void;
|
||||
onEdgeClick?: (info: EdgeHoverInfo | null) => void;
|
||||
}
|
||||
|
||||
/** Label color mapping for the connecting banner */
|
||||
@@ -47,7 +43,7 @@ export default function WorkflowCanvas({
|
||||
onDeleteTask,
|
||||
onAddTask,
|
||||
onSetConnection,
|
||||
onEdgeHover,
|
||||
onEdgeClick,
|
||||
}: WorkflowCanvasProps) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [connectingFrom, setConnectingFrom] = useState<{
|
||||
@@ -63,6 +59,8 @@ export default function WorkflowCanvas({
|
||||
|
||||
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
|
||||
|
||||
const startingTaskIds = useMemo(() => findStartingTaskIds(tasks), [tasks]);
|
||||
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only deselect if clicking the canvas background
|
||||
@@ -213,7 +211,7 @@ export default function WorkflowCanvas({
|
||||
tasks={tasks}
|
||||
connectingFrom={connectingFrom}
|
||||
mousePosition={mousePosition}
|
||||
onEdgeHover={onEdgeHover}
|
||||
onEdgeClick={onEdgeClick}
|
||||
/>
|
||||
|
||||
{/* Task nodes */}
|
||||
@@ -222,6 +220,7 @@ export default function WorkflowCanvas({
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={task.id === selectedTaskId}
|
||||
isStartNode={startingTaskIds.has(task.id)}
|
||||
allTaskNames={allTaskNames}
|
||||
onSelect={onSelectTask}
|
||||
onDelete={onDeleteTask}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow";
|
||||
import { PRESET_COLORS } from "@/types/workflow";
|
||||
import type { TransitionPreset } from "./TaskNode";
|
||||
|
||||
export interface EdgeHoverInfo {
|
||||
@@ -18,8 +19,8 @@ interface WorkflowEdgesProps {
|
||||
connectingFrom?: { taskId: string; preset: TransitionPreset } | null;
|
||||
/** Mouse position for drawing the preview connection line */
|
||||
mousePosition?: { x: number; y: number } | null;
|
||||
/** Called when the mouse enters/leaves an edge hit area */
|
||||
onEdgeHover?: (info: EdgeHoverInfo | null) => void;
|
||||
/** Called when an edge is clicked */
|
||||
onEdgeClick?: (info: EdgeHoverInfo | null) => void;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 240;
|
||||
@@ -40,13 +41,6 @@ const EDGE_DASH: Record<EdgeType, string> = {
|
||||
custom: "8,4,2,4",
|
||||
};
|
||||
|
||||
/** Map presets to edge colors for the preview line */
|
||||
const PRESET_COLORS: Record<TransitionPreset, string> = {
|
||||
succeeded: EDGE_COLORS.success,
|
||||
failed: EDGE_COLORS.failure,
|
||||
always: EDGE_COLORS.complete,
|
||||
};
|
||||
|
||||
/** Calculate the center-bottom of a task node */
|
||||
function getNodeBottomCenter(
|
||||
task: WorkflowTask,
|
||||
@@ -89,14 +83,31 @@ function getNodeRightCenter(
|
||||
|
||||
/**
|
||||
* Determine the best connection points between two nodes.
|
||||
* Returns the start and end points for the edge.
|
||||
* Returns the start and end points for the edge, plus whether this is a
|
||||
* self-loop (from === to).
|
||||
*/
|
||||
function getBestConnectionPoints(
|
||||
fromTask: WorkflowTask,
|
||||
toTask: WorkflowTask,
|
||||
nodeWidth: number,
|
||||
nodeHeight: number,
|
||||
): { start: { x: number; y: number }; end: { x: number; y: number } } {
|
||||
): {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
selfLoop?: boolean;
|
||||
} {
|
||||
// Self-loop: start from right side, end at top-right area
|
||||
if (fromTask.id === toTask.id) {
|
||||
return {
|
||||
start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight),
|
||||
end: {
|
||||
x: fromTask.position.x + nodeWidth * 0.75,
|
||||
y: fromTask.position.y,
|
||||
},
|
||||
selfLoop: true,
|
||||
};
|
||||
}
|
||||
|
||||
const fromCenter = {
|
||||
x: fromTask.position.x + nodeWidth / 2,
|
||||
y: fromTask.position.y + nodeHeight / 2,
|
||||
@@ -117,10 +128,15 @@ function getBestConnectionPoints(
|
||||
};
|
||||
}
|
||||
|
||||
// If the target is mostly above the source, use top→bottom
|
||||
// If the target is mostly above the source, use a side edge (never top).
|
||||
// Pick the side closer to the target, and arrive at the target's bottom.
|
||||
if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) {
|
||||
const start =
|
||||
dx >= 0
|
||||
? getNodeRightCenter(fromTask, nodeWidth, nodeHeight)
|
||||
: getNodeLeftCenter(fromTask, nodeHeight);
|
||||
return {
|
||||
start: getNodeTopCenter(fromTask, nodeWidth),
|
||||
start,
|
||||
end: getNodeBottomCenter(toTask, nodeWidth, nodeHeight),
|
||||
};
|
||||
}
|
||||
@@ -140,6 +156,21 @@ function getBestConnectionPoints(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SVG path for a self-loop: exits from the right side, arcs out
|
||||
* to the right and upward, then curves back to the top of the node.
|
||||
*/
|
||||
function buildSelfLoopPath(
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
): string {
|
||||
const loopOffset = 50;
|
||||
// Control points: push out to the right and then curve up and back
|
||||
const cp1 = { x: start.x + loopOffset, y: start.y - 20 };
|
||||
const cp2 = { x: end.x + loopOffset, y: end.y - 40 };
|
||||
return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SVG path string for a curved edge between two points.
|
||||
* Uses a cubic bezier curve.
|
||||
@@ -179,7 +210,7 @@ function WorkflowEdgesInner({
|
||||
nodeHeight = NODE_HEIGHT,
|
||||
connectingFrom,
|
||||
mousePosition,
|
||||
onEdgeHover,
|
||||
onEdgeClick,
|
||||
}: WorkflowEdgesProps) {
|
||||
const taskMap = useMemo(() => {
|
||||
const map = new Map<string, WorkflowTask>();
|
||||
@@ -211,21 +242,25 @@ function WorkflowEdgesInner({
|
||||
const toTask = taskMap.get(edge.to);
|
||||
if (!fromTask || !toTask) return null;
|
||||
|
||||
const { start, end } = getBestConnectionPoints(
|
||||
const { start, end, selfLoop } = getBestConnectionPoints(
|
||||
fromTask,
|
||||
toTask,
|
||||
nodeWidth,
|
||||
nodeHeight,
|
||||
);
|
||||
|
||||
const pathD = buildCurvePath(start, end);
|
||||
const pathD = selfLoop
|
||||
? buildSelfLoopPath(start, end)
|
||||
: buildCurvePath(start, end);
|
||||
const color =
|
||||
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
|
||||
const dash = EDGE_DASH[edge.type] || "";
|
||||
|
||||
// Calculate label position (midpoint of curve)
|
||||
const labelX = (start.x + end.x) / 2;
|
||||
const labelY = (start.y + end.y) / 2 - 8;
|
||||
// Calculate label position — offset to the right for self-loops
|
||||
const labelX = selfLoop ? start.x + 50 : (start.x + end.x) / 2;
|
||||
const labelY = selfLoop
|
||||
? (start.y + end.y) / 2 - 20
|
||||
: (start.y + end.y) / 2 - 8;
|
||||
|
||||
// Measure approximate label width
|
||||
const labelText = edge.label || "";
|
||||
@@ -254,13 +289,12 @@ function WorkflowEdgesInner({
|
||||
stroke="transparent"
|
||||
strokeWidth={12}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() =>
|
||||
onEdgeHover?.({
|
||||
onClick={() =>
|
||||
onEdgeClick?.({
|
||||
taskId: edge.from,
|
||||
transitionIndex: edge.transitionIndex,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => onEdgeHover?.(null)}
|
||||
/>
|
||||
{/* Label */}
|
||||
{edge.label && (
|
||||
@@ -295,7 +329,7 @@ function WorkflowEdgesInner({
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [edges, taskMap, nodeWidth, nodeHeight, onEdgeHover]);
|
||||
}, [edges, taskMap, nodeWidth, nodeHeight, onEdgeClick]);
|
||||
|
||||
// Preview line when connecting
|
||||
const previewLine = useMemo(() => {
|
||||
|
||||
Reference in New Issue
Block a user