refactor(frontend): improve customNode reusability and add multi-block support (#11368)

This refactor improves developer experience (DX) by creating a more
maintainable and extensible architecture.

The previous `CustomNode` implementation had several issues:
- Code was duplicated across different node types (StandardNodeBlock,
OutputBlock, etc.)
- Poor separation of concerns with all logic in a single component
- Limited flexibility for handling different block types
- Inconsistent handle display logic across different node types

<img width="2133" height="831" alt="Screenshot 2025-11-12 at 9 25 10 PM"
src="https://github.com/user-attachments/assets/02864bba-9ffe-4629-98ab-1c43fa644844"
/>

## Changes 🏗️

- **Refactored CustomNode structure**:
- Extracted reusable components:
[`NodeContainer`](file:///Users/abhi/Documents/AutoGPT/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx),
[`NodeHeader`](file:///Users/abhi/Documents/AutoGPT/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx),
[`NodeAdvancedToggle`](file:///Users/abhi/Documents/AutoGPT/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx),
[`WebhookDisclaimer`](file:///Users/abhi/Documents/AutoGPT/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx)
- Removed `StandardNodeBlock.tsx` and consolidated logic into
[`CustomNode.tsx`](file:///Users/abhi/Documents/AutoGPT/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx)
- Moved
[`StickyNoteBlock`](file:///Users/abhi/Documents/AutoGPT/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx)
to components folder for better organization

- **Added BlockUIType-specific logic**:
- Implemented conditional handle display based on block type (INPUT,
WEBHOOK, WEBHOOK_MANUAL blocks don't show handles)
- Added special handling for AGENT blocks with dynamic input/output
schemas
- Added webhook-specific disclaimer component with library agent
integration
  - Fixed OUTPUT block's name field to not show input handle

- **Enhanced FormCreator**:
  - Added `showHandles` prop for granular control
- Added `className` prop for styling flexibility (used for webhook
opacity)

- **Improved nodeStore**:
  - Added `getNodeBlockUIType` method for retrieving node UI types

- **UI/UX improvements**:
- Fixed duplicate gap classes in
[`BuilderActions`](file:///Users/abhi/Documents/AutoGPT/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx)
- Added proper styling for webhook blocks (disabled state with reduced
opacity)
  - Improved field template spacing for specific block types

## 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 and test a standard node block with input/output handles
  - [x] Create and test INPUT block (verify no input handles)
  - [x] Create and test OUTPUT block (verify name field has no handle)
- [x] Create and test WEBHOOK block (verify disclaimer appears and form
is disabled)
  - [x] Create and test AGENT block with custom schemas
  - [x] Create and test sticky note block
  - [x] Verify advanced toggle works for all node types
  - [x] Test node execution badges display correctly
  - [x] Verify node selection highlighting works
This commit is contained in:
Abhimanyu Yadav
2025-11-18 08:41:32 +05:30
committed by GitHub
parent a66219fc1f
commit 34c9ecf6bc
13 changed files with 319 additions and 128 deletions

View File

@@ -4,7 +4,7 @@ import { ScheduleGraph } from "./components/ScheduleGraph/ScheduleGraph";
export const BuilderActions = () => {
return (
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-2 gap-4">
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4">
<AgentOutputs />
<RunGraph />
<ScheduleGraph />

View File

@@ -2,12 +2,22 @@ import React from "react";
import { Node as XYNode, NodeProps } from "@xyflow/react";
import { RJSFSchema } from "@rjsf/utils";
import { BlockUIType } from "../../../types";
import { StickyNoteBlock } from "./StickyNoteBlock";
import { StickyNoteBlock } from "./components/StickyNoteBlock";
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
import { StandardNodeBlock } from "./StandardNodeBlock";
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { NodeContainer } from "./components/NodeContainer";
import { NodeHeader } from "./components/NodeHeader";
import { FormCreator } from "../FormCreator";
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
import { OutputHandler } from "../OutputHandler";
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
import { cn } from "@/lib/utils";
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
export type CustomNodeData = {
hardcodedValues: {
@@ -32,17 +42,59 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
({ data, id: nodeId, selected }) => {
if (data.uiType === BlockUIType.NOTE) {
return <StickyNoteBlock selected={selected} data={data} id={nodeId} />;
}
if (data.uiType === BlockUIType.STANDARD) {
return (
<StandardNodeBlock data={data} selected={selected} nodeId={nodeId} />
<StickyNoteBlock data={data} selected={selected} nodeId={nodeId} />
);
}
const showHandles =
data.uiType !== BlockUIType.INPUT &&
data.uiType !== BlockUIType.WEBHOOK &&
data.uiType !== BlockUIType.WEBHOOK_MANUAL;
const isWebhook = [
BlockUIType.WEBHOOK,
BlockUIType.WEBHOOK_MANUAL,
].includes(data.uiType);
const isAyrshare = data.uiType === BlockUIType.AYRSHARE;
const inputSchema =
data.uiType === BlockUIType.AGENT
? (data.hardcodedValues.input_schema ?? {})
: data.inputSchema;
const outputSchema =
data.uiType === BlockUIType.AGENT
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
// 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 (
<StandardNodeBlock data={data} selected={selected} nodeId={nodeId} />
<NodeContainer selected={selected} nodeId={nodeId}>
<div className="rounded-xlarge bg-white">
<NodeHeader data={data} nodeId={nodeId} />
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
{isAyrshare && <AyrshareConnectButton />}
<FormCreator
jsonSchema={preprocessInputSchema(inputSchema)}
nodeId={nodeId}
uiType={data.uiType}
className={cn(
"bg-white pr-6",
isWebhook && "pointer-events-none opacity-50",
)}
showHandles={showHandles}
/>
<NodeAdvancedToggle nodeId={nodeId} />
{data.uiType != BlockUIType.OUTPUT && (
<OutputHandler outputSchema={outputSchema} nodeId={nodeId} />
)}
<NodeDataRenderer nodeId={nodeId} />
</div>
<NodeExecutionBadge nodeId={nodeId} />
</NodeContainer>
);
},
);

View File

@@ -1,92 +0,0 @@
import { beautifyString, cn } from "@/lib/utils";
import { CustomNodeData } from "./CustomNode";
import { Text } from "@/components/atoms/Text/Text";
import { FormCreator } from "../FormCreator";
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
import { Switch } from "@/components/atoms/Switch/Switch";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { OutputHandler } from "../OutputHandler";
import { NodeCost } from "./components/NodeCost";
import { NodeBadges } from "./components/NodeBadges";
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
import { nodeStyleBasedOnStatus } from "./helpers";
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
import { NodeContextMenu } from "./components/NodeContextMenu";
type StandardNodeBlockType = {
data: CustomNodeData;
selected: boolean;
nodeId: string;
};
export const StandardNodeBlock = ({
data,
selected,
nodeId,
}: StandardNodeBlockType) => {
const showAdvanced = useNodeStore(
(state) => state.nodeAdvancedStates[nodeId] || false,
);
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
const status = useNodeStore((state) => state.getNodeStatus(nodeId));
return (
<div
className={cn(
"z-12 max-w-[370px] rounded-xlarge shadow-lg shadow-slate-900/5 ring-1 ring-slate-200/60 backdrop-blur-sm",
selected && "shadow-2xl ring-2 ring-slate-200",
status && nodeStyleBasedOnStatus[status],
)}
>
<div className="rounded-xlarge bg-white">
{/* Header */}
<div className="flex h-auto items-start justify-between gap-2 rounded-xlarge border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4">
<div className="flex flex-col gap-2">
{/* Upper section */}
<div className="flex items-center gap-2">
<Text
variant="large-semibold"
className="tracking-tight text-slate-800"
>
{beautifyString(data.title)}
</Text>
<Text variant="small" className="!font-medium !text-slate-500">
#{nodeId.split("-")[0]}
</Text>
</div>
{/* Lower section */}
<div className="flex space-x-2">
<NodeCost blockCosts={data.costs} nodeId={nodeId} />
<NodeBadges categories={data.categories} />
</div>
</div>
<NodeContextMenu
subGraphID={data.hardcodedValues?.graph_id}
nodeId={nodeId}
/>
</div>
{/* Input Handles */}
<div className="bg-white pr-6">
<FormCreator
jsonSchema={preprocessInputSchema(data.inputSchema)}
nodeId={nodeId}
uiType={data.uiType}
/>
</div>
{/* Advanced Button */}
<div className="flex items-center justify-between gap-2 border-t border-slate-200/50 bg-white px-5 py-3.5">
<Text variant="body" className="font-medium text-slate-700">
Advanced
</Text>
<Switch
onCheckedChange={(checked) => setShowAdvanced(nodeId, checked)}
checked={showAdvanced}
/>
</div>
{/* Output Handles */}
<OutputHandler outputSchema={data.outputSchema} nodeId={nodeId} />
<NodeDataRenderer nodeId={nodeId} />
</div>
{status && <NodeExecutionBadge status={status} />}
</div>
);
};

View File

@@ -0,0 +1,58 @@
"use client";
import React, { useState } from "react";
import { Key } from "lucide-react";
import { getV1GetAyrshareSsoUrl } from "@/app/api/__generated__/endpoints/integrations/integrations";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { Button } from "@/components/atoms/Button/Button";
// This SSO button is not a part of inputSchema - that's why we are not rendering it using Input renderer
export const AyrshareConnectButton = () => {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const handleSSOLogin = async () => {
setIsLoading(true);
try {
const { data, status } = await getV1GetAyrshareSsoUrl();
if (status !== 200) {
throw new Error(data.detail);
}
const popup = window.open(data.sso_url, "_blank", "popup=true");
if (!popup) {
throw new Error(
"Please allow popups for this site to be able to login with Ayrshare",
);
}
toast({
title: "Success",
description: "Please complete the authentication in the popup window",
});
} catch (error) {
toast({
title: "Error",
description: `Error getting SSO URL: ${error}`,
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
// TODO :Need better UI to show user which social media accounts are connected
<div className="mt-4 flex flex-col gap-2 px-4">
<Button
type="button"
onClick={handleSSOLogin}
disabled={isLoading}
className="h-fit w-full py-2"
loading={isLoading}
leftIcon={<Key className="mr-2 h-4 w-4" />}
>
Connect Social Media Accounts
</Button>
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
export const NodeAdvancedToggle = ({ nodeId }: { nodeId: string }) => {
const showAdvanced = useNodeStore(
(state) => state.nodeAdvancedStates[nodeId] || false,
);
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
return (
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white px-5 py-3.5">
<Text variant="body" className="font-medium text-slate-700">
Advanced
</Text>
<Switch
onCheckedChange={(checked) => setShowAdvanced(nodeId, checked)}
checked={showAdvanced}
/>
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
import { nodeStyleBasedOnStatus } from "../helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
export const NodeContainer = ({
children,
nodeId,
selected,
}: {
children: React.ReactNode;
nodeId: string;
selected: boolean;
}) => {
const status = useNodeStore((state) => state.getNodeStatus(nodeId));
return (
<div
className={cn(
"z-12 max-w-[370px] rounded-xlarge shadow-lg shadow-slate-900/5 ring-1 ring-slate-200/60 backdrop-blur-sm",
selected && "shadow-2xl ring-2 ring-slate-200",
status && nodeStyleBasedOnStatus[status],
)}
>
{children}
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { Badge } from "@/components/__legacy__/ui/badge";
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
@@ -12,11 +13,9 @@ const statusStyles: Record<AgentExecutionStatus, string> = {
FAILED: "text-red-700 border-red-400",
};
export const NodeExecutionBadge = ({
status,
}: {
status: AgentExecutionStatus;
}) => {
export const NodeExecutionBadge = ({ nodeId }: { nodeId: string }) => {
const status = useNodeStore((state) => state.getNodeStatus(nodeId));
if (!status) return null;
return (
<div className="flex items-center justify-end rounded-b-xl py-2 pr-4">
<Badge

View File

@@ -0,0 +1,42 @@
import { Text } from "@/components/atoms/Text/Text";
import { beautifyString } from "@/lib/utils";
import { NodeCost } from "./NodeCost";
import { NodeBadges } from "./NodeBadges";
import { NodeContextMenu } from "./NodeContextMenu";
import { CustomNodeData } from "../CustomNode";
export const NodeHeader = ({
data,
nodeId,
}: {
data: CustomNodeData;
nodeId: string;
}) => {
return (
<div className="flex h-auto items-start justify-between gap-2 rounded-xlarge border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4">
<div className="flex flex-col gap-2">
{/* Upper section */}
<div className="flex items-center gap-2">
<Text
variant="large-semibold"
className="tracking-tight text-slate-800"
>
{beautifyString(data.title)}
</Text>
<Text variant="small" className="!font-medium !text-slate-500">
#{nodeId.split("-")[0]}
</Text>
</div>
{/* Lower section */}
<div className="flex space-x-2">
<NodeCost blockCosts={data.costs} nodeId={nodeId} />
<NodeBadges categories={data.categories} />
</div>
</div>
<NodeContextMenu
subGraphID={data.hardcodedValues?.graph_id}
nodeId={nodeId}
/>
</div>
);
};

View File

@@ -1,19 +1,19 @@
import { useMemo } from "react";
import { FormCreator } from "../FormCreator";
import { FormCreator } from "../../FormCreator";
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
import { CustomNodeData } from "./CustomNode";
import { CustomNodeData } from "../CustomNode";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
type StickyNoteBlockType = {
selected: boolean;
data: CustomNodeData;
id: string;
nodeId: string;
};
export const StickyNoteBlock = ({ data, id }: StickyNoteBlockType) => {
export const StickyNoteBlock = ({ data, nodeId }: StickyNoteBlockType) => {
const { angle, color } = useMemo(() => {
const hash = id.split("").reduce((acc, char) => {
const hash = nodeId.split("").reduce((acc, char) => {
return char.charCodeAt(0) + ((acc << 5) - acc);
}, 0);
@@ -31,7 +31,7 @@ export const StickyNoteBlock = ({ data, id }: StickyNoteBlockType) => {
angle: (hash % 7) - 3,
color: colors[Math.abs(hash) % colors.length],
};
}, [id]);
}, [nodeId]);
return (
<div
@@ -42,11 +42,11 @@ export const StickyNoteBlock = ({ data, id }: StickyNoteBlockType) => {
style={{ transform: `rotate(${angle}deg)` }}
>
<Text variant="h3" className="tracking-tight text-slate-800">
Notes #{id.split("-")[0]}
Notes #{nodeId.split("-")[0]}
</Text>
<FormCreator
jsonSchema={preprocessInputSchema(data.inputSchema)}
nodeId={id}
nodeId={nodeId}
uiType={data.uiType}
/>
</div>

View File

@@ -0,0 +1,58 @@
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
import { Text } from "@/components/atoms/Text/Text";
import Link from "next/link";
import { useGetV2GetLibraryAgentByGraphId } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useQueryStates, parseAsString } from "nuqs";
import { isValidUUID } from "@/app/(platform)/chat/helpers";
export const WebhookDisclaimer = ({ nodeId }: { nodeId: string }) => {
const [{ flowID }] = useQueryStates({
flowID: parseAsString,
});
// for a single agentId, we are fetching everything - need to make it better in the future
const { data: libraryAgent } = useGetV2GetLibraryAgentByGraphId(
flowID ?? "",
{},
{
query: {
select: (x) => {
return x.data as LibraryAgent;
},
enabled: !!flowID,
},
},
);
const isNodeSaved = isValidUUID(nodeId);
return (
<>
<div className="px-4 pt-4">
<Alert className="mb-3 rounded-xlarge">
<AlertDescription>
<Text variant="small-medium">
You can set up and manage this trigger in your{" "}
<Link
href={
libraryAgent
? `/library/agents/${libraryAgent.id}`
: "/library"
}
className="underline"
>
Agent Library
</Link>
{!isNodeSaved && " (after saving the graph)"}.
</Text>
</AlertDescription>
</Alert>
</div>
<Text variant="small" className="mb-4 ml-6 !text-purple-700">
Below inputs are only for display purposes and cannot be edited.
</Text>
</>
);
};

View File

@@ -10,10 +10,14 @@ export const FormCreator = React.memo(
jsonSchema,
nodeId,
uiType,
showHandles = true,
className,
}: {
jsonSchema: RJSFSchema;
nodeId: string;
uiType: BlockUIType;
showHandles?: boolean;
className?: string;
}) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore(
@@ -29,18 +33,20 @@ export const FormCreator = React.memo(
const initialValues = getHardCodedValues(nodeId);
return (
<FormRenderer
jsonSchema={jsonSchema}
handleChange={handleChange}
uiSchema={uiSchema}
initialValues={initialValues}
formContext={{
nodeId: nodeId,
uiType: uiType,
showHandles: true,
size: "small",
}}
/>
<div className={className}>
<FormRenderer
jsonSchema={jsonSchema}
handleChange={handleChange}
uiSchema={uiSchema}
initialValues={initialValues}
formContext={{
nodeId: nodeId,
uiType: uiType,
showHandles: showHandles,
size: "small",
}}
/>
</div>
);
},
);

View File

@@ -8,6 +8,7 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore";
import { BlockUIType } from "../components/types";
type NodeStore = {
nodes: CustomNode[];
@@ -35,6 +36,8 @@ type NodeStore = {
result: NodeExecutionResult,
) => void;
getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
getNodeBlockUIType: (nodeId: string) => BlockUIType;
};
export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -164,4 +167,10 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
getNodeExecutionResult: (nodeId: string) => {
return get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResult;
},
getNodeBlockUIType: (nodeId: string) => {
return (
get().nodes.find((n) => n.id === nodeId)?.data?.uiType ??
BlockUIType.STANDARD
);
},
}));

View File

@@ -36,6 +36,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
}) => {
const { isInputConnected } = useEdgeStore();
const { nodeId, showHandles = true, size = "small" } = formContext;
const uiType = formContext.uiType;
const showAdvanced = useNodeStore(
(state) => state.nodeAdvancedStates[nodeId] ?? false,
@@ -79,10 +80,14 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
}
// Size-based styling
const shouldShowHandle =
let shouldShowHandle =
showHandles && !suppressHandle && !fromAnyOf && !isCredential;
// We do not want handle for output block's name field
if (uiType === BlockUIType.OUTPUT && fieldId === "root_name") {
shouldShowHandle = false;
}
return (
<div
className={cn(
@@ -107,6 +112,12 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
"line-clamp-1",
isCredential && !shouldShowHandle && "ml-3",
size == "large" && "ml-0",
uiType === BlockUIType.OUTPUT &&
fieldId === "root_name" &&
"ml-3",
uiType === BlockUIType.INPUT && "ml-3",
uiType === BlockUIType.WEBHOOK && "ml-3",
uiType === BlockUIType.WEBHOOK_MANUAL && "ml-3",
)}
>
{isCredential && credentialProvider