From 729400dbe1f2e42eec870a6d86bfff62fc09eaca Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:42:51 +0530 Subject: [PATCH] feat(frontend): display graph validation errors inline on node fields (#11524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2025-12-03 at 12 48
15 PM ### 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 --- .../components/RunGraph/useRunGraph.ts | 51 +++++++++++++++--- .../components/FlowEditor/Flow/useFlow.ts | 2 +- .../nodes/CustomNode/CustomNode.tsx | 17 +++++- .../CustomNode/components/NodeContainer.tsx | 4 ++ .../app/(platform)/build/stores/nodeStore.ts | 52 +++++++++++++++++++ .../templates/FieldTemplate.tsx | 20 ++++++- .../renderers/input-renderer/utils/helpers.ts | 4 ++ 7 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/utils/helpers.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts index 2b37ebe8a9..db3b6660df 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts @@ -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", + }); + } }, }, }); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts index badb9784b8..64f00871d8 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts @@ -81,7 +81,7 @@ export const useFlow = () => { { query: { select: (res) => res.data as BlockInfo[], - enabled: !!flowID && !!blockIds, + enabled: !!flowID && !!blockIds && blockIds.length > 0, }, }, ); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx index 52df3edbc4..974cbe3754 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx @@ -37,6 +37,7 @@ export type CustomNodeData = { costs: BlockCost[]; categories: BlockInfoCategoriesItem[]; metadata?: NodeModelMetadata; + errors?: { [key: string]: string }; }; export type CustomNode = XYNode; @@ -71,10 +72,24 @@ export const CustomNode: React.FC> = 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 ( - +
{isWebhook && } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx index 657f1ca048..f8d5b2e089 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx @@ -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} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts index 3beba0c615..2f41c3bb46 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts @@ -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((set, get) => ({ @@ -253,4 +262,47 @@ export const useNodeStore = create((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 }, + })), + })); + }, })); diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/FieldTemplate.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/FieldTemplate.tsx index b4db9d4159..a056782939 100644 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/FieldTemplate.tsx +++ b/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/FieldTemplate.tsx @@ -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 = ({ id: fieldId, @@ -42,6 +43,11 @@ const FieldTemplate: React.FC = ({ (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 = ({ shouldShowHandle = false; } + const fieldErrorKey = getFieldErrorKey(fieldId); + const fieldError = + nodeErrors?.[fieldErrorKey] || + nodeErrors?.[fieldErrorKey.replace(/_/g, ".")] || + nodeErrors?.[fieldErrorKey.replace(/\./g, "_")] || + null; + return (
= ({
{children}
- )}{" "} + )} + {fieldError && ( + + {fieldError} + + )}
); }; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/utils/helpers.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/utils/helpers.ts new file mode 100644 index 0000000000..51b628d923 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/input-renderer/utils/helpers.ts @@ -0,0 +1,4 @@ +export const getFieldErrorKey = (fieldId: string): string => { + const withoutRoot = fieldId.startsWith("root_") ? fieldId.slice(5) : fieldId; + return withoutRoot; +};