workflow builder, first edition
This commit is contained in:
@@ -68,6 +68,12 @@ interface ParamSchemaFormProps {
|
|||||||
* at enforcement time rather than set to literal values.
|
* at enforcement time rather than set to literal values.
|
||||||
*/
|
*/
|
||||||
allowTemplates?: boolean;
|
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,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
allowTemplates = false,
|
allowTemplates = false,
|
||||||
|
hideTemplateHint = false,
|
||||||
}: ParamSchemaFormProps) {
|
}: ParamSchemaFormProps) {
|
||||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -450,7 +457,7 @@ export default function ParamSchemaForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-4 ${className}`}>
|
<div className={`space-y-4 ${className}`}>
|
||||||
{allowTemplates && (
|
{allowTemplates && !hideTemplateHint && (
|
||||||
<div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
<p className="text-xs text-amber-800">
|
<p className="text-xs text-amber-800">
|
||||||
<span className="font-semibold">Template expressions</span> are
|
<span className="font-semibold">Template expressions</span> are
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Palette,
|
Palette,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
WorkflowTask,
|
WorkflowTask,
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
PRESET_WHEN,
|
PRESET_WHEN,
|
||||||
PRESET_LABELS,
|
PRESET_LABELS,
|
||||||
|
PRESET_COLORS,
|
||||||
classifyTransitionWhen,
|
classifyTransitionWhen,
|
||||||
transitionLabel,
|
transitionLabel,
|
||||||
} from "@/types/workflow";
|
} from "@/types/workflow";
|
||||||
@@ -26,6 +28,7 @@ import ParamSchemaForm, {
|
|||||||
extractProperties,
|
extractProperties,
|
||||||
type ParamSchema,
|
type ParamSchema,
|
||||||
} from "@/components/common/ParamSchemaForm";
|
} from "@/components/common/ParamSchemaForm";
|
||||||
|
import { useAction } from "@/hooks/useActions";
|
||||||
|
|
||||||
/** Preset color swatches for quick transition color selection */
|
/** Preset color swatches for quick transition color selection */
|
||||||
const TRANSITION_COLOR_SWATCHES = [
|
const TRANSITION_COLOR_SWATCHES = [
|
||||||
@@ -67,6 +70,10 @@ export default function TaskInspector({
|
|||||||
onClose,
|
onClose,
|
||||||
highlightTransitionIndex,
|
highlightTransitionIndex,
|
||||||
}: TaskInspectorProps) {
|
}: 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 transitionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
const [flashIndex, setFlashIndex] = useState<number | null>(null);
|
const [flashIndex, setFlashIndex] = useState<number | null>(null);
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
@@ -187,6 +194,8 @@ export default function TaskInspector({
|
|||||||
if (preset) {
|
if (preset) {
|
||||||
const whenExpr = PRESET_WHEN[preset];
|
const whenExpr = PRESET_WHEN[preset];
|
||||||
if (whenExpr) newTransition.when = whenExpr;
|
if (whenExpr) newTransition.when = whenExpr;
|
||||||
|
newTransition.label = PRESET_LABELS[preset];
|
||||||
|
newTransition.color = PRESET_COLORS[preset];
|
||||||
}
|
}
|
||||||
next.push(newTransition);
|
next.push(newTransition);
|
||||||
update({ next });
|
update({ next });
|
||||||
@@ -263,11 +272,12 @@ export default function TaskInspector({
|
|||||||
[task.next, update],
|
[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 selectedAction = availableActions.find((a) => a.ref === task.action);
|
||||||
|
const fetchedAction = actionDetail?.data;
|
||||||
const actionParamSchema: ParamSchema = useMemo(
|
const actionParamSchema: ParamSchema = useMemo(
|
||||||
() => (selectedAction?.param_schema as ParamSchema) || {},
|
() => (fetchedAction?.param_schema as ParamSchema) || {},
|
||||||
[selectedAction?.param_schema],
|
[fetchedAction?.param_schema],
|
||||||
);
|
);
|
||||||
const schemaProperties = useMemo(
|
const schemaProperties = useMemo(
|
||||||
() => extractProperties(actionParamSchema),
|
() => extractProperties(actionParamSchema),
|
||||||
@@ -275,30 +285,6 @@ export default function TaskInspector({
|
|||||||
);
|
);
|
||||||
const hasSchema = Object.keys(schemaProperties).length > 0;
|
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);
|
const otherTaskNames = allTaskNames.filter((n) => n !== task.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -375,14 +361,20 @@ export default function TaskInspector({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{selectedAction?.description && (
|
{(fetchedAction?.description || selectedAction?.description) && (
|
||||||
<p className="text-[10px] text-gray-400 mt-1">
|
<p className="text-[10px] text-gray-400 mt-1">
|
||||||
{selectedAction.description}
|
{fetchedAction?.description || selectedAction?.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input Parameters — schema-driven form */}
|
{/* 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 && (
|
{hasSchema && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
||||||
@@ -390,130 +382,21 @@ export default function TaskInspector({
|
|||||||
</label>
|
</label>
|
||||||
<ParamSchemaForm
|
<ParamSchemaForm
|
||||||
schema={actionParamSchema}
|
schema={actionParamSchema}
|
||||||
values={schemaValues}
|
values={task.input}
|
||||||
onChange={(newValues) => {
|
onChange={(newValues) => {
|
||||||
// Merge schema-driven values back with extra params
|
update({ input: newValues });
|
||||||
update({ input: { ...newValues, ...extraParams } });
|
|
||||||
}}
|
}}
|
||||||
allowTemplates
|
allowTemplates
|
||||||
|
hideTemplateHint
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{task.action && !actionLoading && !hasSchema && (
|
||||||
{/* Extra custom parameters (not in schema) */}
|
<p className="text-[10px] text-gray-400 italic">
|
||||||
{hasSchema && Object.keys(extraParams).length > 0 && (
|
This action has no declared parameters.
|
||||||
<div>
|
</p>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
@@ -850,48 +733,50 @@ export default function TaskInspector({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.with_items && (
|
<div>
|
||||||
<>
|
<label
|
||||||
<div>
|
className={`block text-xs font-medium mb-1 ${localWithItems ? "text-gray-700" : "text-gray-400"}`}
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
>
|
||||||
Batch Size
|
Batch Size
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={task.batch_size || ""}
|
value={task.batch_size || ""}
|
||||||
onChange={(e) =>
|
disabled={!localWithItems}
|
||||||
update({
|
onChange={(e) =>
|
||||||
batch_size: e.target.value
|
update({
|
||||||
? parseInt(e.target.value)
|
batch_size: e.target.value
|
||||||
: undefined,
|
? 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"
|
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"
|
||||||
min={1}
|
placeholder="Process all at once"
|
||||||
/>
|
min={1}
|
||||||
</div>
|
/>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<div>
|
||||||
Concurrency
|
<label
|
||||||
</label>
|
className={`block text-xs font-medium mb-1 ${localWithItems ? "text-gray-700" : "text-gray-400"}`}
|
||||||
<input
|
>
|
||||||
type="number"
|
Concurrency
|
||||||
value={task.concurrency || ""}
|
</label>
|
||||||
onChange={(e) =>
|
<input
|
||||||
update({
|
type="number"
|
||||||
concurrency: e.target.value
|
value={task.concurrency || ""}
|
||||||
? parseInt(e.target.value)
|
disabled={!localWithItems}
|
||||||
: undefined,
|
onChange={(e) =>
|
||||||
})
|
update({
|
||||||
}
|
concurrency: e.target.value
|
||||||
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"
|
? parseInt(e.target.value)
|
||||||
placeholder="No limit"
|
: undefined,
|
||||||
min={1}
|
})
|
||||||
/>
|
}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback, useRef, useState } from "react";
|
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 type { WorkflowTask, TransitionPreset } from "@/types/workflow";
|
||||||
import {
|
import {
|
||||||
PRESET_LABELS,
|
PRESET_LABELS,
|
||||||
@@ -12,6 +12,7 @@ export type { TransitionPreset };
|
|||||||
interface TaskNodeProps {
|
interface TaskNodeProps {
|
||||||
task: WorkflowTask;
|
task: WorkflowTask;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isStartNode: boolean;
|
||||||
allTaskNames: string[];
|
allTaskNames: string[];
|
||||||
onSelect: (taskId: string) => void;
|
onSelect: (taskId: string) => void;
|
||||||
onDelete: (taskId: string) => void;
|
onDelete: (taskId: string) => void;
|
||||||
@@ -95,6 +96,7 @@ function transitionSummary(task: WorkflowTask): string | null {
|
|||||||
function TaskNodeInner({
|
function TaskNodeInner({
|
||||||
task,
|
task,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isStartNode,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
@@ -107,7 +109,6 @@ function TaskNodeInner({
|
|||||||
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
|
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isInputHandleHovered, setIsInputHandleHovered] = useState(false);
|
|
||||||
const dragOffset = useRef({ x: 0, y: 0 });
|
const dragOffset = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
@@ -147,9 +148,9 @@ function TaskNodeInner({
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (connectingFrom && connectingFrom.taskId !== task.id) {
|
if (connectingFrom) {
|
||||||
onCompleteConnection(task.id);
|
onCompleteConnection(task.id);
|
||||||
} else if (!connectingFrom) {
|
} else {
|
||||||
onSelect(task.id);
|
onSelect(task.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -173,18 +174,7 @@ function TaskNodeInner({
|
|||||||
[task.id, onStartConnection],
|
[task.id, onStartConnection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInputHandleMouseUp = useCallback(
|
const isConnectionTarget = connectingFrom !== null;
|
||||||
(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 borderColor = isSelected
|
const borderColor = isSelected
|
||||||
? "border-blue-500 ring-2 ring-blue-200"
|
? "border-blue-500 ring-2 ring-blue-200"
|
||||||
@@ -195,6 +185,12 @@ function TaskNodeInner({
|
|||||||
const hasAction = task.action && task.action.length > 0;
|
const hasAction = task.action && task.action.length > 0;
|
||||||
const summary = transitionSummary(task);
|
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)
|
// Count custom transitions (those not matching any preset)
|
||||||
const customTransitionCount = (task.next || []).filter((t) => {
|
const customTransitionCount = (task.next || []).filter((t) => {
|
||||||
const ct = classifyTransitionWhen(t.when);
|
const ct = classifyTransitionWhen(t.when);
|
||||||
@@ -212,55 +208,35 @@ function TaskNodeInner({
|
|||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onClick={handleClick}
|
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
|
<div
|
||||||
className={`bg-white rounded-lg border-2 shadow-sm transition-colors ${borderColor}`}
|
className={`bg-white rounded-lg border-2 shadow-sm transition-colors ${borderColor}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* 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">
|
<div
|
||||||
<GripVertical className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
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="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}
|
{task.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,35 +298,40 @@ function TaskNodeInner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer actions */}
|
{/* 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
|
||||||
<div className="flex gap-1">
|
className={`flex items-center px-2 py-1.5 border-t rounded-b-md ${
|
||||||
<button
|
isStopNode
|
||||||
data-action-button
|
? "border-red-200 bg-red-50"
|
||||||
onClick={(e) => {
|
: "border-gray-100 bg-gray-50"
|
||||||
e.stopPropagation();
|
} ${isStopNode ? "justify-between" : "justify-end"}`}
|
||||||
onSelect(task.id);
|
>
|
||||||
}}
|
{isStopNode && (
|
||||||
className="p-1 rounded hover:bg-blue-100 text-gray-400 hover:text-blue-600 transition-colors"
|
<div className="flex items-center gap-1">
|
||||||
title="Configure task"
|
<Octagon
|
||||||
>
|
className="w-3.5 h-3.5 text-red-500"
|
||||||
<Settings className="w-3 h-3" />
|
fill="currentColor"
|
||||||
</button>
|
strokeWidth={0}
|
||||||
<button
|
/>
|
||||||
data-action-button
|
<span className="text-[10px] font-medium text-red-600">Stop</span>
|
||||||
onClick={handleDelete}
|
</div>
|
||||||
className="p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
|
)}
|
||||||
title="Delete task"
|
<button
|
||||||
>
|
data-action-button
|
||||||
<Trash2 className="w-3 h-3" />
|
onClick={handleDelete}
|
||||||
</button>
|
className="p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
</div>
|
title="Delete task"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connection target overlay */}
|
{/* Connection target overlay */}
|
||||||
{isConnectionTarget && (
|
{isConnectionTarget && (
|
||||||
<div className="absolute inset-0 rounded-lg bg-purple-100 bg-opacity-20 pointer-events-none flex items-center justify-center">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import TaskNode from "./TaskNode";
|
|||||||
import type { TransitionPreset } from "./TaskNode";
|
import type { TransitionPreset } from "./TaskNode";
|
||||||
import WorkflowEdges from "./WorkflowEdges";
|
import WorkflowEdges from "./WorkflowEdges";
|
||||||
import type { EdgeHoverInfo } from "./WorkflowEdges";
|
import type { EdgeHoverInfo } from "./WorkflowEdges";
|
||||||
import type {
|
import type { WorkflowTask, WorkflowEdge } from "@/types/workflow";
|
||||||
WorkflowTask,
|
|
||||||
PaletteAction,
|
|
||||||
WorkflowEdge,
|
|
||||||
} from "@/types/workflow";
|
|
||||||
import {
|
import {
|
||||||
deriveEdges,
|
deriveEdges,
|
||||||
generateUniqueTaskName,
|
generateUniqueTaskName,
|
||||||
generateTaskId,
|
generateTaskId,
|
||||||
|
findStartingTaskIds,
|
||||||
PRESET_LABELS,
|
PRESET_LABELS,
|
||||||
} from "@/types/workflow";
|
} from "@/types/workflow";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
@@ -19,7 +16,6 @@ import { Plus } from "lucide-react";
|
|||||||
interface WorkflowCanvasProps {
|
interface WorkflowCanvasProps {
|
||||||
tasks: WorkflowTask[];
|
tasks: WorkflowTask[];
|
||||||
selectedTaskId: string | null;
|
selectedTaskId: string | null;
|
||||||
availableActions: PaletteAction[];
|
|
||||||
onSelectTask: (taskId: string | null) => void;
|
onSelectTask: (taskId: string | null) => void;
|
||||||
onUpdateTask: (taskId: string, updates: Partial<WorkflowTask>) => void;
|
onUpdateTask: (taskId: string, updates: Partial<WorkflowTask>) => void;
|
||||||
onDeleteTask: (taskId: string) => void;
|
onDeleteTask: (taskId: string) => void;
|
||||||
@@ -29,7 +25,7 @@ interface WorkflowCanvasProps {
|
|||||||
preset: TransitionPreset,
|
preset: TransitionPreset,
|
||||||
toTaskName: string,
|
toTaskName: string,
|
||||||
) => void;
|
) => void;
|
||||||
onEdgeHover?: (info: EdgeHoverInfo | null) => void;
|
onEdgeClick?: (info: EdgeHoverInfo | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Label color mapping for the connecting banner */
|
/** Label color mapping for the connecting banner */
|
||||||
@@ -47,7 +43,7 @@ export default function WorkflowCanvas({
|
|||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onAddTask,
|
onAddTask,
|
||||||
onSetConnection,
|
onSetConnection,
|
||||||
onEdgeHover,
|
onEdgeClick,
|
||||||
}: WorkflowCanvasProps) {
|
}: WorkflowCanvasProps) {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const [connectingFrom, setConnectingFrom] = useState<{
|
const [connectingFrom, setConnectingFrom] = useState<{
|
||||||
@@ -63,6 +59,8 @@ export default function WorkflowCanvas({
|
|||||||
|
|
||||||
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
|
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
|
||||||
|
|
||||||
|
const startingTaskIds = useMemo(() => findStartingTaskIds(tasks), [tasks]);
|
||||||
|
|
||||||
const handleCanvasClick = useCallback(
|
const handleCanvasClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
// Only deselect if clicking the canvas background
|
// Only deselect if clicking the canvas background
|
||||||
@@ -213,7 +211,7 @@ export default function WorkflowCanvas({
|
|||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
connectingFrom={connectingFrom}
|
connectingFrom={connectingFrom}
|
||||||
mousePosition={mousePosition}
|
mousePosition={mousePosition}
|
||||||
onEdgeHover={onEdgeHover}
|
onEdgeClick={onEdgeClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Task nodes */}
|
{/* Task nodes */}
|
||||||
@@ -222,6 +220,7 @@ export default function WorkflowCanvas({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
isSelected={task.id === selectedTaskId}
|
isSelected={task.id === selectedTaskId}
|
||||||
|
isStartNode={startingTaskIds.has(task.id)}
|
||||||
allTaskNames={allTaskNames}
|
allTaskNames={allTaskNames}
|
||||||
onSelect={onSelectTask}
|
onSelect={onSelectTask}
|
||||||
onDelete={onDeleteTask}
|
onDelete={onDeleteTask}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow";
|
import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow";
|
||||||
|
import { PRESET_COLORS } from "@/types/workflow";
|
||||||
import type { TransitionPreset } from "./TaskNode";
|
import type { TransitionPreset } from "./TaskNode";
|
||||||
|
|
||||||
export interface EdgeHoverInfo {
|
export interface EdgeHoverInfo {
|
||||||
@@ -18,8 +19,8 @@ interface WorkflowEdgesProps {
|
|||||||
connectingFrom?: { taskId: string; preset: TransitionPreset } | null;
|
connectingFrom?: { taskId: string; preset: TransitionPreset } | null;
|
||||||
/** Mouse position for drawing the preview connection line */
|
/** Mouse position for drawing the preview connection line */
|
||||||
mousePosition?: { x: number; y: number } | null;
|
mousePosition?: { x: number; y: number } | null;
|
||||||
/** Called when the mouse enters/leaves an edge hit area */
|
/** Called when an edge is clicked */
|
||||||
onEdgeHover?: (info: EdgeHoverInfo | null) => void;
|
onEdgeClick?: (info: EdgeHoverInfo | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NODE_WIDTH = 240;
|
const NODE_WIDTH = 240;
|
||||||
@@ -40,13 +41,6 @@ const EDGE_DASH: Record<EdgeType, string> = {
|
|||||||
custom: "8,4,2,4",
|
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 */
|
/** Calculate the center-bottom of a task node */
|
||||||
function getNodeBottomCenter(
|
function getNodeBottomCenter(
|
||||||
task: WorkflowTask,
|
task: WorkflowTask,
|
||||||
@@ -89,14 +83,31 @@ function getNodeRightCenter(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the best connection points between two nodes.
|
* 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(
|
function getBestConnectionPoints(
|
||||||
fromTask: WorkflowTask,
|
fromTask: WorkflowTask,
|
||||||
toTask: WorkflowTask,
|
toTask: WorkflowTask,
|
||||||
nodeWidth: number,
|
nodeWidth: number,
|
||||||
nodeHeight: 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 = {
|
const fromCenter = {
|
||||||
x: fromTask.position.x + nodeWidth / 2,
|
x: fromTask.position.x + nodeWidth / 2,
|
||||||
y: fromTask.position.y + nodeHeight / 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) {
|
if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) {
|
||||||
|
const start =
|
||||||
|
dx >= 0
|
||||||
|
? getNodeRightCenter(fromTask, nodeWidth, nodeHeight)
|
||||||
|
: getNodeLeftCenter(fromTask, nodeHeight);
|
||||||
return {
|
return {
|
||||||
start: getNodeTopCenter(fromTask, nodeWidth),
|
start,
|
||||||
end: getNodeBottomCenter(toTask, nodeWidth, nodeHeight),
|
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.
|
* Build an SVG path string for a curved edge between two points.
|
||||||
* Uses a cubic bezier curve.
|
* Uses a cubic bezier curve.
|
||||||
@@ -179,7 +210,7 @@ function WorkflowEdgesInner({
|
|||||||
nodeHeight = NODE_HEIGHT,
|
nodeHeight = NODE_HEIGHT,
|
||||||
connectingFrom,
|
connectingFrom,
|
||||||
mousePosition,
|
mousePosition,
|
||||||
onEdgeHover,
|
onEdgeClick,
|
||||||
}: WorkflowEdgesProps) {
|
}: WorkflowEdgesProps) {
|
||||||
const taskMap = useMemo(() => {
|
const taskMap = useMemo(() => {
|
||||||
const map = new Map<string, WorkflowTask>();
|
const map = new Map<string, WorkflowTask>();
|
||||||
@@ -211,21 +242,25 @@ function WorkflowEdgesInner({
|
|||||||
const toTask = taskMap.get(edge.to);
|
const toTask = taskMap.get(edge.to);
|
||||||
if (!fromTask || !toTask) return null;
|
if (!fromTask || !toTask) return null;
|
||||||
|
|
||||||
const { start, end } = getBestConnectionPoints(
|
const { start, end, selfLoop } = getBestConnectionPoints(
|
||||||
fromTask,
|
fromTask,
|
||||||
toTask,
|
toTask,
|
||||||
nodeWidth,
|
nodeWidth,
|
||||||
nodeHeight,
|
nodeHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pathD = buildCurvePath(start, end);
|
const pathD = selfLoop
|
||||||
|
? buildSelfLoopPath(start, end)
|
||||||
|
: buildCurvePath(start, end);
|
||||||
const color =
|
const color =
|
||||||
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
|
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
|
||||||
const dash = EDGE_DASH[edge.type] || "";
|
const dash = EDGE_DASH[edge.type] || "";
|
||||||
|
|
||||||
// Calculate label position (midpoint of curve)
|
// Calculate label position — offset to the right for self-loops
|
||||||
const labelX = (start.x + end.x) / 2;
|
const labelX = selfLoop ? start.x + 50 : (start.x + end.x) / 2;
|
||||||
const labelY = (start.y + end.y) / 2 - 8;
|
const labelY = selfLoop
|
||||||
|
? (start.y + end.y) / 2 - 20
|
||||||
|
: (start.y + end.y) / 2 - 8;
|
||||||
|
|
||||||
// Measure approximate label width
|
// Measure approximate label width
|
||||||
const labelText = edge.label || "";
|
const labelText = edge.label || "";
|
||||||
@@ -254,13 +289,12 @@ function WorkflowEdgesInner({
|
|||||||
stroke="transparent"
|
stroke="transparent"
|
||||||
strokeWidth={12}
|
strokeWidth={12}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onMouseEnter={() =>
|
onClick={() =>
|
||||||
onEdgeHover?.({
|
onEdgeClick?.({
|
||||||
taskId: edge.from,
|
taskId: edge.from,
|
||||||
transitionIndex: edge.transitionIndex,
|
transitionIndex: edge.transitionIndex,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onMouseLeave={() => onEdgeHover?.(null)}
|
|
||||||
/>
|
/>
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
{edge.label && (
|
{edge.label && (
|
||||||
@@ -295,7 +329,7 @@ function WorkflowEdgesInner({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}, [edges, taskMap, nodeWidth, nodeHeight, onEdgeHover]);
|
}, [edges, taskMap, nodeWidth, nodeHeight, onEdgeClick]);
|
||||||
|
|
||||||
// Preview line when connecting
|
// Preview line when connecting
|
||||||
const previewLine = useMemo(() => {
|
const previewLine = useMemo(() => {
|
||||||
|
|||||||
@@ -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 { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Save,
|
Save,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
FileCode,
|
FileCode,
|
||||||
Code,
|
Code,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import type { WorkflowYamlDefinition } from "@/types/workflow";
|
import type { WorkflowYamlDefinition } from "@/types/workflow";
|
||||||
@@ -15,6 +17,7 @@ import WorkflowCanvas from "@/components/workflows/WorkflowCanvas";
|
|||||||
import type { EdgeHoverInfo } from "@/components/workflows/WorkflowEdges";
|
import type { EdgeHoverInfo } from "@/components/workflows/WorkflowEdges";
|
||||||
import TaskInspector from "@/components/workflows/TaskInspector";
|
import TaskInspector from "@/components/workflows/TaskInspector";
|
||||||
import { useActions } from "@/hooks/useActions";
|
import { useActions } from "@/hooks/useActions";
|
||||||
|
import { ActionsService } from "@/api";
|
||||||
import { usePacks } from "@/hooks/usePacks";
|
import { usePacks } from "@/hooks/usePacks";
|
||||||
import { useWorkflow } from "@/hooks/useWorkflows";
|
import { useWorkflow } from "@/hooks/useWorkflows";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +38,8 @@ import {
|
|||||||
validateWorkflow,
|
validateWorkflow,
|
||||||
addTransitionTarget,
|
addTransitionTarget,
|
||||||
removeTaskFromTransitions,
|
removeTaskFromTransitions,
|
||||||
|
renameTaskInTransitions,
|
||||||
|
findStartingTaskIds,
|
||||||
} from "@/types/workflow";
|
} from "@/types/workflow";
|
||||||
|
|
||||||
const INITIAL_STATE: WorkflowBuilderState = {
|
const INITIAL_STATE: WorkflowBuilderState = {
|
||||||
@@ -82,28 +87,20 @@ export default function WorkflowBuilderPage() {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
transitionIndex: number;
|
transitionIndex: number;
|
||||||
} | null>(null);
|
} | 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) => {
|
(info: EdgeHoverInfo | null) => {
|
||||||
// Clear any pending auto-clear timeout
|
|
||||||
if (highlightTimeoutRef.current) {
|
|
||||||
clearTimeout(highlightTimeoutRef.current);
|
|
||||||
highlightTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info) {
|
if (info) {
|
||||||
// Select the source task so TaskInspector opens for it
|
// Select the source task so TaskInspector opens for it
|
||||||
setSelectedTaskId(info.taskId);
|
setSelectedTaskId(info.taskId);
|
||||||
setHighlightedTransition(info);
|
setHighlightedTransition(info);
|
||||||
|
|
||||||
// Auto-clear highlight after 2 seconds so the flash animation plays once
|
|
||||||
highlightTimeoutRef.current = setTimeout(() => {
|
|
||||||
setHighlightedTransition(null);
|
|
||||||
highlightTimeoutRef.current = null;
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
} else {
|
||||||
setHighlightedTransition(null);
|
setHighlightedTransition(null);
|
||||||
}
|
}
|
||||||
@@ -138,6 +135,7 @@ export default function WorkflowBuilderPage() {
|
|||||||
);
|
);
|
||||||
setState(builderState);
|
setState(builderState);
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
|
setJustInitialized(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,8 +147,6 @@ export default function WorkflowBuilderPage() {
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
pack_ref: string;
|
pack_ref: string;
|
||||||
param_schema?: Record<string, unknown> | null;
|
|
||||||
out_schema?: Record<string, unknown> | null;
|
|
||||||
}>;
|
}>;
|
||||||
return actions.map((a) => ({
|
return actions.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -158,19 +154,42 @@ export default function WorkflowBuilderPage() {
|
|||||||
label: a.label,
|
label: a.label,
|
||||||
description: a.description || "",
|
description: a.description || "",
|
||||||
pack_ref: a.pack_ref,
|
pack_ref: a.pack_ref,
|
||||||
param_schema: a.param_schema || null,
|
|
||||||
out_schema: a.out_schema || null,
|
|
||||||
}));
|
}));
|
||||||
}, [actionsData]);
|
}, [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 actionSchemaMap = useMemo(() => {
|
||||||
const map = new Map<string, Record<string, unknown> | null>();
|
const map = new Map<string, Record<string, unknown> | null>();
|
||||||
for (const action of paletteActions) {
|
for (let i = 0; i < uniqueActionRefs.length; i++) {
|
||||||
map.set(action.ref, action.param_schema);
|
const query = actionDetailQueries[i];
|
||||||
|
if (query.data?.data) {
|
||||||
|
map.set(
|
||||||
|
uniqueActionRefs[i],
|
||||||
|
(query.data.data.param_schema as Record<string, unknown>) || null,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [paletteActions]);
|
}, [uniqueActionRefs, actionDetailQueries]);
|
||||||
|
|
||||||
const packs = useMemo(() => {
|
const packs = useMemo(() => {
|
||||||
return (packsData?.data || []) as Array<{
|
return (packsData?.data || []) as Array<{
|
||||||
@@ -190,6 +209,65 @@ export default function WorkflowBuilderPage() {
|
|||||||
[state.tasks],
|
[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
|
// State updaters
|
||||||
const updateMetadata = useCallback(
|
const updateMetadata = useCallback(
|
||||||
(updates: Partial<WorkflowBuilderState>) => {
|
(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 = {
|
const newTask: WorkflowTask = {
|
||||||
id: generateTaskId(),
|
id: generateTaskId(),
|
||||||
name,
|
name,
|
||||||
action: action.ref,
|
action: action.ref,
|
||||||
input,
|
input: {},
|
||||||
position: {
|
position: {
|
||||||
x: 300,
|
x: 300,
|
||||||
y: state.tasks.length === 0 ? 60 : maxY + 160,
|
y: state.tasks.length === 0 ? 60 : maxY + 160,
|
||||||
@@ -254,12 +323,49 @@ export default function WorkflowBuilderPage() {
|
|||||||
|
|
||||||
const handleUpdateTask = useCallback(
|
const handleUpdateTask = useCallback(
|
||||||
(taskId: string, updates: Partial<WorkflowTask>) => {
|
(taskId: string, updates: Partial<WorkflowTask>) => {
|
||||||
setState((prev) => ({
|
setState((prev) => {
|
||||||
...prev,
|
// Detect a name change so we can propagate it to transitions
|
||||||
tasks: prev.tasks.map((t) =>
|
const oldName =
|
||||||
t.id === taskId ? { ...t, ...updates } : t,
|
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);
|
setSaveSuccess(false);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -310,7 +416,7 @@ export default function WorkflowBuilderPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const doSave = useCallback(async () => {
|
||||||
// Validate
|
// Validate
|
||||||
const errors = validateWorkflow(state);
|
const errors = validateWorkflow(state);
|
||||||
setValidationErrors(errors);
|
setValidationErrors(errors);
|
||||||
@@ -381,6 +487,18 @@ export default function WorkflowBuilderPage() {
|
|||||||
actionSchemaMap,
|
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
|
// YAML preview — generate proper YAML from builder state
|
||||||
const yamlPreview = useMemo(() => {
|
const yamlPreview = useMemo(() => {
|
||||||
if (!showYamlPreview) return "";
|
if (!showYamlPreview) return "";
|
||||||
@@ -640,13 +758,12 @@ export default function WorkflowBuilderPage() {
|
|||||||
<WorkflowCanvas
|
<WorkflowCanvas
|
||||||
tasks={state.tasks}
|
tasks={state.tasks}
|
||||||
selectedTaskId={selectedTaskId}
|
selectedTaskId={selectedTaskId}
|
||||||
availableActions={paletteActions}
|
|
||||||
onSelectTask={setSelectedTaskId}
|
onSelectTask={setSelectedTaskId}
|
||||||
onUpdateTask={handleUpdateTask}
|
onUpdateTask={handleUpdateTask}
|
||||||
onDeleteTask={handleDeleteTask}
|
onDeleteTask={handleDeleteTask}
|
||||||
onAddTask={handleAddTask}
|
onAddTask={handleAddTask}
|
||||||
onSetConnection={handleSetConnection}
|
onSetConnection={handleSetConnection}
|
||||||
onEdgeHover={handleEdgeHover}
|
onEdgeClick={handleEdgeClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right: Task Inspector */}
|
{/* Right: Task Inspector */}
|
||||||
@@ -667,6 +784,122 @@ export default function WorkflowBuilderPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ export const PRESET_LABELS: Record<TransitionPreset, string> = {
|
|||||||
always: "Always",
|
always: "Always",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Default edge colors for each preset */
|
||||||
|
export const PRESET_COLORS: Record<TransitionPreset, string> = {
|
||||||
|
succeeded: "#22c55e", // green-500
|
||||||
|
failed: "#ef4444", // red-500
|
||||||
|
always: "#6b7280", // gray-500
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a `when` expression into an edge visual type.
|
* Classify a `when` expression into an edge visual type.
|
||||||
* Used for edge coloring and labeling.
|
* Used for edge coloring and labeling.
|
||||||
@@ -197,15 +204,27 @@ export interface WorkflowYamlDefinition {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chart-only metadata for a transition edge (not consumed by the backend) */
|
||||||
|
export interface TransitionChartMeta {
|
||||||
|
/** Custom display label for the transition */
|
||||||
|
label?: string;
|
||||||
|
/** Custom color for the transition edge (CSS color string) */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Transition as represented in YAML format */
|
/** Transition as represented in YAML format */
|
||||||
export interface WorkflowYamlTransition {
|
export interface WorkflowYamlTransition {
|
||||||
when?: string;
|
when?: string;
|
||||||
publish?: PublishDirective[];
|
publish?: PublishDirective[];
|
||||||
do?: string[];
|
do?: string[];
|
||||||
/** Custom display label for the transition */
|
/** Visual metadata (label, color) — ignored by backend */
|
||||||
label?: string;
|
__chart_meta__?: TransitionChartMeta;
|
||||||
/** Custom color for the transition edge */
|
}
|
||||||
color?: string;
|
|
||||||
|
/** Chart-only metadata for a task node (not consumed by the backend) */
|
||||||
|
export interface TaskChartMeta {
|
||||||
|
/** Visual position on the canvas */
|
||||||
|
position?: NodePosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Task as represented in YAML format */
|
/** Task as represented in YAML format */
|
||||||
@@ -221,6 +240,8 @@ export interface WorkflowYamlTask {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
next?: WorkflowYamlTransition[];
|
next?: WorkflowYamlTransition[];
|
||||||
join?: number;
|
join?: number;
|
||||||
|
/** Visual metadata (position) — ignored by backend */
|
||||||
|
__chart_meta__?: TaskChartMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Request to save a workflow file to disk and sync to DB */
|
/** Request to save a workflow file to disk and sync to DB */
|
||||||
@@ -254,8 +275,6 @@ export interface PaletteAction {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
pack_ref: string;
|
pack_ref: string;
|
||||||
param_schema: Record<string, unknown> | null;
|
|
||||||
out_schema: Record<string, unknown> | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -352,6 +371,11 @@ export function builderStateToDefinition(
|
|||||||
if (task.timeout) yamlTask.timeout = task.timeout;
|
if (task.timeout) yamlTask.timeout = task.timeout;
|
||||||
if (task.join) yamlTask.join = task.join;
|
if (task.join) yamlTask.join = task.join;
|
||||||
|
|
||||||
|
// Persist canvas position in __chart_meta__ so layout is restored on reload
|
||||||
|
yamlTask.__chart_meta__ = {
|
||||||
|
position: { x: task.position.x, y: task.position.y },
|
||||||
|
};
|
||||||
|
|
||||||
// Serialize transitions as `next` array
|
// Serialize transitions as `next` array
|
||||||
if (task.next && task.next.length > 0) {
|
if (task.next && task.next.length > 0) {
|
||||||
yamlTask.next = task.next.map((t) => {
|
yamlTask.next = task.next.map((t) => {
|
||||||
@@ -359,8 +383,12 @@ export function builderStateToDefinition(
|
|||||||
if (t.when) yt.when = t.when;
|
if (t.when) yt.when = t.when;
|
||||||
if (t.publish && t.publish.length > 0) yt.publish = t.publish;
|
if (t.publish && t.publish.length > 0) yt.publish = t.publish;
|
||||||
if (t.do && t.do.length > 0) yt.do = t.do;
|
if (t.do && t.do.length > 0) yt.do = t.do;
|
||||||
if (t.label) yt.label = t.label;
|
// Store label/color in __chart_meta__ to avoid polluting the transition namespace
|
||||||
if (t.color) yt.color = t.color;
|
if (t.label || t.color) {
|
||||||
|
yt.__chart_meta__ = {};
|
||||||
|
if (t.label) yt.__chart_meta__.label = t.label;
|
||||||
|
if (t.color) yt.__chart_meta__.color = t.color;
|
||||||
|
}
|
||||||
return yt;
|
return yt;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -499,8 +527,8 @@ export function definitionToBuilderState(
|
|||||||
when: t.when,
|
when: t.when,
|
||||||
publish: t.publish,
|
publish: t.publish,
|
||||||
do: t.do,
|
do: t.do,
|
||||||
label: t.label,
|
label: t.__chart_meta__?.label,
|
||||||
color: t.color,
|
color: t.__chart_meta__?.color,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const converted = legacyTransitionsToNext(task);
|
const converted = legacyTransitionsToNext(task);
|
||||||
@@ -520,7 +548,7 @@ export function definitionToBuilderState(
|
|||||||
batch_size: task.batch_size,
|
batch_size: task.batch_size,
|
||||||
concurrency: task.concurrency,
|
concurrency: task.concurrency,
|
||||||
join: task.join,
|
join: task.join,
|
||||||
position: {
|
position: task.__chart_meta__?.position ?? {
|
||||||
x: 300,
|
x: 300,
|
||||||
y: 80 + index * 160,
|
y: 80 + index * 160,
|
||||||
},
|
},
|
||||||
@@ -623,8 +651,11 @@ export function findOrCreateTransition(
|
|||||||
return { next, index: existingIndex };
|
return { next, index: existingIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new transition
|
// Create new transition with default label and color for the preset
|
||||||
const newTransition: TaskTransition = {};
|
const newTransition: TaskTransition = {
|
||||||
|
label: PRESET_LABELS[preset],
|
||||||
|
color: PRESET_COLORS[preset],
|
||||||
|
};
|
||||||
if (whenExpr) newTransition.when = whenExpr;
|
if (whenExpr) newTransition.when = whenExpr;
|
||||||
next.push(newTransition);
|
next.push(newTransition);
|
||||||
return { next, index: next.length - 1 };
|
return { next, index: next.length - 1 };
|
||||||
@@ -677,6 +708,60 @@ export function removeTaskFromTransitions(
|
|||||||
return cleaned.length > 0 ? cleaned : undefined;
|
return cleaned.length > 0 ? cleaned : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a task in all transition `do` lists.
|
||||||
|
* Returns a new array (or undefined) only when something changed;
|
||||||
|
* otherwise returns the original reference so callers can cheaply
|
||||||
|
* detect a no-op via `===`.
|
||||||
|
*/
|
||||||
|
export function renameTaskInTransitions(
|
||||||
|
next: TaskTransition[] | undefined,
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
): TaskTransition[] | undefined {
|
||||||
|
if (!next) return undefined;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const updated = next.map((t) => {
|
||||||
|
if (!t.do || !t.do.includes(oldName)) return t;
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
do: t.do.map((name) => (name === oldName ? newName : name)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? updated : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find "starting" tasks — those whose name does not appear in any
|
||||||
|
* transition `do` list (i.e. no other task transitions into them).
|
||||||
|
* Returns a Set of task IDs.
|
||||||
|
*/
|
||||||
|
export function findStartingTaskIds(tasks: WorkflowTask[]): Set<string> {
|
||||||
|
// Collect every task name that is referenced as a transition target
|
||||||
|
const targeted = new Set<string>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task.next) continue;
|
||||||
|
for (const t of task.next) {
|
||||||
|
if (t.do) {
|
||||||
|
for (const name of t.do) {
|
||||||
|
targeted.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIds = new Set<string>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!targeted.has(task.name)) {
|
||||||
|
startIds.add(task.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return startIds;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Utility functions
|
// Utility functions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user