Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/renders-validation-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": patch
---

Renders SDK validation errors on the diagram and in the sidepanel
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

export * from "./workflowSdk";
export * from "./validationErrors";
export * from "./graph";
export * from "./taskDetails";
export * from "./taskSubType";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright 2021-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { SdkError, ValidationError } from "./workflowSdk";

/* workflowSdk produces a flat array of errors, but the UI needs them split into two categories: errors that attach to a specific node, and workflow-level errors that don't. This file provides helper functions to filter, sort, and slice that error list.
*/
Comment thread
lornakelly marked this conversation as resolved.

/* The SDK reports an invalid task as "missing" every other task type, which is
* noise. These are the missing-type errors to filter out.
*
* `catch` is intentionally excluded: a missing-property error on a `catch` is a genuine problem worth surfacing.
*/
const MISSING_PROP_TASK_TYPES = new Set([
"call",
"do",
"emit",
"for",
"fork",
"listen",
"raise",
"run",
"set",
"switch",
"try",
"wait",
]);

type NodeError = ValidationError & { path: string };

export function isValidationError(error: SdkError): error is ValidationError {
return !(error instanceof Error);
}

/* Get the last segment as keyword eg #/oneOf/2/allOf/1/properties/with/required returns 'required' */
function getErrorKeyword(error: ValidationError): string | undefined {
return error.errorType?.split("/").pop();
}

function isNoiseError(error: ValidationError): boolean {
const keyword = getErrorKeyword(error);

if (keyword === "oneOf") {
return true;
}

const missingProperty = error.object?.["missingProperty"];
if (typeof missingProperty === "string" && MISSING_PROP_TASK_TYPES.has(missingProperty)) {
return true;
}

/* `call` is a special case: it has a subtype, so the SDK reports errors complaining it must be one of `grpc`, `http`, etc. These are noise*/
if ((keyword === "const" || keyword === "not") && error.errorType?.includes("/properties/call")) {
return true;
}

return false;
}

function isNodeError(error: SdkError): error is NodeError {
return isValidationError(error) && error.path !== undefined && !isNoiseError(error);
}

/* Match the longest id prefix to find owning node */
function findOwningNode(path: string, nodeIds: Set<string>): string | undefined {
let owner: string | undefined;
for (const id of nodeIds) {
if (path === id || path.startsWith(`${id}/`)) {
if (owner === undefined || id.length > owner.length) {
owner = id;
}
}
}
return owner;
}
Comment thread
lornakelly marked this conversation as resolved.

/* returns errors for a particular node and removes noise */
export function getNodeErrors(
errors: SdkError[],
nodeId: string,
nodeIds: Set<string>,
): ValidationError[] {
return errors.filter((error): error is NodeError => {
return isNodeError(error) && findOwningNode(error.path, nodeIds) === nodeId;
});
}

export function getNodeErrorField(error: ValidationError, nodeId: string): string | undefined {
const prefix = `${nodeId}/`;
if (error.path === undefined || !error.path.startsWith(prefix)) {
return undefined;
}

return error.path.slice(prefix.length).split("/").join(".");
}

/* To quickly lookup nodeIds that should display badge and outline */
export function getErrorNodeIds(errors: SdkError[], nodeIds: Set<string>): Set<string> {
const ids = new Set<string>();
for (const error of errors) {
if (!isNodeError(error)) {
continue;
}
const owner = findOwningNode(error.path, nodeIds);
if (owner !== undefined) {
ids.add(owner);
}
}
return ids;
}

/* Errors not associated with node (workflow-level errors) */
export function getGeneralErrors(errors: SdkError[], nodeIds: Set<string>): SdkError[] {
return errors.filter((error) => {
if (!isValidationError(error) || error.path === undefined) {
return true;
}
return findOwningNode(error.path, nodeIds) === undefined;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function sanitizeObject(obj: Record<string, unknown>): Record<string, unknown> {
}

export type ValidationError = {
taskId?: string;
path?: string;
errorType?: string;
message: string;
object?: Record<string, unknown>;
Expand All @@ -59,7 +59,7 @@ export type WorkflowParseResult = {
*
* **Format 1: Pipe-delimited with 4 fields (task-specific errors)**
* ```
* - taskId | errorType | message | object
* - path | errorType | message | object
* ```
* Example:
* ```
Expand All @@ -78,7 +78,7 @@ export type WorkflowParseResult = {
* @param message - The raw error message string from the SDK, typically containing multiple lines
* @returns Array of structured ValidationError objects. Each error is guaranteed to have:
* - `message`: The error description (always present)
* - `taskId`: The workflow task path (present only in Format 1)
* - `path`: The workflow task path (present only in Format 1)
* - `errorType`: The error type/schema reference (present in both formats)
* - `object`: Additional error context as a sanitized object with null prototype (present only in Format 1;
* empty object if JSON parsing fails or if the JSON is not a plain object)
Expand All @@ -98,7 +98,7 @@ export type WorkflowParseResult = {
*
* const errors = parseValidationErrorMessage(sdkError);
* // [
* // { taskId: "/do/0/call", errorType: "#/required", message: "must have property",
* // { path: "/do/0/call", errorType: "#/required", message: "must have property",
* // object: { missingProperty: "http" } },
* // { errorType: "#/document", message: "must have required property 'document'" }
* // ]
Expand Down Expand Up @@ -134,7 +134,7 @@ export function parseValidationErrorMessage(message: string): ValidationError[]
const firstPipe = pipePositions[0]!;
const secondPipe = pipePositions[1]!;

const taskId = content.substring(0, firstPipe).trim();
const path = content.substring(0, firstPipe).trim();
const errorType = content.substring(firstPipe + 1, secondPipe).trim();

// Try to find the last pipe that separates valid JSON
Expand Down Expand Up @@ -182,12 +182,12 @@ export function parseValidationErrorMessage(message: string): ValidationError[]
}

// Validate all required fields are non-empty
if (!taskId || !errorType || !errorMessage || !objectStr) {
if (!path || !errorType || !errorMessage || !objectStr) {
continue;
}

errors.push({
taskId,
path,
errorType,
message: errorMessage,
object: parsedObject,
Expand All @@ -212,13 +212,31 @@ export function parseValidationErrorMessage(message: string): ValidationError[]
return errors;
}

/*
* The SDK repeats its full error block within a single message, so the parsed list
* contains duplicates (identical path + errorType + message). Those carry no
* extra information and would otherwise surface the same error twice on a node and
* inflate error counts, so collapse exact duplicates here at the produce point.
*/
function dedupeValidationErrors(errors: ValidationError[]): ValidationError[] {
const seen = new Set<string>();
return errors.filter((error) => {
const signature = `${error.path ?? ""}|${error.errorType}|${error.message}`;
if (seen.has(signature)) {
return false;
}
seen.add(signature);
return true;
});
}
Comment thread
lornakelly marked this conversation as resolved.

export function validateWorkflow(model: sdk.Specification.Workflow): SdkError[] {
try {
sdk.validate("Workflow", model);
return [];
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const parsedErrors = parseValidationErrorMessage(message);
const parsedErrors = dedupeValidationErrors(parseValidationErrorMessage(message));

// If parsing succeeded and returned errors, use them
if (parsedErrors.length > 0) {
Expand All @@ -237,7 +255,9 @@ export function parseWorkflow(text: string): WorkflowParseResult {
let raw: Partial<sdk.Specification.Workflow>;

try {
raw = yaml.load(text, { schema: yaml.DEFAULT_SCHEMA }) as Partial<sdk.Specification.Workflow>;
raw = yaml.load(text, {
schema: yaml.DEFAULT_SCHEMA,
}) as Partial<sdk.Specification.Workflow>;
} catch (err) {
return {
model: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const en = {
"sidebar.selectNode": "Select a node to view its details.",
"sidebar.sectionDocument": "Document",
"sidebar.sectionMetadata": "Metadata",
"sidebar.sectionErrors": "Errors",
"sidebar.name": "Name",
"sidebar.version": "Version",
"sidebar.namespace": "Namespace",
Expand All @@ -38,6 +39,7 @@ export const en = {
"sidebar.noDetails": "No additional details for this node",
"node.entry": "Entry",
"node.exit": "Exit",
"node.errorBadge": "This node has validation errors",
"sidebar.exportMermaid.copy": "Copy Mermaid Code",
"sidebar.exportMermaid.download": "Download as Mermaid File",
"sidebar.exportMermaid.copied": "Copied!",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,50 @@
@apply dec:truncate;
}
/* end terminal markers */
/* Validation errors */
.dec-root .dec-leaf-node.has-error,
.dec-root .dec-container-node.has-error {
box-shadow:
0 0 0 1px var(--dec-error-accent),
0 0 10px 1px var(--dec-error-glow);
}

.dec-root.dark .dec-leaf-node.has-error,
.dec-root.dark .dec-container-node.has-error {
box-shadow:
0 0 0 1px var(--dec-error-accent),
0 0 12px 2px rgba(248, 113, 113, 0.4);
}

.dec-root .dec-leaf-node.has-error.selected,
.dec-root .dec-container-node.has-error.selected {
box-shadow:
0 0 0 2px var(--dec-error-accent),
0 0 0 4px rgb(59 130 246),
0 0 12px 4px rgba(59, 130, 246, 0.45);
}

.dec-root.dark .dec-leaf-node.has-error.selected,
.dec-root.dark .dec-container-node.has-error.selected {
box-shadow:
0 0 0 2px var(--dec-error-accent),
0 0 0 4px rgb(96 165 250),
0 0 16px 4px rgba(96, 165, 250, 0.5);
}

.dec-root .dec-leaf-node.has-error {
@apply dec:relative;
}
.dec-root .dec-node-error-badge {
@apply dec:absolute dec:z-10 dec:h-[18px] dec:w-[18px] dec:rounded-full;
top: -8px;
left: -8px;
fill: var(--dec-error-accent);
color: #ffffff;
stroke-width: 2.25;
}

/* end validation errors */
}

/* custom edges */
Expand All @@ -368,8 +412,8 @@
}

.dec-root .edge-line.error {
@apply dec:stroke-red-500
dec:[stroke-dasharray:5_5];
stroke: var(--dec-error-accent);
@apply dec:[stroke-dasharray:5_5];
}

.dec-root .edge-line.condition {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import { ResolvedColorMode } from "../../types/colorMode";
import { ReactFlowEdgeTypes } from "../edges/Edges";
import { useDiagramEditorContext } from "../../store/DiagramEditorContext";
import { buildDiagramElements } from "./diagramBuilder";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { applyAutoLayout } from "./autoLayout";
import { SidePanelTrigger } from "@/side-panel/SidePanelTrigger";

const FIT_VIEW_OPTIONS: RF.FitViewOptions = {
maxZoom: 1,
Expand All @@ -47,7 +47,7 @@ export type DiagramProps = {

export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
const reactFlowInstance: RF.ReactFlowInstance = RF.useReactFlow();
const { model, nodes, edges, isReadOnly, setNodes, setEdges, setSelectedNodeId } =
const { model, errors, nodes, edges, isReadOnly, setNodes, setEdges, setSelectedNodeId } =
useDiagramEditorContext();

const [minimapVisible, setMinimapVisible] = React.useState(false);
Expand Down Expand Up @@ -87,7 +87,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
// Create abort controller for this layout operation
abortController = new AbortController();

const graph = buildDiagramElements(model);
const graph = buildDiagramElements(model, errors);
applyAutoLayout(graph, abortController.signal)
.then(({ nodes, edges }) => {
// Only update if this effect is still active (not cancelled by cleanup)
Expand Down Expand Up @@ -128,7 +128,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
abortController.abort();
}
};
}, [model, reactFlowInstance, setNodes, setEdges]);
}, [model, errors, reactFlowInstance, setNodes, setEdges]);

return (
<div
Expand Down Expand Up @@ -167,7 +167,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
{minimapVisible && <RF.MiniMap pannable zoomable position={"top-right"} />}

<RF.Panel position="top-right">
<SidebarTrigger />
<SidePanelTrigger />
</RF.Panel>

<RF.Controls
Expand Down
Loading