mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 05:08:44 +02:00
feat(workflows): add condition editor, loop controls, and edge visualization
Phase 2 frontend: visual condition editor with property rule builder, expression input, message-type selector, loop toggle with max iterations, self-loop edge rendering, conditional/loop edge badges, and per-edge validation display. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -296,6 +296,7 @@ export function WorkflowEditor({
|
|||||||
onNodeRemove={handleNodeRemove}
|
onNodeRemove={handleNodeRemove}
|
||||||
selectedEdgeId={selectedEdgeId}
|
selectedEdgeId={selectedEdgeId}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
|
validationIssues={issues}
|
||||||
workflow={workflow}
|
workflow={workflow}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { EdgeCondition, WorkflowConditionRule } from '@shared/domain/workflow';
|
||||||
|
|
||||||
|
type ConditionType = 'none' | 'always' | 'property' | 'message-type' | 'expression';
|
||||||
|
|
||||||
|
interface ConditionEditorProps {
|
||||||
|
condition: EdgeCondition | undefined;
|
||||||
|
onChange: (condition: EdgeCondition | undefined) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATOR_OPTIONS: { value: WorkflowConditionRule['operator']; label: string }[] = [
|
||||||
|
{ value: 'equals', label: '=' },
|
||||||
|
{ value: 'not-equals', label: '≠' },
|
||||||
|
{ value: 'contains', label: 'contains' },
|
||||||
|
{ value: 'gt', label: '>' },
|
||||||
|
{ value: 'lt', label: '<' },
|
||||||
|
{ value: 'regex', label: 'matches regex' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inputClasses =
|
||||||
|
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
const selectClasses =
|
||||||
|
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
function resolveConditionType(condition: EdgeCondition | undefined): ConditionType {
|
||||||
|
if (!condition) return 'none';
|
||||||
|
return condition.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyRule(): WorkflowConditionRule {
|
||||||
|
return { propertyPath: '', operator: 'equals', value: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConditionForType(type: ConditionType): EdgeCondition | undefined {
|
||||||
|
switch (type) {
|
||||||
|
case 'none':
|
||||||
|
return undefined;
|
||||||
|
case 'always':
|
||||||
|
return { type: 'always' };
|
||||||
|
case 'property':
|
||||||
|
return { type: 'property', combinator: 'and', rules: [emptyRule()] };
|
||||||
|
case 'message-type':
|
||||||
|
return { type: 'message-type', typeName: '' };
|
||||||
|
case 'expression':
|
||||||
|
return { type: 'expression', expression: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Property rules editor ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function PropertyRuleRow({
|
||||||
|
rule,
|
||||||
|
index,
|
||||||
|
disabled,
|
||||||
|
onRuleChange,
|
||||||
|
onRemove,
|
||||||
|
canRemove,
|
||||||
|
}: {
|
||||||
|
rule: WorkflowConditionRule;
|
||||||
|
index: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onRuleChange: (index: number, patch: Partial<WorkflowConditionRule>) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
canRemove: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
className={inputClasses}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onRuleChange(index, { propertyPath: e.target.value })}
|
||||||
|
placeholder="property.path"
|
||||||
|
style={{ flex: 2 }}
|
||||||
|
value={rule.propertyPath}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className={selectClasses}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onRuleChange(index, { operator: e.target.value as WorkflowConditionRule['operator'] })
|
||||||
|
}
|
||||||
|
style={{ flex: 1.2 }}
|
||||||
|
value={rule.operator}
|
||||||
|
>
|
||||||
|
{OPERATOR_OPTIONS.map((op) => (
|
||||||
|
<option key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className={inputClasses}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onRuleChange(index, { value: e.target.value })}
|
||||||
|
placeholder="value"
|
||||||
|
style={{ flex: 2 }}
|
||||||
|
value={rule.value}
|
||||||
|
/>
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
className="flex size-6 shrink-0 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)] disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
title="Remove rule"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyRulesEditor({
|
||||||
|
condition,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
condition: Extract<EdgeCondition, { type: 'property' }>;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (condition: EdgeCondition) => void;
|
||||||
|
}) {
|
||||||
|
const rules = condition.rules;
|
||||||
|
|
||||||
|
const handleRuleChange = useCallback(
|
||||||
|
(index: number, patch: Partial<WorkflowConditionRule>) => {
|
||||||
|
const updated = rules.map((r, i) => (i === index ? { ...r, ...patch } : r));
|
||||||
|
onChange({ ...condition, rules: updated });
|
||||||
|
},
|
||||||
|
[rules, condition, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveRule = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onChange({ ...condition, rules: rules.filter((_, i) => i !== index) });
|
||||||
|
},
|
||||||
|
[rules, condition, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddRule = useCallback(() => {
|
||||||
|
onChange({ ...condition, rules: [...rules, emptyRule()] });
|
||||||
|
}, [rules, condition, onChange]);
|
||||||
|
|
||||||
|
const handleCombinatorChange = useCallback(
|
||||||
|
(combinator: 'and' | 'or') => {
|
||||||
|
onChange({ ...condition, combinator });
|
||||||
|
},
|
||||||
|
[condition, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rules.length >= 2 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">Combine</span>
|
||||||
|
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||||
|
<button
|
||||||
|
className={`px-2.5 py-1 text-[11px] font-medium transition ${
|
||||||
|
(condition.combinator ?? 'and') === 'and'
|
||||||
|
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleCombinatorChange('and')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
AND
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-2.5 py-1 text-[11px] font-medium transition ${
|
||||||
|
condition.combinator === 'or'
|
||||||
|
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleCombinatorChange('or')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
OR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rules.map((rule, i) => (
|
||||||
|
<PropertyRuleRow
|
||||||
|
canRemove={rules.length > 1}
|
||||||
|
disabled={disabled}
|
||||||
|
index={i}
|
||||||
|
key={i}
|
||||||
|
onRemove={handleRemoveRule}
|
||||||
|
onRuleChange={handleRuleChange}
|
||||||
|
rule={rule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleAddRule}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
Add Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main condition editor ─────────────────────────────────── */
|
||||||
|
|
||||||
|
export function ConditionEditor({ condition, onChange, disabled }: ConditionEditorProps) {
|
||||||
|
const currentType = resolveConditionType(condition);
|
||||||
|
|
||||||
|
const handleTypeChange = useCallback(
|
||||||
|
(type: ConditionType) => {
|
||||||
|
onChange(defaultConditionForType(type));
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
Condition
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className={selectClasses}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleTypeChange(e.target.value as ConditionType)}
|
||||||
|
value={currentType}
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="always">Always</option>
|
||||||
|
<option value="property">Property Rule</option>
|
||||||
|
<option value="message-type">Message Type</option>
|
||||||
|
<option value="expression">Expression</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{condition?.type === 'property' && (
|
||||||
|
<PropertyRulesEditor condition={condition} disabled={disabled} onChange={onChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{condition?.type === 'message-type' && (
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">Type Name</span>
|
||||||
|
<input
|
||||||
|
className={inputClasses}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...condition, typeName: e.target.value })}
|
||||||
|
placeholder="e.g. ApprovalResponse"
|
||||||
|
value={condition.typeName}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{condition?.type === 'expression' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">Expression</span>
|
||||||
|
<input
|
||||||
|
className={`${inputClasses} font-mono`}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...condition, expression: e.target.value })}
|
||||||
|
placeholder='e.g. result.status == "done"'
|
||||||
|
value={condition.expression}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-start gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2.5 py-2">
|
||||||
|
<Info className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
<span className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||||
|
Supported: ==, !=, >, <, contains, matches. Combine with && or ||
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '@renderer/lib/workflowGraph';
|
} from '@renderer/lib/workflowGraph';
|
||||||
|
|
||||||
import { workflowNodeTypes } from './WorkflowGraphNodes';
|
import { workflowNodeTypes } from './WorkflowGraphNodes';
|
||||||
|
import { workflowEdgeTypes } from './WorkflowGraphEdges';
|
||||||
|
|
||||||
interface WorkflowGraphCanvasProps {
|
interface WorkflowGraphCanvasProps {
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
@@ -197,6 +198,7 @@ function WorkflowGraphCanvasInner({
|
|||||||
onEdgeClick={handleEdgeClick}
|
onEdgeClick={handleEdgeClick}
|
||||||
onPaneClick={handlePaneClick}
|
onPaneClick={handlePaneClick}
|
||||||
nodeTypes={workflowNodeTypes}
|
nodeTypes={workflowNodeTypes}
|
||||||
|
edgeTypes={workflowEdgeTypes}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.3 }}
|
fitViewOptions={{ padding: 0.3 }}
|
||||||
minZoom={0.3}
|
minZoom={0.3}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { BaseEdge, type EdgeProps } from '@xyflow/react';
|
||||||
|
|
||||||
|
function SelfLoopEdge({ id, sourceX, sourceY, style, markerEnd }: EdgeProps) {
|
||||||
|
const loopHeight = 60;
|
||||||
|
const loopWidth = 40;
|
||||||
|
|
||||||
|
const path = `M ${sourceX} ${sourceY} C ${sourceX + loopWidth} ${sourceY - loopHeight}, ${sourceX - loopWidth} ${sourceY - loopHeight}, ${sourceX} ${sourceY}`;
|
||||||
|
|
||||||
|
return <BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workflowEdgeTypes = {
|
||||||
|
selfLoop: SelfLoopEdge,
|
||||||
|
} as const;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Bot, Code, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
|
import { useCallback } from 'react';
|
||||||
|
import { AlertCircle, Bot, Code, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findModel,
|
findModel,
|
||||||
@@ -7,19 +8,23 @@ import {
|
|||||||
type ModelDefinition,
|
type ModelDefinition,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import type {
|
import type {
|
||||||
|
EdgeCondition,
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
WorkflowEdge,
|
WorkflowEdge,
|
||||||
WorkflowNode,
|
WorkflowNode,
|
||||||
WorkflowNodeConfig,
|
WorkflowNodeConfig,
|
||||||
|
WorkflowValidationIssue,
|
||||||
AgentNodeConfig,
|
AgentNodeConfig,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
||||||
|
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
|
||||||
|
|
||||||
interface WorkflowGraphInspectorProps {
|
interface WorkflowGraphInspectorProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
selectedEdgeId: string | null;
|
selectedEdgeId: string | null;
|
||||||
|
validationIssues?: WorkflowValidationIssue[];
|
||||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||||
onNodeRemove: (nodeId: string) => void;
|
onNodeRemove: (nodeId: string) => void;
|
||||||
@@ -238,13 +243,25 @@ function PlaceholderNodeInspector({
|
|||||||
|
|
||||||
function EdgeInspector({
|
function EdgeInspector({
|
||||||
edge,
|
edge,
|
||||||
|
validationIssues,
|
||||||
onEdgeChange,
|
onEdgeChange,
|
||||||
onEdgeRemove,
|
onEdgeRemove,
|
||||||
}: {
|
}: {
|
||||||
edge: WorkflowEdge;
|
edge: WorkflowEdge;
|
||||||
|
validationIssues?: WorkflowValidationIssue[];
|
||||||
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
||||||
onEdgeRemove: (edgeId: string) => void;
|
onEdgeRemove: (edgeId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isFanIn = edge.kind === 'fan-in';
|
||||||
|
const edgeIssues = validationIssues?.filter((i) => i.edgeId === edge.id) ?? [];
|
||||||
|
|
||||||
|
const handleConditionChange = useCallback(
|
||||||
|
(condition: EdgeCondition | undefined) => {
|
||||||
|
onEdgeChange(edge.id, { condition });
|
||||||
|
},
|
||||||
|
[edge.id, onEdgeChange],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -272,12 +289,83 @@ function EdgeInspector({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Loop controls */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
Loop Edge
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
checked={edge.isLoop === true}
|
||||||
|
className="size-4 accent-[var(--color-accent)]"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
onEdgeChange(edge.id, { isLoop: true, maxIterations: edge.maxIterations ?? 10 });
|
||||||
|
} else {
|
||||||
|
onEdgeChange(edge.id, { isLoop: undefined, maxIterations: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{edge.isLoop && (
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">Max Iterations</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
min={1}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = parseInt(e.target.value, 10);
|
||||||
|
onEdgeChange(edge.id, {
|
||||||
|
maxIterations: Number.isNaN(raw) ? undefined : Math.max(1, raw),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
value={edge.maxIterations ?? 10}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Condition editor */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<ConditionEditor
|
||||||
|
condition={edge.condition}
|
||||||
|
disabled={isFanIn}
|
||||||
|
onChange={handleConditionChange}
|
||||||
|
/>
|
||||||
|
{isFanIn && (
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Fan-in edges cannot have conditions
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
label="Label"
|
label="Label"
|
||||||
onChange={(v) => onEdgeChange(edge.id, { label: v || undefined })}
|
onChange={(v) => onEdgeChange(edge.id, { label: v || undefined })}
|
||||||
placeholder="Optional edge label"
|
placeholder="Optional edge label"
|
||||||
value={edge.label ?? ''}
|
value={edge.label ?? ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Validation issues for this edge */}
|
||||||
|
{edgeIssues.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{edgeIssues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
|
||||||
|
issue.level === 'error'
|
||||||
|
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
|
||||||
|
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
|
||||||
|
}`}
|
||||||
|
key={`${issue.field ?? 'v'}-${i}`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 size-3 shrink-0" />
|
||||||
|
<span>{issue.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -289,6 +377,7 @@ export function WorkflowGraphInspector({
|
|||||||
workflow,
|
workflow,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
selectedEdgeId,
|
selectedEdgeId,
|
||||||
|
validationIssues,
|
||||||
onNodeChange,
|
onNodeChange,
|
||||||
onNodeConfigChange,
|
onNodeConfigChange,
|
||||||
onNodeRemove,
|
onNodeRemove,
|
||||||
@@ -305,7 +394,12 @@ export function WorkflowGraphInspector({
|
|||||||
if (selectedEdge) {
|
if (selectedEdge) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<EdgeInspector edge={selectedEdge} onEdgeChange={onEdgeChange} onEdgeRemove={onEdgeRemove} />
|
<EdgeInspector
|
||||||
|
edge={selectedEdge}
|
||||||
|
onEdgeChange={onEdgeChange}
|
||||||
|
onEdgeRemove={onEdgeRemove}
|
||||||
|
validationIssues={validationIssues}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,19 +94,42 @@ const EDGE_DASH: Record<WorkflowEdgeKind, string | undefined> = {
|
|||||||
'fan-in': '2 2',
|
'fan-in': '2 2',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LOOP_EDGE_COLOR = { stroke: '#c084fc', markerColor: '#c084fc' };
|
||||||
|
const LOOP_EDGE_DASH = '8 4';
|
||||||
|
|
||||||
|
function buildEdgeLabel(edge: WfEdge): string | undefined {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (edge.condition && edge.condition.type !== 'always') {
|
||||||
|
parts.push('⚡');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge.label) {
|
||||||
|
parts.push(edge.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge.isLoop && edge.maxIterations) {
|
||||||
|
parts.push(`🔁 ×${edge.maxIterations}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(' ') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function toCanvasEdges(graph: WorkflowGraph): Edge[] {
|
export function toCanvasEdges(graph: WorkflowGraph): Edge[] {
|
||||||
return graph.edges.map((edge) => {
|
return graph.edges.map((edge) => {
|
||||||
const color = EDGE_COLORS[edge.kind] ?? EDGE_COLORS.direct;
|
const isLoop = edge.isLoop === true;
|
||||||
const dashArray = EDGE_DASH[edge.kind];
|
const isSelfLoop = edge.source === edge.target;
|
||||||
|
const color = isLoop ? LOOP_EDGE_COLOR : (EDGE_COLORS[edge.kind] ?? EDGE_COLORS.direct);
|
||||||
|
const dashArray = isLoop ? LOOP_EDGE_DASH : EDGE_DASH[edge.kind];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
type: 'default',
|
type: isSelfLoop ? 'selfLoop' : 'default',
|
||||||
animated: edge.kind === 'fan-out',
|
animated: isLoop || edge.kind === 'fan-out',
|
||||||
deletable: true,
|
deletable: true,
|
||||||
label: edge.label,
|
label: buildEdgeLabel(edge),
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
|
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
|
||||||
style: {
|
style: {
|
||||||
stroke: color.stroke,
|
stroke: color.stroke,
|
||||||
@@ -144,10 +167,6 @@ export function isWorkflowConnectionAllowed(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection.source === connection.target) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceNode = graph.nodes.find((n) => n.id === connection.source);
|
const sourceNode = graph.nodes.find((n) => n.id === connection.source);
|
||||||
const targetNode = graph.nodes.find((n) => n.id === connection.target);
|
const targetNode = graph.nodes.find((n) => n.id === connection.target);
|
||||||
if (!sourceNode || !targetNode) {
|
if (!sourceNode || !targetNode) {
|
||||||
|
|||||||
Reference in New Issue
Block a user