feat(frontend): display graph validation errors inline on node fields (#11524)

When running a graph in the new builder, validation errors were only
displayed in toast notifications, making it difficult for users to
identify which specific fields had errors. Users needed to see
validation errors directly next to the problematic fields within each
node for better UX and faster debugging.

<img width="1319" height="953" alt="Screenshot 2025-12-03 at 12 48
15 PM"
src="https://github.com/user-attachments/assets/d444bc71-9bee-4fa7-8b7f-33339bd0cb24"
/>

### Changes 🏗️

- **Error handling in graph execution** (`useRunGraph.ts`):
- Added detection for graph validation errors using
`ApiError.isGraphValidationError()`
  - Parse and store node-level errors from backend validation response
  - Clear all node errors on successful graph execution
- Enhanced toast messages to guide users to fix validation errors on
highlighted nodes

- **Node store error management** (`nodeStore.ts`):
  - Added `errors` field to node data structure
  - Implemented `updateNodeErrors()` to set errors for a specific node
- Implemented `clearNodeErrors()` to remove errors from a specific node
  - Implemented `getNodeErrors()` to retrieve errors for a specific node
- Implemented `setNodeErrorsForBackendId()` to set errors by backend ID
(supports matching by `metadata.backend_id` or node `id`)
- Implemented `clearAllNodeErrors()` to clear all node errors across the
graph

- **Visual error indication** (`CustomNode.tsx`, `NodeContainer.tsx`):
- Added error detection logic to identify both configuration errors and
output errors
- Applied error styling to nodes with validation errors (using `FAILED`
status styling)
- Nodes with errors now display with red border/ring to visually
indicate issues

- **Field-level error display** (`FieldTemplate.tsx`):
  - Fetch node errors from store for the current node
- Match field IDs with error keys (handles both underscore and dot
notation)
  - Display field-specific error messages below each field in red text
- Added helper function `getFieldErrorKey()` to normalize field IDs for
error matching

- **Utility helpers** (`helpers.ts`):
- Created `getFieldErrorKey()` function to extract field key from field
ID (removes `root_` prefix)

### Checklist 📋

#### For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Create a graph with multiple nodes and intentionally leave
required fields empty
- [x] Run the graph and verify that validation errors appear in toast
notification
- [x] Verify that nodes with errors are highlighted with red border/ring
styling
- [x] Verify that field-specific error messages appear below each
problematic field in red text
- [x] Verify that error messages handle both underscore and dot notation
in field keys
- [x] Fix validation errors and run graph again - verify errors are
cleared
  - [x] Verify that successful graph execution clears all node errors
- [x] Test with nodes that have `backend_id` in metadata vs nodes
without
  - [x] Verify that nodes without errors don't show error styling
- [x] Test with nested fields and array fields to ensure error matching
works correctly
This commit is contained in:
Abhimanyu Yadav
2025-12-04 20:42:51 +05:30
committed by GitHub
parent f6608e99c8
commit 729400dbe1
7 changed files with 141 additions and 9 deletions

View File

@@ -9,6 +9,8 @@ import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { useShallow } from "zustand/react/shallow";
import { useState } from "react";
import { useSaveGraph } from "@/app/(platform)/build/hooks/useSaveGraph";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { ApiError } from "@/lib/autogpt-server-api/helpers"; // Check if this exists
export const useRunGraph = () => {
const { saveGraph, isSaving } = useSaveGraph({
@@ -24,6 +26,13 @@ export const useRunGraph = () => {
);
const [openRunInputDialog, setOpenRunInputDialog] = useState(false);
const setNodeErrorsForBackendId = useNodeStore(
useShallow((state) => state.setNodeErrorsForBackendId),
);
const clearAllNodeErrors = useNodeStore(
useShallow((state) => state.clearAllNodeErrors),
);
const [{ flowID, flowVersion, flowExecutionID }, setQueryStates] =
useQueryStates({
flowID: parseAsString,
@@ -35,19 +44,49 @@ export const useRunGraph = () => {
usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response: any) => {
clearAllNodeErrors();
const { id } = response.data as GraphExecutionMeta;
setQueryStates({
flowExecutionID: id,
});
},
onError: (error: any) => {
// Reset running state on error
setIsGraphRunning(false);
toast({
title: (error.detail as string) ?? "An unexpected error occurred.",
description: "An unexpected error occurred.",
variant: "destructive",
});
if (error instanceof ApiError && error.isGraphValidationError?.()) {
const errorData = error.response?.detail;
if (errorData?.node_errors) {
Object.entries(errorData.node_errors).forEach(
([backendId, nodeErrors]) => {
setNodeErrorsForBackendId(
backendId,
nodeErrors as { [key: string]: string },
);
},
);
useNodeStore.getState().nodes.forEach((node) => {
const backendId = node.data.metadata?.backend_id || node.id;
if (!errorData.node_errors[backendId as string]) {
useNodeStore.getState().updateNodeErrors(node.id, {});
}
});
}
toast({
title: errorData?.message || "Graph validation failed",
description:
"Please fix the validation errors on the highlighted nodes and try again.",
variant: "destructive",
});
} else {
toast({
title:
(error.detail as string) ?? "An unexpected error occurred.",
description: "An unexpected error occurred.",
variant: "destructive",
});
}
},
},
});

View File

@@ -81,7 +81,7 @@ export const useFlow = () => {
{
query: {
select: (res) => res.data as BlockInfo[],
enabled: !!flowID && !!blockIds,
enabled: !!flowID && !!blockIds && blockIds.length > 0,
},
},
);

View File

@@ -37,6 +37,7 @@ export type CustomNodeData = {
costs: BlockCost[];
categories: BlockInfoCategoriesItem[];
metadata?: NodeModelMetadata;
errors?: { [key: string]: string };
};
export type CustomNode = XYNode<CustomNodeData, "custom">;
@@ -71,10 +72,24 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
const hasConfigErrors =
data.errors &&
Object.values(data.errors).some(
(value) => value !== null && value !== undefined && value !== "",
);
const outputData = data.nodeExecutionResult?.output_data;
const hasOutputError =
typeof outputData === "object" &&
outputData !== null &&
"error" in outputData;
const hasErrors = hasConfigErrors || hasOutputError;
// Currently all blockTypes design are similar - that's why i am using the same component for all of them
// If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
return (
<NodeContainer selected={selected} nodeId={nodeId}>
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
<div className="rounded-xlarge bg-white">
<NodeHeader data={data} nodeId={nodeId} />
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />}

View File

@@ -3,15 +3,18 @@ import { nodeStyleBasedOnStatus } from "../helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useShallow } from "zustand/react/shallow";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
export const NodeContainer = ({
children,
nodeId,
selected,
hasErrors, // these are configuration errors that occur before executing the graph -- more like validation errors
}: {
children: React.ReactNode;
nodeId: string;
selected: boolean;
hasErrors?: boolean;
}) => {
const status = useNodeStore(
useShallow((state) => state.getNodeStatus(nodeId)),
@@ -22,6 +25,7 @@ export const NodeContainer = ({
"z-12 max-w-[370px] rounded-xlarge ring-1 ring-slate-200/60",
selected && "shadow-lg ring-2 ring-slate-200",
status && nodeStyleBasedOnStatus[status],
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
)}
>
{children}

View File

@@ -53,6 +53,15 @@ type NodeStore = {
getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
getNodeBlockUIType: (nodeId: string) => BlockUIType;
hasWebhookNodes: () => boolean;
updateNodeErrors: (nodeId: string, errors: { [key: string]: string }) => void;
clearNodeErrors: (nodeId: string) => void;
getNodeErrors: (nodeId: string) => { [key: string]: string } | undefined;
setNodeErrorsForBackendId: (
backendId: string,
errors: { [key: string]: string },
) => void;
clearAllNodeErrors: () => void; // Add this
};
export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -253,4 +262,47 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
[BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(n.data.uiType),
);
},
updateNodeErrors: (nodeId: string, errors: { [key: string]: string }) => {
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, errors } } : n,
),
}));
},
clearNodeErrors: (nodeId: string) => {
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, errors: undefined } } : n,
),
}));
},
getNodeErrors: (nodeId: string) => {
return get().nodes.find((n) => n.id === nodeId)?.data?.errors;
},
setNodeErrorsForBackendId: (
backendId: string,
errors: { [key: string]: string },
) => {
set((state) => ({
nodes: state.nodes.map((n) => {
// Match by backend_id if nodes have it, or by id
const matches =
n.data.metadata?.backend_id === backendId || n.id === backendId;
return matches ? { ...n, data: { ...n.data, errors } } : n;
}),
}));
},
clearAllNodeErrors: () => {
set((state) => ({
nodes: state.nodes.map((n) => ({
...n,
data: { ...n.data, errors: undefined },
})),
}));
},
}));

View File

@@ -23,6 +23,7 @@ import { cn } from "@/lib/utils";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { BlockUIType } from "@/lib/autogpt-server-api";
import NodeHandle from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
import { getFieldErrorKey } from "../utils/helpers";
const FieldTemplate: React.FC<FieldTemplateProps> = ({
id: fieldId,
@@ -42,6 +43,11 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
(state) => state.nodeAdvancedStates[nodeId] ?? false,
);
const nodeErrors = useNodeStore((state) => {
const node = state.nodes.find((n) => n.id === nodeId);
return node?.data?.errors;
});
const { isArrayItem, arrayFieldHandleId } = useContext(ArrayEditorContext);
const isAnyOf =
@@ -89,6 +95,13 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
shouldShowHandle = false;
}
const fieldErrorKey = getFieldErrorKey(fieldId);
const fieldError =
nodeErrors?.[fieldErrorKey] ||
nodeErrors?.[fieldErrorKey.replace(/_/g, ".")] ||
nodeErrors?.[fieldErrorKey.replace(/\./g, "_")] ||
null;
return (
<div
className={cn(
@@ -150,7 +163,12 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
<div className={cn(size === "small" ? "max-w-[340px] pl-2" : "")}>
{children}
</div>
)}{" "}
)}
{fieldError && (
<Text variant="small" className="mt-1 pl-4 !text-red-600">
{fieldError}
</Text>
)}
</div>
);
};

View File

@@ -0,0 +1,4 @@
export const getFieldErrorKey = (fieldId: string): string => {
const withoutRoot = fieldId.startsWith("root_") ? fieldId.slice(5) : fieldId;
return withoutRoot;
};