Skip to content

Commit 61c71d2

Browse files
committed
Refine builder canvas actions
1 parent 386e154 commit 61c71d2

2 files changed

Lines changed: 138 additions & 75 deletions

File tree

pkg/validation/validation.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,12 @@ func (v *Validator) Validate(ctx context.Context, def *enginev1.WorkflowDefiniti
137137
if !has {
138138
if !field.Optional {
139139
addErr(stepID, "input_bindings."+inName, "missing_required_input",
140-
fmt.Sprintf("step %q: action input %q is required but has no binding — connect a value or provide a constant", stepID, inName))
140+
"required input is not connected — connect a value or provide a constant")
141141
}
142142
continue
143143
}
144144

145-
srcType, srcDesc := v.bindingType(stepID, inName, b, inputTypes, actionShapes, stepsByID, addEdge, addErr)
145+
srcType, _ := v.bindingType(stepID, inName, b, inputTypes, actionShapes, stepsByID, addEdge, addErr)
146146
if srcType == "" {
147147
continue
148148
}
@@ -156,7 +156,7 @@ func (v *Validator) Validate(ctx context.Context, def *enginev1.WorkflowDefiniti
156156

157157
if !enumStringCompatible && !types.CompatibleWithSystem(expType, srcType, v.Types) {
158158
addErr(stepID, "input_bindings."+inName, "binding_type_mismatch",
159-
fmt.Sprintf("input %q expects type %q but is bound to %s which has type %q", inName, expType, srcDesc, srcType))
159+
fmt.Sprintf("type mismatch: expected %q, got %q", expType, srcType))
160160
}
161161
}
162162

@@ -166,7 +166,7 @@ func (v *Validator) Validate(ctx context.Context, def *enginev1.WorkflowDefiniti
166166
for inName := range st.GetInputBindings() {
167167
if _, ok := shape.Inputs[inName]; !ok {
168168
addErr(stepID, "input_bindings."+inName, "unknown_action_input",
169-
fmt.Sprintf("step %q: action %q has no input named %q — check for typos", stepID, st.GetActionRef(), inName))
169+
"not a recognized input for this action — check for typos")
170170
}
171171
}
172172
}
@@ -236,14 +236,14 @@ func (v *Validator) Validate(ctx context.Context, def *enginev1.WorkflowDefiniti
236236
wout := outputByName[p.GetWorkflowOutputName()]
237237
if wout == nil {
238238
addErr(stepID, path+".workflow_output_name", "unknown_workflow_output",
239-
fmt.Sprintf("step %q produces into workflow output %q which is not declared — add it to workflow_outputs or fix the name", stepID, p.GetWorkflowOutputName()))
239+
"produces into a workflow output that is not declared — add it to workflow_outputs or fix the name")
240240
continue
241241
}
242242

243243
outField, ok := shape.Outputs[p.GetStepOutputName()]
244244
if !ok {
245245
addErr(stepID, path+".step_output_name", "unknown_step_output",
246-
fmt.Sprintf("step %q has no output named %q — check step_output_name in produces", stepID, p.GetStepOutputName()))
246+
"produces from a step output that doesn't exist — check step_output_name")
247247
continue
248248
}
249249

@@ -433,7 +433,7 @@ func (v *Validator) bindingType(
433433
t, ok := inputTypes[name]
434434
if !ok {
435435
addErr(stepID, "input_bindings."+inputName, "unknown_workflow_input",
436-
fmt.Sprintf("input %q is bound to workflow input %q which does not exist — check the workflow's declared inputs", inputName, name))
436+
"bound to a workflow input that doesn't exist — check the workflow's declared inputs")
437437
return "", ""
438438
}
439439
return t, "workflow_input." + name
@@ -447,19 +447,19 @@ func (v *Validator) bindingType(
447447
srcStep := stepsByID[ref.GetStepId()]
448448
if srcStep == nil {
449449
addErr(stepID, "input_bindings."+inputName, "unknown_step",
450-
fmt.Sprintf("input %q is bound to step %q which does not exist — check the step_id", inputName, ref.GetStepId()))
450+
"bound to a step that doesn't exist")
451451
return "", ""
452452
}
453453
srcShape, ok := actionShapes[ref.GetStepId()]
454454
if !ok {
455455
addErr(stepID, "input_bindings."+inputName, "unknown_action_ref",
456-
fmt.Sprintf("cannot type-check binding: step %q has an unknown action", ref.GetStepId()))
456+
"source step has an unknown action — cannot type-check this binding")
457457
return "", ""
458458
}
459459
out, ok := srcShape.Outputs[ref.GetOutputName()]
460460
if !ok {
461461
addErr(stepID, "input_bindings."+inputName, "unknown_step_output",
462-
fmt.Sprintf("input %q is bound to step %q output %q which does not exist — check the output_name", inputName, ref.GetStepId(), ref.GetOutputName()))
462+
"bound to a step output that doesn't exist")
463463
return "", ""
464464
}
465465
addEdge(ref.GetStepId(), stepID)

ui/src/app/workflows/[id]/page.tsx

Lines changed: 128 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,62 @@ function InspectorPanelIcon({ className }: { className?: string }) {
291291
);
292292
}
293293

294-
function ErrorsPanel({ errors }: { errors: string[] }) {
294+
/** Maps a structured ValidationError to a user-friendly message string. */
295+
function friendlyValidationMessage(e: { code: string; bindingPath: string; message: string }): string {
296+
// Extract the leaf field name from paths like "input_bindings.ticket_details"
297+
const field = e.bindingPath.includes(".") ? e.bindingPath.split(".").slice(1).join(".") : "";
298+
const q = (s: string) => s ? `"${s}"` : "";
299+
switch (e.code) {
300+
case "missing_required_input":
301+
return `${q(field)} must be connected`;
302+
case "binding_type_mismatch":
303+
return `${q(field)} has an incompatible type`;
304+
case "unknown_action_input":
305+
return `${q(field)} is not a recognized input`;
306+
case "unknown_workflow_input":
307+
return `${q(field)} references a workflow input that doesn't exist`;
308+
case "unknown_step_output":
309+
return field === "when"
310+
? "Condition references a step output that doesn't exist"
311+
: `${q(field)} references a step output that doesn't exist`;
312+
case "unknown_step":
313+
return `${q(field)} references a step that doesn't exist`;
314+
case "unknown_action_ref":
315+
return "Unknown action type";
316+
case "cycle_detected":
317+
return "Circular dependency detected";
318+
case "duplicate_step_id":
319+
return "Duplicate step ID";
320+
case "when_not_bool":
321+
return "Condition must evaluate to true/false";
322+
case "invalid_when":
323+
return "Invalid condition";
324+
case "invalid_enum_value":
325+
return "Invalid enum value in condition";
326+
case "unknown_workflow_output":
327+
return "References a workflow output that doesn't exist";
328+
case "workflow_output_type_mismatch":
329+
return "Output type doesn't match the workflow output";
330+
case "required_workflow_output_unsatisfied":
331+
return `Required output ${q(field)} has no producers`;
332+
case "conditional_output_incomplete_branch":
333+
return `Required output ${q(field)} is not produced for all cases`;
334+
case "workflow_output_multiple_producers":
335+
return "Multiple steps unconditionally produce the same output";
336+
default:
337+
return e.message;
338+
}
339+
}
340+
341+
function ValidationErrorsPanel({
342+
validationErrors,
343+
definition,
344+
onJump,
345+
}: {
346+
validationErrors: import("@/gen/engine/v1/engine_pb").ValidationError[];
347+
definition: import("@/gen/engine/v1/engine_pb").WorkflowDefinition | null;
348+
onJump: (stepId: string) => void;
349+
}) {
295350
const [expanded, setExpanded] = useState(false);
296351
const ref = useRef<HTMLDivElement>(null);
297352

@@ -304,28 +359,64 @@ function ErrorsPanel({ errors }: { errors: string[] }) {
304359
return () => document.removeEventListener("click", onClick);
305360
}, [expanded]);
306361

307-
if (errors.length === 0) return null;
362+
if (validationErrors.length === 0) return null;
363+
364+
// Build stepId → label map for display
365+
const stepLabelById = new Map<string, string>();
366+
for (const st of definition?.steps ?? []) {
367+
stepLabelById.set(st.stepId, st.name || st.stepId);
368+
}
369+
308370
return (
309-
<div className="relative ml-1 shrink-0" ref={ref}>
371+
<div className="relative shrink-0" ref={ref}>
310372
<button
311373
type="button"
312374
onClick={() => setExpanded((v) => !v)}
313-
className="flex items-center gap-1.5 rounded-lg border border-red-300 bg-red-50 px-2 py-1.5 text-red-800 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/60 dark:text-red-200 dark:hover:bg-red-900/50"
375+
className="flex items-center gap-1 rounded border border-red-200 bg-red-50 px-2 py-1.5 text-red-700 transition-colors hover:bg-red-100 dark:border-red-800/50 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-900/40"
314376
aria-expanded={expanded}
315-
aria-label={`${errors.length} error(s)`}
377+
aria-label={`${validationErrors.length} validation error(s)`}
316378
>
317-
<AlertIcon className="h-4 w-4" />
318-
<span className="text-xs font-semibold">{errors.length}</span>
379+
<AlertIcon className="h-3.5 w-3.5" />
380+
<span className="text-xs font-semibold">{validationErrors.length}</span>
319381
</button>
320382
{expanded ? (
321-
<div className="absolute right-0 top-full z-50 mt-1.5 max-h-72 w-[min(26rem,calc(100vw-2rem))] overflow-auto rounded-lg border border-red-200 bg-white py-2 shadow-lg dark:border-red-900/50 dark:bg-zinc-950">
322-
<div className="px-3 pb-2 text-xs font-semibold text-red-700 dark:text-red-300">Errors</div>
323-
<ul className="space-y-1 px-3">
324-
{errors.map((e, i) => (
325-
<li key={i} className="break-words rounded bg-red-50/80 py-2 px-2.5 text-sm text-red-900 dark:bg-red-950/40 dark:text-red-100">
326-
{e}
327-
</li>
328-
))}
383+
<div className="absolute right-0 top-full z-50 mt-1.5 w-[min(28rem,calc(100vw-2rem))] overflow-hidden rounded-lg border border-red-200 bg-white shadow-lg dark:border-red-900/50 dark:bg-zinc-950">
384+
<div className="border-b border-red-100 px-3 py-2 text-[11px] font-semibold text-red-700 dark:border-red-900/50 dark:text-red-300">
385+
{validationErrors.length} validation error{validationErrors.length !== 1 ? "s" : ""}
386+
</div>
387+
<ul className="max-h-80 overflow-auto py-1.5">
388+
{validationErrors.map((e, i) => {
389+
const stepLabel = e.stepId ? (stepLabelById.get(e.stepId) ?? e.stepId) : null;
390+
const msg = friendlyValidationMessage(e);
391+
const clickable = !!e.stepId;
392+
return (
393+
<li key={i}>
394+
<button
395+
type="button"
396+
disabled={!clickable}
397+
onClick={() => {
398+
if (e.stepId) {
399+
onJump(e.stepId);
400+
setExpanded(false);
401+
}
402+
}}
403+
className={[
404+
"w-full px-3 py-2 text-left transition-colors",
405+
clickable
406+
? "cursor-pointer hover:bg-red-50/60 dark:hover:bg-red-950/30"
407+
: "cursor-default",
408+
].join(" ")}
409+
>
410+
{stepLabel ? (
411+
<div className="mb-0.5 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
412+
{stepLabel}
413+
</div>
414+
) : null}
415+
<div className="text-xs text-red-800 dark:text-red-200">{msg}</div>
416+
</button>
417+
</li>
418+
);
419+
})}
329420
</ul>
330421
</div>
331422
) : null}
@@ -349,7 +440,7 @@ export default function WorkflowBuilderPage({
349440
const [edges, setEdges] = useEdgesState<Edge>([]);
350441
const [errors, setErrors] = useState<string[]>([]);
351442
const [validationErrors, setValidationErrors] = useState<
352-
{ stepId?: string; message: string }[]
443+
import("@/gen/engine/v1/engine_pb").ValidationError[]
353444
>([]);
354445
const [status, setStatus] = useState<
355446
"idle" | "loading" | "saving" | "validating"
@@ -1213,13 +1304,10 @@ export default function WorkflowBuilderPage({
12131304
create(ValidateRequestSchema, { definition })
12141305
);
12151306
if (!resp.runnable) {
1216-
const errs = resp.errors.map((e) => (e.stepId ? `Step ${e.stepId}: ` : "") + e.message);
1217-
setErrors(errs);
1218-
setValidationErrors(
1219-
resp.errors.map((e) => ({ stepId: e.stepId || undefined, message: e.message }))
1220-
);
1307+
setValidationErrors(resp.errors);
1308+
setErrors(resp.errors.map((e) => friendlyValidationMessage(e)));
12211309
setLastValidateOk(false);
1222-
const first = resp.errors[0];
1310+
const first = resp.errors.find((e) => e.stepId);
12231311
setFirstValidationErrorStepId(first?.stepId ?? null);
12241312
return;
12251313
}
@@ -1573,14 +1661,12 @@ export default function WorkflowBuilderPage({
15731661
</button>
15741662
<button
15751663
className={[
1576-
"flex min-w-[80px] items-center justify-center gap-1.5 rounded border px-3 py-1.5 text-xs font-medium transition-colors",
1664+
"flex min-w-[80px] items-center justify-center gap-1.5 rounded px-3 py-1.5 text-xs font-semibold transition-colors",
15771665
status === "validating"
1578-
? "cursor-wait border-zinc-200 bg-zinc-50 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900"
1666+
? "cursor-wait bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
15791667
: validateSuccessAt
1580-
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400"
1581-
: errors.length > 0 && lastValidateOk === false
1582-
? "border-red-200 bg-red-50 text-red-700 dark:border-red-800/50 dark:bg-red-950/30 dark:text-red-400"
1583-
: "border-zinc-200 text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800",
1668+
? "bg-emerald-600 text-white dark:bg-emerald-500"
1669+
: "bg-zinc-200 text-zinc-500 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-600",
15841670
].join(" ")}
15851671
onClick={() => void validate()}
15861672
disabled={status !== "idle"}
@@ -1589,13 +1675,21 @@ export default function WorkflowBuilderPage({
15891675
<SpinnerIcon className="h-3.5 w-3.5" />
15901676
) : validateSuccessAt ? (
15911677
<CheckIcon className="h-3.5 w-3.5" />
1592-
) : errors.length > 0 && lastValidateOk === false ? (
1593-
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
1594-
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
1595-
</svg>
15961678
) : null}
1597-
{status === "validating" ? "Checking…" : validateSuccessAt ? "Valid" : errors.length > 0 && lastValidateOk === false ? "Errors" : "Validate"}
1679+
{status === "validating" ? "Checking…" : validateSuccessAt ? "Valid" : "Validate"}
15981680
</button>
1681+
<ValidationErrorsPanel
1682+
validationErrors={validationErrors}
1683+
definition={definition}
1684+
onJump={(stepId) => {
1685+
setShowRightPanel(true);
1686+
setSelectedStepId(stepId);
1687+
// Scroll canvas to the erroring node
1688+
setTimeout(() => {
1689+
rf?.fitView({ nodes: [{ id: stepId }], padding: 0.35, duration: 350 });
1690+
}, 50);
1691+
}}
1692+
/>
15991693
<button
16001694
className={[
16011695
"flex min-w-[72px] items-center justify-center gap-1.5 rounded px-3 py-1.5 text-xs font-semibold transition-colors",
@@ -1639,15 +1733,7 @@ export default function WorkflowBuilderPage({
16391733
? "bg-emerald-50/70 dark:bg-emerald-950/30"
16401734
: "bg-zinc-50/60 dark:bg-zinc-900/30",
16411735
operationStatusClass,
1642-
firstValidationErrorStepId ? "cursor-pointer hover:bg-zinc-100/80 dark:hover:bg-zinc-900/60" : "",
16431736
].join(" ")}
1644-
onClick={() => {
1645-
if (firstValidationErrorStepId) {
1646-
setShowRightPanel(true);
1647-
setSelectedStepId(firstValidationErrorStepId);
1648-
}
1649-
}}
1650-
role={firstValidationErrorStepId ? "button" : undefined}
16511737
>
16521738
<span
16531739
className={[
@@ -1662,11 +1748,6 @@ export default function WorkflowBuilderPage({
16621748
].join(" ")}
16631749
/>
16641750
<span className="truncate">{operationStatusText}</span>
1665-
{errors.length > 0 && lastValidateOk === false ? (
1666-
<span className="ml-1 shrink-0 text-[10px] text-zinc-500 dark:text-zinc-400">
1667-
(click to jump to first error)
1668-
</span>
1669-
) : null}
16701751
</div>
16711752
) : null}
16721753

@@ -2528,7 +2609,7 @@ function StepInspector({
25282609
onSetOptionalConstBinding: (inputName: string, jsValue: unknown) => void;
25292610
onClearOptionalBinding: (inputName: string) => void;
25302611
onSetOptionalSourceBinding: (inputName: string, sourceKey: string) => void;
2531-
validationErrors: { stepId?: string; message: string }[];
2612+
validationErrors: import("@/gen/engine/v1/engine_pb").ValidationError[];
25322613
}) {
25332614
const [tab, setTab] = useState<"inputs" | "outputs" | "when" | "advanced">("inputs");
25342615
const [showActions, setShowActions] = useState(false);
@@ -2709,24 +2790,6 @@ function StepInspector({
27092790
Input Bindings
27102791
</div>
27112792
</div>
2712-
{/* Per-step validation messages */}
2713-
{validationErrors && validationErrors.length > 0 ? (
2714-
<div className="mt-2 space-y-1.5">
2715-
{validationErrors
2716-
.filter((ve) => !ve.stepId || ve.stepId === selectedStep.stepId)
2717-
.map((ve, idx: number) => (
2718-
<div
2719-
key={`${ve.stepId ?? "workflow"}:${idx}`}
2720-
className="flex items-start gap-1 text-[11px] text-red-600 dark:text-red-300"
2721-
>
2722-
<span className="mt-[3px] inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
2723-
<span className="leading-snug">
2724-
{ve.message}
2725-
</span>
2726-
</div>
2727-
))}
2728-
</div>
2729-
) : null}
27302793
<div className="mt-2 space-y-1.5">
27312794
{Object.entries(selectedStep.inputBindings ?? {})
27322795
.filter(([name]) => name !== "...") // strip sentinel

0 commit comments

Comments
 (0)