Merge branch 'dev' into figure-out-docs

This commit is contained in:
Nicholas Tindle
2026-01-07 21:22:41 -07:00
committed by GitHub
118 changed files with 4494 additions and 5631 deletions

View File

@@ -46,14 +46,15 @@
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.7",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@rjsf/core": "5.24.13",
"@rjsf/utils": "5.24.13",
"@rjsf/validator-ajv8": "5.24.13",
"@rjsf/core": "6.1.2",
"@rjsf/utils": "6.1.2",
"@rjsf/validator-ajv8": "6.1.2",
"@sentry/nextjs": "10.27.0",
"@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.78.0",

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal";
import { OAuthPopupResultMessage } from "./types";
import { NextResponse } from "next/server";
// This route is intended to be used as the callback for integration OAuth flows,

View File

@@ -0,0 +1,11 @@
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);

View File

@@ -5,7 +5,7 @@ import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { Button } from "@/components/atoms/Button/Button";
import { ClockIcon, PlayIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import { useRunInputDialog } from "./useRunInputDialog";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";

View File

@@ -8,7 +8,7 @@ import {
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useMemo, useState } from "react";
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
import { isCredentialFieldSchema } from "@/components/renderers/input-renderer/fields/CredentialField/helpers";
import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
export const useRunInputDialog = ({
setIsOpen,

View File

@@ -12,16 +12,59 @@ import {
import { useDraftRecoveryPopup } from "./useDraftRecoveryPopup";
import { Text } from "@/components/atoms/Text/Text";
import { AnimatePresence, motion } from "framer-motion";
import { DraftDiff } from "@/lib/dexie/draft-utils";
interface DraftRecoveryPopupProps {
isInitialLoadComplete: boolean;
}
function formatDiffSummary(diff: DraftDiff | null): string {
if (!diff) return "";
const parts: string[] = [];
// Node changes
const nodeChanges: string[] = [];
if (diff.nodes.added > 0) nodeChanges.push(`+${diff.nodes.added}`);
if (diff.nodes.removed > 0) nodeChanges.push(`-${diff.nodes.removed}`);
if (diff.nodes.modified > 0) nodeChanges.push(`~${diff.nodes.modified}`);
if (nodeChanges.length > 0) {
parts.push(
`${nodeChanges.join("/")} block${diff.nodes.added + diff.nodes.removed + diff.nodes.modified !== 1 ? "s" : ""}`,
);
}
// Edge changes
const edgeChanges: string[] = [];
if (diff.edges.added > 0) edgeChanges.push(`+${diff.edges.added}`);
if (diff.edges.removed > 0) edgeChanges.push(`-${diff.edges.removed}`);
if (diff.edges.modified > 0) edgeChanges.push(`~${diff.edges.modified}`);
if (edgeChanges.length > 0) {
parts.push(
`${edgeChanges.join("/")} connection${diff.edges.added + diff.edges.removed + diff.edges.modified !== 1 ? "s" : ""}`,
);
}
return parts.join(", ");
}
export function DraftRecoveryPopup({
isInitialLoadComplete,
}: DraftRecoveryPopupProps) {
const { isOpen, popupRef, nodeCount, edgeCount, savedAt, onLoad, onDiscard } =
useDraftRecoveryPopup(isInitialLoadComplete);
const {
isOpen,
popupRef,
nodeCount,
edgeCount,
diff,
savedAt,
onLoad,
onDiscard,
} = useDraftRecoveryPopup(isInitialLoadComplete);
const diffSummary = formatDiffSummary(diff);
return (
<AnimatePresence>
@@ -72,10 +115,9 @@ export function DraftRecoveryPopup({
variant="small"
className="text-amber-700 dark:text-amber-400"
>
{nodeCount} block{nodeCount !== 1 ? "s" : ""}, {edgeCount}{" "}
connection
{edgeCount !== 1 ? "s" : ""} {" "}
{formatTimeAgo(new Date(savedAt).toISOString())}
{diffSummary ||
`${nodeCount} block${nodeCount !== 1 ? "s" : ""}, ${edgeCount} connection${edgeCount !== 1 ? "s" : ""}`}{" "}
{formatTimeAgo(new Date(savedAt).toISOString())}
</Text>
</div>

View File

@@ -9,6 +9,7 @@ export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => {
savedAt,
nodeCount,
edgeCount,
diff,
loadDraft: onLoad,
discardDraft: onDiscard,
} = useDraftManager(isInitialLoadComplete);
@@ -54,6 +55,7 @@ export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => {
isOpen,
nodeCount,
edgeCount,
diff,
savedAt,
onLoad,
onDiscard,

View File

@@ -48,8 +48,6 @@ export const resolveCollisions: CollisionAlgorithm = (
const width = (node.width ?? node.measured?.width ?? 0) + margin * 2;
const height = (node.height ?? node.measured?.height ?? 0) + margin * 2;
console.log("width", width);
console.log("height", height);
const x = node.position.x - margin;
const y = node.position.y - margin;

View File

@@ -7,7 +7,12 @@ import {
DraftData,
} from "@/services/builder-draft/draft-service";
import { BuilderDraft } from "@/lib/dexie/db";
import { cleanNodes, cleanEdges } from "@/lib/dexie/draft-utils";
import {
cleanNodes,
cleanEdges,
calculateDraftDiff,
DraftDiff,
} from "@/lib/dexie/draft-utils";
import { useNodeStore } from "../../../stores/nodeStore";
import { useEdgeStore } from "../../../stores/edgeStore";
import { useGraphStore } from "../../../stores/graphStore";
@@ -19,6 +24,7 @@ const AUTO_SAVE_INTERVAL_MS = 15000; // 15 seconds
interface DraftRecoveryState {
isOpen: boolean;
draft: BuilderDraft | null;
diff: DraftDiff | null;
}
/**
@@ -31,6 +37,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
const [state, setState] = useState<DraftRecoveryState>({
isOpen: false,
draft: null,
diff: null,
});
const [{ flowID, flowVersion }] = useQueryStates({
@@ -207,9 +214,16 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
);
if (isDifferent && (draft.nodes.length > 0 || draft.edges.length > 0)) {
const diff = calculateDraftDiff(
draft.nodes,
draft.edges,
currentNodes,
currentEdges,
);
setState({
isOpen: true,
draft,
diff,
});
} else {
await draftService.deleteDraft(effectiveFlowId);
@@ -231,6 +245,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
setState({
isOpen: false,
draft: null,
diff: null,
});
}, [flowID]);
@@ -242,8 +257,10 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
try {
useNodeStore.getState().setNodes(draft.nodes);
useEdgeStore.getState().setEdges(draft.edges);
draft.nodes.forEach((node) => {
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
});
// Restore nodeCounter to prevent ID conflicts when adding new nodes
if (draft.nodeCounter !== undefined) {
useNodeStore.setState({ nodeCounter: draft.nodeCounter });
}
@@ -267,6 +284,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
setState({
isOpen: false,
draft: null,
diff: null,
});
} catch (error) {
console.error("[DraftRecovery] Failed to load draft:", error);
@@ -275,7 +293,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
const discardDraft = useCallback(async () => {
if (!state.draft) {
setState({ isOpen: false, draft: null });
setState({ isOpen: false, draft: null, diff: null });
return;
}
@@ -285,7 +303,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
console.error("[DraftRecovery] Failed to discard draft:", error);
}
setState({ isOpen: false, draft: null });
setState({ isOpen: false, draft: null, diff: null });
}, [state.draft]);
return {
@@ -294,6 +312,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) {
savedAt: state.draft?.savedAt ?? 0,
nodeCount: state.draft?.nodes.length ?? 0,
edgeCount: state.draft?.edges.length ?? 0,
diff: state.diff,
loadDraft,
discardDraft,
};

View File

@@ -121,6 +121,14 @@ export const useFlow = () => {
if (customNodes.length > 0) {
useNodeStore.getState().setNodes([]);
addNodes(customNodes);
// Sync hardcoded values with handle IDs.
// If a keyvalue field has a key without a value, the backend omits it from hardcoded values.
// But if a handleId exists for that key, it causes inconsistency.
// This ensures hardcoded values stay in sync with handle IDs.
customNodes.forEach((node) => {
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
});
}
}, [customNodes, addNodes]);

View File

@@ -1,12 +1,17 @@
import { Connection as RFConnection, EdgeChange } from "@xyflow/react";
import {
Connection as RFConnection,
EdgeChange,
applyEdgeChanges,
} from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useCallback } from "react";
import { useNodeStore } from "../../../stores/nodeStore";
import { CustomEdge } from "./CustomEdge";
export const useCustomEdge = () => {
const edges = useEdgeStore((s) => s.edges);
const addEdge = useEdgeStore((s) => s.addEdge);
const removeEdge = useEdgeStore((s) => s.removeEdge);
const setEdges = useEdgeStore((s) => s.setEdges);
const onConnect = useCallback(
(conn: RFConnection) => {
@@ -45,14 +50,10 @@ export const useCustomEdge = () => {
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
changes.forEach((change) => {
if (change.type === "remove") {
removeEdge(change.id);
}
});
(changes: EdgeChange<CustomEdge>[]) => {
setEdges(applyEdgeChanges(changes, edges));
},
[removeEdge],
[edges, setEdges],
);
return { edges, onConnect, onEdgesChange };

View File

@@ -1,26 +1,32 @@
import { CircleIcon } from "@phosphor-icons/react";
import { Handle, Position } from "@xyflow/react";
import { useEdgeStore } from "../../../stores/edgeStore";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
import { cn } from "@/lib/utils";
const NodeHandle = ({
const InputNodeHandle = ({
handleId,
isConnected,
side,
nodeId,
}: {
handleId: string;
isConnected: boolean;
side: "left" | "right";
nodeId: string;
}) => {
const cleanedHandleId = cleanUpHandleId(handleId);
const isInputConnected = useEdgeStore((state) =>
state.isInputConnected(nodeId ?? "", cleanedHandleId),
);
return (
<Handle
type={side === "left" ? "target" : "source"}
position={side === "left" ? Position.Left : Position.Right}
id={handleId}
className={side === "left" ? "-ml-4 mr-2" : "-mr-2 ml-2"}
type={"target"}
position={Position.Left}
id={cleanedHandleId}
className={"-ml-6 mr-2"}
>
<div className="pointer-events-none">
<CircleIcon
size={16}
weight={isConnected ? "fill" : "duotone"}
weight={isInputConnected ? "fill" : "duotone"}
className={"text-gray-400 opacity-100"}
/>
</div>
@@ -28,4 +34,35 @@ const NodeHandle = ({
);
};
export default NodeHandle;
const OutputNodeHandle = ({
field_name,
nodeId,
hexColor,
}: {
field_name: string;
nodeId: string;
hexColor: string;
}) => {
const isOutputConnected = useEdgeStore((state) =>
state.isOutputConnected(nodeId, field_name),
);
return (
<Handle
type={"source"}
position={Position.Right}
id={field_name}
className={"-mr-2 ml-2"}
>
<div className="pointer-events-none">
<CircleIcon
size={16}
weight={"duotone"}
color={isOutputConnected ? hexColor : "gray"}
className={cn("text-gray-400 opacity-100")}
/>
</div>
</Handle>
);
};
export { InputNodeHandle, OutputNodeHandle };

View File

@@ -1,31 +1,4 @@
/**
* Handle ID Types for different input structures
*
* Examples:
* SIMPLE: "message"
* NESTED: "config.api_key"
* ARRAY: "items_$_0", "items_$_1"
* KEY_VALUE: "headers_#_Authorization", "params_#_limit"
*
* Note: All handle IDs are sanitized to remove spaces and special characters.
* Spaces become underscores, and special characters are removed.
* Example: "user name" becomes "user_name", "email@domain.com" becomes "emaildomaincom"
*/
export enum HandleIdType {
SIMPLE = "SIMPLE",
NESTED = "NESTED",
ARRAY = "ARRAY",
KEY_VALUE = "KEY_VALUE",
}
const fromRjsfId = (id: string): string => {
if (!id) return "";
const parts = id.split("_");
const filtered = parts.filter(
(p) => p !== "root" && p !== "properties" && p.length > 0,
);
return filtered.join("_") || "";
};
// Here we are handling single level of nesting, if need more in future then i will update it
const sanitizeForHandleId = (str: string): string => {
if (!str) return "";
@@ -38,51 +11,53 @@ const sanitizeForHandleId = (str: string): string => {
.replace(/^_|_$/g, ""); // Remove leading/trailing underscores
};
export const generateHandleId = (
const cleanTitleId = (id: string): string => {
if (!id) return "";
if (id.endsWith("_title")) {
id = id.slice(0, -6);
}
const parts = id.split("_");
const filtered = parts.filter(
(p) => p !== "root" && p !== "properties" && p.length > 0,
);
const filtered_id = filtered.join("_") || "";
return filtered_id;
};
export const generateHandleIdFromTitleId = (
fieldKey: string,
nestedValues: string[] = [],
type: HandleIdType = HandleIdType.SIMPLE,
{
isObjectProperty,
isAdditionalProperty,
isArrayItem,
}: {
isArrayItem?: boolean;
isObjectProperty?: boolean;
isAdditionalProperty?: boolean;
} = {
isArrayItem: false,
isObjectProperty: false,
isAdditionalProperty: false,
},
): string => {
if (!fieldKey) return "";
fieldKey = fromRjsfId(fieldKey);
fieldKey = sanitizeForHandleId(fieldKey);
const filteredKey = cleanTitleId(fieldKey);
if (isAdditionalProperty || isArrayItem) {
return filteredKey;
}
const cleanedKey = sanitizeForHandleId(filteredKey);
if (type === HandleIdType.SIMPLE || nestedValues.length === 0) {
return fieldKey;
if (isObjectProperty) {
// "config_api_key" -> "config.api_key"
const parts = cleanedKey.split("_");
if (parts.length >= 2) {
const baseName = parts[0];
const propertyName = parts.slice(1).join("_");
return `${baseName}.${propertyName}`;
}
}
const sanitizedNestedValues = nestedValues.map((value) =>
sanitizeForHandleId(value),
);
switch (type) {
case HandleIdType.NESTED:
return [fieldKey, ...sanitizedNestedValues].join(".");
case HandleIdType.ARRAY:
return [fieldKey, ...sanitizedNestedValues].join("_$_");
case HandleIdType.KEY_VALUE:
return [fieldKey, ...sanitizedNestedValues].join("_#_");
default:
return fieldKey;
}
};
export const parseKeyValueHandleId = (
handleId: string,
type: HandleIdType,
): string => {
if (type === HandleIdType.KEY_VALUE) {
return handleId.split("_#_")[1];
} else if (type === HandleIdType.ARRAY) {
return handleId.split("_$_")[1];
} else if (type === HandleIdType.NESTED) {
return handleId.split(".")[1];
} else if (type === HandleIdType.SIMPLE) {
return handleId.split("_")[1];
}
return "";
return cleanedKey;
};

View File

@@ -10,7 +10,7 @@ import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutio
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 { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
import { OutputHandler } from "../OutputHandler";
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
@@ -99,7 +99,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
nodeId={nodeId}
uiType={data.uiType}
className={cn(
"bg-white pr-6",
"bg-white px-4",
isWebhook && "pointer-events-none opacity-50",
)}
showHandles={showHandles}

View File

@@ -8,7 +8,7 @@ export const NodeAdvancedToggle = ({ nodeId }: { nodeId: string }) => {
);
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">
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white px-5 py-3.5">
<Text variant="body" className="font-medium text-slate-700">
Advanced
</Text>

View File

@@ -22,7 +22,7 @@ export const NodeContainer = ({
return (
<div
className={cn(
"z-12 max-w-[370px] rounded-xlarge ring-1 ring-slate-200/60",
"z-12 w-[350px] rounded-xlarge ring-1 ring-slate-200/60",
selected && "shadow-lg ring-2 ring-slate-200",
status && nodeStyleBasedOnStatus[status],
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",

View File

@@ -23,7 +23,9 @@ export const NodeHeader = ({
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const title = (data.metadata?.customized_name as string) || data.title;
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(title);
const [editedTitle, setEditedTitle] = useState(
beautifyString(title).replace("Block", "").trim(),
);
const handleTitleEdit = () => {
updateNodeData(nodeId, {
@@ -41,7 +43,7 @@ export const NodeHeader = ({
};
return (
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-zinc-200 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
{/* Title row with context menu */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
@@ -68,12 +70,12 @@ export const NodeHeader = ({
<TooltipTrigger asChild>
<div>
<Text variant="large-semibold" className="line-clamp-1">
{beautifyString(title)}
{beautifyString(title).replace("Block", "").trim()}
</Text>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{beautifyString(title)}</p>
<p>{beautifyString(title).replace("Block", "").trim()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -23,7 +23,7 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
}
return (
<div className="flex flex-col gap-3 rounded-b-xl border-t border-slate-200/50 px-4 py-4">
<div className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4">
<div className="flex items-center justify-between">
<Text variant="body-medium" className="!font-semibold text-slate-700">
Node Output

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { FormCreator } from "../../FormCreator";
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
import { CustomNodeData } from "../CustomNode";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";

View File

@@ -3,7 +3,7 @@ import React from "react";
import { uiSchema } from "./uiSchema";
import { useNodeStore } from "../../../stores/nodeStore";
import { BlockUIType } from "../../types";
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
export const FormCreator = React.memo(
({

View File

@@ -4,7 +4,7 @@ import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react";
import { RJSFSchema } from "@rjsf/utils";
import { useState } from "react";
import NodeHandle from "../handlers/NodeHandle";
import { OutputNodeHandle } from "../handlers/NodeHandle";
import {
Tooltip,
TooltipContent,
@@ -13,7 +13,6 @@ import {
} from "@/components/atoms/Tooltip/BaseTooltip";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { getTypeDisplayInfo } from "./helpers";
import { generateHandleId } from "../handlers/helpers";
import { BlockUIType } from "../../types";
export const OutputHandler = ({
@@ -29,8 +28,73 @@ export const OutputHandler = ({
const properties = outputSchema?.properties || {};
const [isOutputVisible, setIsOutputVisible] = useState(true);
const showHandles = uiType !== BlockUIType.OUTPUT;
const renderOutputHandles = (
schema: RJSFSchema,
keyPrefix: string = "",
titlePrefix: string = "",
): React.ReactNode[] => {
return Object.entries(schema).map(
([key, fieldSchema]: [string, RJSFSchema]) => {
const fullKey = keyPrefix ? `${keyPrefix}_#_${key}` : key;
const fieldTitle = titlePrefix + (fieldSchema?.title || key);
const isConnected = isOutputConnected(nodeId, fullKey);
const shouldShow = isConnected || isOutputVisible;
const { displayType, colorClass, hexColor } =
getTypeDisplayInfo(fieldSchema);
return shouldShow ? (
<div key={fullKey} className="flex flex-col items-end gap-2">
<div className="relative flex items-center gap-2">
{fieldSchema?.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
style={{ marginLeft: 6, cursor: "pointer" }}
aria-label="info"
tabIndex={0}
>
<InfoIcon />
</span>
</TooltipTrigger>
<TooltipContent>{fieldSchema?.description}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Text variant="body" className="text-slate-700">
{fieldTitle}
</Text>
<Text variant="small" as="span" className={colorClass}>
({displayType})
</Text>
{showHandles && (
<OutputNodeHandle
field_name={fullKey}
nodeId={nodeId}
hexColor={hexColor}
/>
)}
</div>
{/* Recursively render nested properties */}
{fieldSchema?.properties &&
renderOutputHandles(
fieldSchema.properties,
fullKey,
`${fieldTitle}.`,
)}
</div>
) : null;
},
);
};
return (
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white py-3.5">
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white py-3.5">
<Button
variant="ghost"
className="mr-4 h-fit min-w-0 p-0 hover:border-transparent hover:bg-transparent"
@@ -49,50 +113,9 @@ export const OutputHandler = ({
</Text>
</Button>
{
<div className="flex flex-col items-end gap-2">
{Object.entries(properties).map(([key, property]: [string, any]) => {
const isConnected = isOutputConnected(nodeId, key);
const shouldShow = isConnected || isOutputVisible;
const { displayType, colorClass } = getTypeDisplayInfo(property);
return shouldShow ? (
<div key={key} className="relative flex items-center gap-2">
{property?.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
style={{ marginLeft: 6, cursor: "pointer" }}
aria-label="info"
tabIndex={0}
>
<InfoIcon />
</span>
</TooltipTrigger>
<TooltipContent>{property?.description}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Text variant="body" className="text-slate-700">
{property?.title || key}{" "}
</Text>
<Text variant="small" as="span" className={colorClass}>
({displayType})
</Text>
<NodeHandle
handleId={
uiType === BlockUIType.AGENT ? key : generateHandleId(key)
}
isConnected={isConnected}
side="right"
/>
</div>
) : null;
})}
</div>
}
<div className="flex flex-col items-end gap-2">
{renderOutputHandles(properties)}
</div>
</div>
);
};

View File

@@ -92,14 +92,38 @@ export const getTypeDisplayInfo = (schema: any) => {
if (schema?.type === "string" && schema?.format) {
const formatMap: Record<
string,
{ displayType: string; colorClass: string }
{ displayType: string; colorClass: string; hexColor: string }
> = {
file: { displayType: "file", colorClass: "!text-green-500" },
date: { displayType: "date", colorClass: "!text-blue-500" },
time: { displayType: "time", colorClass: "!text-blue-500" },
"date-time": { displayType: "datetime", colorClass: "!text-blue-500" },
"long-text": { displayType: "text", colorClass: "!text-green-500" },
"short-text": { displayType: "text", colorClass: "!text-green-500" },
file: {
displayType: "file",
colorClass: "!text-green-500",
hexColor: "#22c55e",
},
date: {
displayType: "date",
colorClass: "!text-blue-500",
hexColor: "#3b82f6",
},
time: {
displayType: "time",
colorClass: "!text-blue-500",
hexColor: "#3b82f6",
},
"date-time": {
displayType: "datetime",
colorClass: "!text-blue-500",
hexColor: "#3b82f6",
},
"long-text": {
displayType: "text",
colorClass: "!text-green-500",
hexColor: "#22c55e",
},
"short-text": {
displayType: "text",
colorClass: "!text-green-500",
hexColor: "#22c55e",
},
};
const formatInfo = formatMap[schema.format];
@@ -131,10 +155,23 @@ export const getTypeDisplayInfo = (schema: any) => {
any: "!text-gray-500",
};
const hexColorMap: Record<string, string> = {
string: "#22c55e",
number: "#3b82f6",
integer: "#3b82f6",
boolean: "#eab308",
object: "#a855f7",
array: "#6366f1",
null: "#6b7280",
any: "#6b7280",
};
const colorClass = colorMap[schema?.type] || "!text-gray-500";
const hexColor = hexColorMap[schema?.type] || "#6b7280";
return {
displayType,
colorClass,
hexColor,
};
};

View File

@@ -4,6 +4,7 @@ import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
import { customEdgeToLink, linkToCustomEdge } from "../components/helper";
import { MarkerType } from "@xyflow/react";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
type EdgeStore = {
edges: CustomEdge[];
@@ -13,6 +14,8 @@ type EdgeStore = {
removeEdge: (edgeId: string) => void;
upsertMany: (edges: CustomEdge[]) => void;
removeEdgesByHandlePrefix: (nodeId: string, handlePrefix: string) => void;
getNodeEdges: (nodeId: string) => CustomEdge[];
isInputConnected: (nodeId: string, handle: string) => boolean;
isOutputConnected: (nodeId: string, handle: string) => boolean;
@@ -79,11 +82,27 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
return { edges: Array.from(byKey.values()) };
}),
removeEdgesByHandlePrefix: (nodeId, handlePrefix) =>
set((state) => ({
edges: state.edges.filter(
(e) =>
!(
e.target === nodeId &&
e.targetHandle &&
e.targetHandle.startsWith(handlePrefix)
),
),
})),
getNodeEdges: (nodeId) =>
get().edges.filter((e) => e.source === nodeId || e.target === nodeId),
isInputConnected: (nodeId, handle) =>
get().edges.some((e) => e.target === nodeId && e.targetHandle === handle),
isInputConnected: (nodeId, handle) => {
const cleanedHandle = cleanUpHandleId(handle);
return get().edges.some(
(e) => e.target === nodeId && e.targetHandle === cleanedHandle,
);
},
isOutputConnected: (nodeId, handle) =>
get().edges.some((e) => e.source === nodeId && e.sourceHandle === handle),
@@ -105,15 +124,15 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
targetNodeId: string,
executionResult: NodeExecutionResult,
) => {
set((state) => ({
edges: state.edges.map((edge) => {
set((state) => {
let hasChanges = false;
const newEdges = state.edges.map((edge) => {
if (edge.target !== targetNodeId) {
return edge;
}
const beadData =
edge.data?.beadData ??
new Map<string, NodeExecutionResult["status"]>();
const beadData = new Map(edge.data?.beadData ?? new Map());
const inputValue = edge.targetHandle
? executionResult.input_data[edge.targetHandle]
@@ -137,6 +156,11 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
beadUp = beadDown + 1;
}
if (edge.data?.beadUp === beadUp && edge.data?.beadDown === beadDown) {
return edge;
}
hasChanges = true;
return {
...edge,
data: {
@@ -146,8 +170,10 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
beadData,
},
};
}),
}));
});
return hasChanges ? { edges: newEdges } : state;
});
},
resetEdgeBeads: () => {

View File

@@ -13,6 +13,10 @@ import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore";
import { BlockUIType } from "../components/types";
import { pruneEmptyValues } from "@/lib/utils";
import {
ensurePathExists,
parseHandleIdToPath,
} from "@/components/renderers/InputRenderer/helpers";
// Minimum movement (in pixels) required before logging position change to history
// Prevents spamming history with small movements when clicking on inputs inside blocks
@@ -62,6 +66,8 @@ type NodeStore = {
errors: { [key: string]: string },
) => void;
clearAllNodeErrors: () => void; // Add this
syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
};
export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -305,4 +311,35 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
})),
}));
},
syncHardcodedValuesWithHandleIds: (nodeId: string) => {
const node = get().nodes.find((n) => n.id === nodeId);
if (!node) return;
const handleIds = useEdgeStore.getState().getAllHandleIdsOfANode(nodeId);
const additionalHandles = handleIds.filter((h) => h.includes("_#_"));
if (additionalHandles.length === 0) return;
const hardcodedValues = JSON.parse(
JSON.stringify(node.data.hardcodedValues || {}),
);
let modified = false;
additionalHandles.forEach((handleId) => {
const segments = parseHandleIdToPath(handleId);
if (ensurePathExists(hardcodedValues, segments)) {
modified = true;
}
});
if (modified) {
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, hardcodedValues } } : n,
),
}));
}
},
}));

View File

@@ -143,6 +143,7 @@ export function CredentialsInput({
size="small"
onClick={handleActionButtonClick}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
@@ -155,6 +156,7 @@ export function CredentialsInput({
size="small"
onClick={handleActionButtonClick}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>

View File

@@ -13,7 +13,7 @@ import {
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { providerIcons } from "@/components/renderers/input-renderer/fields/CredentialField/helpers";
import { providerIcons } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
import { CredentialsProviderName } from "@/lib/autogpt-server-api";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";

View File

@@ -49,6 +49,7 @@ export function GoogleDrivePicker(props: Props) {
)}
<Button
size="small"
type="button"
onClick={handleOpenPicker}
disabled={props.disabled || isLoading || isAuthInProgress}
>

View File

@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
import React, { useCallback } from "react";
import { GoogleDrivePicker } from "./GoogleDrivePicker";
import { isValidFile } from "./helpers";
export interface Props {
config: GoogleDrivePickerConfig;
@@ -27,13 +28,15 @@ export function GoogleDrivePickerInput({
const hasAutoCredentials = !!config.auto_credentials;
// Strip _credentials_id from value for display purposes
const currentFiles = isMultiSelect
? Array.isArray(value)
? value
: []
: value
? [value]
: [];
// Only show files section when there are valid file objects
const currentFiles = React.useMemo(() => {
if (isMultiSelect) {
if (!Array.isArray(value)) return [];
return value.filter(isValidFile);
}
if (!value || !isValidFile(value)) return [];
return [value];
}, [value, isMultiSelect]);
const handlePicked = useCallback(
(files: any[], credentialId?: string) => {
@@ -85,23 +88,27 @@ export function GoogleDrivePickerInput({
return (
<div className={cn("flex flex-col gap-2", className)}>
{/* Picker Button */}
<GoogleDrivePicker
multiselect={config.multiselect || false}
views={config.allowed_views || ["DOCS"]}
scopes={config.scopes || ["https://www.googleapis.com/auth/drive.file"]}
disabled={false}
requirePlatformCredentials={hasAutoCredentials}
onPicked={handlePicked}
onCanceled={() => {
// User canceled - no action needed
}}
onError={handleError}
/>
<div className="mb-4">
{/* Picker Button */}
<GoogleDrivePicker
multiselect={config.multiselect || false}
views={config.allowed_views || ["DOCS"]}
scopes={
config.scopes || ["https://www.googleapis.com/auth/drive.file"]
}
disabled={false}
requirePlatformCredentials={hasAutoCredentials}
onPicked={handlePicked}
onCanceled={() => {
// User canceled - no action needed
}}
onError={handleError}
/>
</div>
{/* Display Selected Files */}
{currentFiles.length > 0 && (
<div className="space-y-1">
<div className="mb-8 space-y-1">
{currentFiles.map((file: any, idx: number) => (
<div
key={file.id || idx}

View File

@@ -119,3 +119,14 @@ export function getCredentialsSchema(scopes: string[]) {
secret: true,
} satisfies BlockIOCredentialsSubSchema;
}
export function isValidFile(
file: unknown,
): file is { id?: string; name?: string } {
return (
typeof file === "object" &&
file !== null &&
(typeof (file as { id?: unknown }).id === "string" ||
typeof (file as { name?: unknown }).name === "string")
);
}

View File

@@ -1,26 +1,16 @@
import { BlockUIType } from "@/app/(platform)/build/components/types";
import Form from "@rjsf/core";
import { RJSFSchema } from "@rjsf/utils";
import { fields } from "./fields";
import { templates } from "./templates";
import { widgets } from "./widgets";
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
import { useMemo } from "react";
import { customValidator } from "./utils/custom-validator";
type FormContextType = {
nodeId?: string;
uiType?: BlockUIType;
showHandles?: boolean;
size?: "small" | "medium" | "large";
};
import Form from "./registry";
import { ExtendedFormContextType } from "./types";
type FormRendererProps = {
jsonSchema: RJSFSchema;
handleChange: (formData: any) => void;
uiSchema: any;
initialValues: any;
formContext: FormContextType;
formContext: ExtendedFormContextType;
};
export const FormRenderer = ({
@@ -33,19 +23,18 @@ export const FormRenderer = ({
const preprocessedSchema = useMemo(() => {
return preprocessInputSchema(jsonSchema);
}, [jsonSchema]);
return (
<div className={"mt-4"}>
<div className={"mb-6 mt-4"}>
<Form
formContext={formContext}
idPrefix="agpt"
idSeparator="_%_"
schema={preprocessedSchema}
validator={customValidator}
fields={fields}
templates={templates}
widgets={widgets}
formContext={formContext}
onChange={handleChange}
uiSchema={uiSchema}
formData={initialValues}
noValidate={true}
liveValidate={false}
/>
</div>

View File

@@ -0,0 +1,86 @@
import { FieldProps, getUiOptions, getWidget } from "@rjsf/utils";
import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle";
import { isEmpty } from "lodash";
import { useAnyOfField } from "./useAnyOfField";
import { getHandleId, updateUiOption } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { ANY_OF_FLAG } from "../../constants";
export const AnyOfField = (props: FieldProps) => {
const { registry, schema } = props;
const { fields } = registry;
const { SchemaField: _SchemaField } = fields;
const { nodeId } = registry.formContext;
const { isInputConnected } = useEdgeStore();
const uiOptions = getUiOptions(props.uiSchema, props.globalUiOptions);
const Widget = getWidget({ type: "string" }, "select", registry.widgets);
const {
handleOptionChange,
enumOptions,
selectedOption,
optionSchema,
field_id,
} = useAnyOfField(props);
const handleId = getHandleId({
uiOptions,
id: field_id + ANY_OF_FLAG,
schema: schema,
});
const updatedUiSchema = updateUiOption(props.uiSchema, {
handleId: handleId,
label: false,
fromAnyOf: true,
});
const isHandleConnected = isInputConnected(nodeId, handleId);
const optionsSchemaField =
(optionSchema && optionSchema.type !== "null" && (
<_SchemaField
{...props}
schema={optionSchema}
uiSchema={updatedUiSchema}
/>
)) ||
null;
const selector = (
<Widget
id={field_id}
name={`${props.name}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`}
schema={{ type: "number", default: 0 }}
onChange={handleOptionChange}
onBlur={props.onBlur}
onFocus={props.onFocus}
disabled={props.disabled || isEmpty(enumOptions)}
multiple={false}
value={selectedOption >= 0 ? selectedOption : undefined}
options={{ enumOptions }}
registry={registry}
placeholder={props.placeholder}
autocomplete={props.autocomplete}
className="-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium"
autofocus={props.autofocus}
label=""
hideLabel={true}
readonly={props.readonly}
/>
);
return (
<div>
<AnyOfFieldTitle
{...props}
selector={selector}
uiSchema={updatedUiSchema}
/>
{!isHandleConnected && optionsSchemaField}
</div>
);
};

View File

@@ -0,0 +1,78 @@
import {
descriptionId,
FieldProps,
getTemplate,
getUiOptions,
titleId,
} from "@rjsf/utils";
import { shouldShowTypeSelector } from "../helpers";
import { useIsArrayItem } from "../../array/context/array-item-context";
import { cleanUpHandleId } from "../../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { Text } from "@/components/atoms/Text/Text";
import { isOptionalType } from "../../../utils/schema-utils";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { cn } from "@/lib/utils";
interface customFieldProps extends FieldProps {
selector: JSX.Element;
}
export const AnyOfFieldTitle = (props: customFieldProps) => {
const { uiSchema, schema, required, name, registry, fieldPathId, selector } =
props;
const { isInputConnected } = useEdgeStore();
const { nodeId } = registry.formContext;
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
uiOptions,
);
const title_id = titleId(fieldPathId ?? "");
const description_id = descriptionId(fieldPathId ?? "");
const isArrayItem = useIsArrayItem();
const handleId = cleanUpHandleId(uiOptions.handleId);
const isHandleConnected = isInputConnected(nodeId, handleId);
const { isOptional, type } = isOptionalType(schema); // If we have something like int | null = we will treat it as optional int
const { displayType, colorClass } = getTypeDisplayInfo(type);
const shouldShowSelector =
shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected;
const shoudlShowType = isHandleConnected || (isOptional && type);
return (
<div className="flex items-center gap-2">
<TitleFieldTemplate
id={title_id}
title={schema.title || name || ""}
required={required}
schema={schema}
registry={registry}
uiSchema={uiSchema}
/>
{shoudlShowType && (
<Text variant="small" className={cn("text-zinc-700", colorClass)}>
{isOptional ? `(${displayType})` : "(any)"}
</Text>
)}
{shouldShowSelector && selector}
<DescriptionFieldTemplate
id={description_id}
description={schema.description || ""}
schema={schema}
registry={registry}
/>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import { RJSFSchema, StrictRJSFSchema } from "@rjsf/utils";
const TYPE_PRIORITY = [
"string",
"number",
"integer",
"boolean",
"array",
"object",
] as const;
export function getDefaultTypeIndex(options: StrictRJSFSchema[]): number {
for (const preferredType of TYPE_PRIORITY) {
const index = options.findIndex((opt) => opt.type === preferredType);
if (index >= 0) return index;
}
const nonNullIndex = options.findIndex((opt) => opt.type !== "null");
return nonNullIndex >= 0 ? nonNullIndex : 0;
}
/**
* Determines if a type selector should be shown for an anyOf schema
* Returns false for simple optional types (type | null)
* Returns true for complex anyOf (3+ types or multiple non-null types)
*/
export function shouldShowTypeSelector(
schema: RJSFSchema | undefined,
): boolean {
const anyOf = schema?.anyOf;
if (!anyOf || !Array.isArray(anyOf) || anyOf.length === 0) {
return false;
}
if (anyOf.length === 2 && anyOf.some((opt: any) => opt.type === "null")) {
return false;
}
return anyOf.length >= 3;
}
export function isSimpleOptional(schema: RJSFSchema | undefined): boolean {
const anyOf = schema?.anyOf;
return (
Array.isArray(anyOf) &&
anyOf.length === 2 &&
anyOf.some((opt: any) => opt.type === "null")
);
}
export function getOptionalType(
schema: RJSFSchema | undefined,
): string | undefined {
if (!isSimpleOptional(schema)) {
return undefined;
}
const anyOf = schema?.anyOf;
const nonNullOption = anyOf?.find((opt: any) => opt.type !== "null");
return nonNullOption ? (nonNullOption as any).type : undefined;
}

View File

@@ -0,0 +1,96 @@
import { FieldProps, getFirstMatchingOption, mergeSchemas } from "@rjsf/utils";
import { useRef, useState } from "react";
import validator from "@rjsf/validator-ajv8";
import { getDefaultTypeIndex } from "./helpers";
import { cleanUpHandleId } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
export const useAnyOfField = (props: FieldProps) => {
const { registry, schema, options, onChange, formData } = props;
const { schemaUtils } = registry;
const getInitialOption = () => {
if (formData !== undefined && formData !== null) {
const option = getFirstMatchingOption(
validator,
formData,
options,
schema,
);
return option !== undefined ? option : getDefaultTypeIndex(options);
}
return getDefaultTypeIndex(options);
};
const [selectedOption, setSelectedOption] =
useState<number>(getInitialOption());
const retrievedOptions = useRef<any[]>(
options.map((opt: any) => schemaUtils.retrieveSchema(opt, formData)),
);
const option =
selectedOption >= 0
? retrievedOptions.current[selectedOption] || null
: null;
let optionSchema: any | undefined | null;
// adding top level required to each option schema
if (option) {
const { required } = schema;
optionSchema = required
? (mergeSchemas({ required }, option) as any)
: option;
}
const field_id = props.fieldPathId.$id;
const handleOptionChange = (option?: string) => {
const intOption = option !== undefined ? parseInt(option, 10) : -1;
if (intOption === selectedOption) return;
const newOption =
intOption >= 0 ? retrievedOptions.current[intOption] : undefined;
const oldOption =
selectedOption >= 0
? retrievedOptions.current[selectedOption]
: undefined;
// When we change the option, we need to clean the form data
let newFormData = schemaUtils.sanitizeDataForNewSchema(
newOption,
oldOption,
formData,
);
const handlePrefix = cleanUpHandleId(field_id);
console.log("handlePrefix", handlePrefix);
useEdgeStore
.getState()
.removeEdgesByHandlePrefix(registry.formContext.nodeId, handlePrefix);
// We have cleaned the form data, now we need to get the default form state of new selected option
if (newOption) {
newFormData = schemaUtils.getDefaultFormState(
newOption,
newFormData,
"excludeObjectChildren",
) as any;
}
setSelectedOption(intOption);
onChange(newFormData, props.fieldPathId.path, undefined, field_id);
};
const enumOptions = retrievedOptions.current.map((option, index) => ({
value: index,
label: option.type,
}));
return {
handleOptionChange,
enumOptions,
selectedOption,
optionSchema,
field_id,
};
};

View File

@@ -0,0 +1,34 @@
import {
ArrayFieldItemTemplateProps,
getTemplate,
getUiOptions,
} from "@rjsf/utils";
export default function ArrayFieldItemTemplate(
props: ArrayFieldItemTemplateProps,
) {
const { children, buttonsProps, hasToolbar, uiSchema, registry } = props;
const uiOptions = getUiOptions(uiSchema);
const ArrayFieldItemButtonsTemplate = getTemplate(
"ArrayFieldItemButtonsTemplate",
registry,
uiOptions,
);
return (
<div>
<div className="mb-2 flex flex-row flex-wrap items-center">
<div className="shrink grow">
<div className="shrink grow">{children}</div>
</div>
<div className="flex items-end justify-end">
{hasToolbar && (
<div className="-mt-4 mb-2 flex gap-2">
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import {
ArrayFieldTemplateProps,
buttonId,
getTemplate,
getUiOptions,
} from "@rjsf/utils";
import { getHandleId, updateUiOption } from "../../helpers";
export default function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
const {
canAdd,
disabled,
fieldPathId,
uiSchema,
items,
optionalDataControl,
onAddClick,
readonly,
registry,
required,
schema,
title,
} = props;
const uiOptions = getUiOptions(uiSchema);
const ArrayFieldDescriptionTemplate = getTemplate(
"ArrayFieldDescriptionTemplate",
registry,
uiOptions,
);
const ArrayFieldTitleTemplate = getTemplate(
"ArrayFieldTitleTemplate",
registry,
uiOptions,
);
const showOptionalDataControlInTitle = !readonly && !disabled;
const {
ButtonTemplates: { AddButton },
} = registry.templates;
const { fromAnyOf } = uiOptions;
const handleId = getHandleId({
uiOptions,
id: fieldPathId.$id,
schema: schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
return (
<div>
<div className="m-0 flex p-0">
<div className="m-0 w-full space-y-4 p-0">
{!fromAnyOf && (
<div className="flex items-center">
<ArrayFieldTitleTemplate
fieldPathId={fieldPathId}
title={uiOptions.title || title}
schema={schema}
uiSchema={updatedUiSchema}
required={required}
registry={registry}
optionalDataControl={
showOptionalDataControlInTitle
? optionalDataControl
: undefined
}
/>
<ArrayFieldDescriptionTemplate
fieldPathId={fieldPathId}
description={uiOptions.description || schema.description}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
</div>
)}
<div
key={`array-item-list-${fieldPathId.$id}`}
className="m-0 mb-2 w-full p-0"
>
{!showOptionalDataControlInTitle ? optionalDataControl : undefined}
{items}
{canAdd && (
<div className="mt-4 flex justify-end">
<AddButton
id={buttonId(fieldPathId, "add")}
className="rjsf-array-item-add"
onClick={onAddClick}
disabled={disabled || readonly}
uiSchema={updatedUiSchema}
registry={registry}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { FieldProps, getUiOptions } from "@rjsf/utils";
import { getHandleId, updateUiOption } from "../../helpers";
import { ARRAY_ITEM_FLAG } from "../../constants";
const ArraySchemaField = (props: FieldProps) => {
const { index, registry, fieldPathId } = props;
const { SchemaField } = registry.fields;
const uiOptions = getUiOptions(props.uiSchema);
const handleId = getHandleId({
uiOptions,
id: fieldPathId.$id,
schema: props.schema,
});
const updatedUiSchema = updateUiOption(props.uiSchema, {
handleId: handleId + ARRAY_ITEM_FLAG,
});
return (
<SchemaField
{...props}
uiSchema={updatedUiSchema}
title={"_item-" + index.toString()}
/>
);
};
export default ArraySchemaField;

View File

@@ -0,0 +1,33 @@
import React, { createContext, useContext } from "react";
interface ArrayItemContextValue {
isArrayItem: boolean;
arrayItemHandleId: string;
}
const ArrayItemContext = createContext<ArrayItemContextValue>({
isArrayItem: false,
arrayItemHandleId: "",
});
export const ArrayItemProvider: React.FC<{
children: React.ReactNode;
arrayItemHandleId: string;
}> = ({ children, arrayItemHandleId }) => {
return (
<ArrayItemContext.Provider value={{ isArrayItem: true, arrayItemHandleId }}>
{children}
</ArrayItemContext.Provider>
);
};
export const useIsArrayItem = (): boolean => {
// here this will be true if field is inside an array
const context = useContext(ArrayItemContext);
return context.isArrayItem;
};
export const useArrayItemHandleId = (): string => {
const context = useContext(ArrayItemContext);
return context.arrayItemHandleId;
};

View File

@@ -0,0 +1,3 @@
export const generateArrayItemHandleId = (id: string) => {
return `array-item-${id}`;
};

View File

@@ -0,0 +1,7 @@
export { default as ArrayFieldTemplate } from "./ArrayFieldTemplate";
export { default as ArrayFieldItemTemplate } from "./ArrayFieldItemTemplate";
export { default as ArraySchemaField } from "./ArraySchemaField";
export {
ArrayItemProvider,
useIsArrayItem,
} from "./context/array-item-context";

View File

@@ -0,0 +1,71 @@
import {
RegistryFieldsType,
RegistryWidgetsType,
TemplatesType,
} from "@rjsf/utils";
import { AnyOfField } from "./anyof/AnyOfField";
import {
ArrayFieldItemTemplate,
ArrayFieldTemplate,
ArraySchemaField,
} from "./array";
import {
ObjectFieldTemplate,
OptionalDataControlsTemplate,
WrapIfAdditionalTemplate,
} from "./object";
import { DescriptionField, FieldTemplate, TitleField } from "./standard";
import { AddButton, CopyButton, RemoveButton } from "./standard/buttons";
import {
CheckboxWidget,
DateTimeWidget,
DateWidget,
FileWidget,
GoogleDrivePickerWidget,
SelectWidget,
TextWidget,
TimeWidget,
} from "./standard/widgets";
const NoButton = () => null;
export function generateBaseFields(): RegistryFieldsType {
return {
AnyOfField,
ArraySchemaField,
};
}
export function generateBaseTemplates(): Partial<TemplatesType> {
return {
ArrayFieldItemTemplate,
ArrayFieldTemplate,
ButtonTemplates: {
AddButton,
CopyButton,
MoveDownButton: NoButton,
MoveUpButton: NoButton,
RemoveButton,
SubmitButton: NoButton,
},
DescriptionFieldTemplate: DescriptionField,
FieldTemplate,
ObjectFieldTemplate,
OptionalDataControlsTemplate,
TitleFieldTemplate: TitleField,
WrapIfAdditionalTemplate,
};
}
export function generateBaseWidgets(): RegistryWidgetsType {
return {
TextWidget,
SelectWidget,
CheckboxWidget,
FileWidget,
DateWidget,
TimeWidget,
DateTimeWidget,
GoogleDrivePickerWidget,
};
}

View File

@@ -0,0 +1,5 @@
export * from "./array";
export * from "./object";
export * from "./standard";
export * from "./standard/widgets";
export * from "./standard/buttons";

View File

@@ -0,0 +1,122 @@
import {
ADDITIONAL_PROPERTY_FLAG,
buttonId,
canExpand,
descriptionId,
getTemplate,
getUiOptions,
ObjectFieldTemplateProps,
titleId,
} from "@rjsf/utils";
import { getHandleId, updateUiOption } from "../../helpers";
import React from "react";
export default function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const {
description,
title,
properties,
required,
uiSchema,
fieldPathId,
schema,
formData,
optionalDataControl,
onAddProperty,
disabled,
readonly,
registry,
} = props;
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
uiOptions,
);
const showOptionalDataControlInTitle = !readonly && !disabled;
const {
ButtonTemplates: { AddButton },
} = registry.templates;
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const handleId = getHandleId({
uiOptions,
id: fieldPathId.$id,
schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
return (
<>
<div className="flex items-center gap-2">
{title && !additional && (
<TitleFieldTemplate
id={titleId(fieldPathId)}
title={title}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
optionalDataControl={true ? optionalDataControl : undefined}
/>
)}
{description && (
<DescriptionFieldTemplate
id={descriptionId(fieldPathId)}
description={description}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
)}
</div>
<div className="flex flex-col">
{!showOptionalDataControlInTitle ? optionalDataControl : undefined}
{/* I have cloned it - so i could pass updated uiSchema to the nested children */}
{properties.map((element: any, index: number) => {
const clonedContent = React.cloneElement(element.content, {
...element.content.props,
uiSchema: updateUiOption(element.content.props.uiSchema, {
handleId: handleId,
}),
});
return (
<div
key={index}
className={`${element.hidden ? "hidden" : ""} flex`}
>
<div className="w-full">{clonedContent}</div>
</div>
);
})}
{canExpand(schema, uiSchema, formData) ? (
<div className="mt-2 flex justify-end">
<AddButton
id={buttonId(fieldPathId, "add")}
onClick={onAddProperty}
disabled={disabled || readonly}
className="rjsf-object-property-expand"
uiSchema={updatedUiSchema}
registry={registry}
/>
</div>
) : null}
</div>
</>
);
}

View File

@@ -0,0 +1,35 @@
import { OptionalDataControlsTemplateProps } from "@rjsf/utils";
import { PlusCircle } from "lucide-react";
import { IconButton, RemoveButton } from "../standard/buttons";
export default function OptionalDataControlsTemplate(
props: OptionalDataControlsTemplateProps,
) {
const { id, registry, label, onAddClick, onRemoveClick } = props;
if (onAddClick) {
return (
<IconButton
id={id}
registry={registry}
className="rjsf-add-optional-data"
onClick={onAddClick}
title={label}
icon={<PlusCircle />}
size="small"
/>
);
} else if (onRemoveClick) {
return (
<RemoveButton
id={id}
registry={registry}
className="rjsf-remove-optional-data"
onClick={onRemoveClick}
title={label}
size="small"
/>
);
}
return <em id={id}>{label}</em>;
}

View File

@@ -0,0 +1,114 @@
import {
ADDITIONAL_PROPERTY_FLAG,
buttonId,
getTemplate,
getUiOptions,
titleId,
WrapIfAdditionalTemplateProps,
} from "@rjsf/utils";
import { Input } from "@/components/atoms/Input/Input";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
export default function WrapIfAdditionalTemplate(
props: WrapIfAdditionalTemplateProps,
) {
const {
classNames,
style,
children,
disabled,
id,
label,
onRemoveProperty,
onKeyRenameBlur,
readonly,
required,
schema,
uiSchema,
registry,
} = props;
const { templates, formContext } = registry;
const uiOptions = getUiOptions(uiSchema);
// Button templates are not overridden in the uiSchema
const { RemoveButton } = templates.ButtonTemplates;
const { isInputConnected } = useEdgeStore();
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const { nodeId } = formContext;
const handleId = uiOptions.handleId;
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
if (!additional) {
return (
<div className={classNames} style={style}>
{children}
</div>
);
}
const keyId = `${id}-key`;
const generateObjectPropertyTitleId = (id: string, label: string) => {
return id.replace(`_${label}`, `_#_${label}`);
};
const title_id = generateObjectPropertyTitleId(id, label);
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (e.target.value == "") {
onRemoveProperty();
} else {
onKeyRenameBlur(e);
}
};
const isHandleConnected = isInputConnected(nodeId, handleId);
return (
<>
<div className={`mb-4 flex flex-col gap-1`} style={style}>
<TitleFieldTemplate
id={titleId(title_id)}
title={`#${label}`}
required={required}
schema={schema}
registry={registry}
uiSchema={uiSchema}
/>
{!isHandleConnected && (
<div className="flex flex-1 items-center gap-2">
<Input
label={""}
hideLabel={true}
required={required}
defaultValue={label}
disabled={disabled || readonly}
id={keyId}
wrapperClassName="mb-2 w-30"
name={keyId}
onBlur={!readonly ? handleBlur : undefined}
type="text"
size="small"
/>
<div className="mt-2"> {children}</div>
</div>
)}
{!isHandleConnected && (
<div className="-mt-4">
<RemoveButton
id={buttonId(id, "remove")}
disabled={disabled || readonly}
onClick={onRemoveProperty}
uiSchema={uiSchema}
registry={registry}
/>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,3 @@
export { default as ObjectFieldTemplate } from "./ObjectFieldTemplate";
export { default as WrapIfAdditionalTemplate } from "./WrapIfAdditionalTemplate";
export { default as OptionalDataControlsTemplate } from "./OptionalDataControlsTemplate";

View File

@@ -0,0 +1,32 @@
import { DescriptionFieldProps } from "@rjsf/utils";
import { RichDescription } from "@rjsf/core";
import { InfoIcon } from "@phosphor-icons/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
export default function DescriptionField(props: DescriptionFieldProps) {
const { id, description, registry, uiSchema } = props;
if (!description) {
return null;
}
return (
<div id={id} className="0 inline w-fit">
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon size={16} className="cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<RichDescription
description={description}
registry={registry}
uiSchema={uiSchema}
/>
</TooltipContent>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { Text } from "@/components/atoms/Text/Text";
export const FieldError = ({
nodeId,
fieldId,
}: {
nodeId: string;
fieldId: string;
}) => {
const nodeErrors = useNodeStore((state) => {
const node = state.nodes.find((n) => n.id === nodeId);
return node?.data?.errors;
});
const fieldError =
nodeErrors?.[fieldId] || nodeErrors?.[fieldId.replace(/_%_/g, ".")] || null;
return (
<div>
{fieldError && (
<Text variant="small" className="mt-1 pl-4 !text-red-600">
{fieldError}
</Text>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
import {
ADDITIONAL_PROPERTY_FLAG,
FieldTemplateProps,
getTemplate,
getUiOptions,
titleId,
} from "@rjsf/utils";
import { isAnyOfChild, isAnyOfSchema } from "../../utils/schema-utils";
import {
cleanUpHandleId,
getHandleId,
isPartOfAnyOf,
updateUiOption,
} from "../../helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { FieldError } from "./FieldError";
export default function FieldTemplate(props: FieldTemplateProps) {
const {
id,
children,
displayLabel,
description,
rawDescription,
label,
hidden,
required,
schema,
uiSchema,
registry,
classNames,
style,
disabled,
onKeyRename,
onKeyRenameBlur,
onRemoveProperty,
readonly,
} = props;
const { nodeId } = registry.formContext;
const { isInputConnected } = useEdgeStore();
const showAdvanced = useNodeStore(
(state) => state.nodeAdvancedStates[registry.formContext.nodeId ?? ""],
);
if (hidden) {
return <div className="hidden">{children}</div>;
}
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate",
registry,
uiOptions,
);
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const handleId = getHandleId({
uiOptions,
id: id,
schema: schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
const isHandleConnected = isInputConnected(nodeId, cleanUpHandleId(handleId));
const shouldDisplayLabel =
displayLabel ||
(schema.type === "boolean" && !isAnyOfChild(uiSchema as any));
const shouldShowTitleSection = !isAnyOfSchema(schema) && !additional;
const shouldShowChildren = isAnyOfSchema(schema) || !isHandleConnected;
const isAdvancedField = (schema as any).advanced === true;
if (!showAdvanced && isAdvancedField && !isHandleConnected) {
return null;
}
const marginBottom =
isPartOfAnyOf({ uiOptions }) || isAnyOfSchema(schema) ? 0 : 16;
return (
<WrapIfAdditionalTemplate
classNames={classNames}
style={style}
disabled={disabled}
id={id}
label={label}
displayLabel={displayLabel}
onKeyRename={onKeyRename}
onKeyRenameBlur={onKeyRenameBlur}
onRemoveProperty={onRemoveProperty}
rawDescription={rawDescription}
readonly={readonly}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
>
<div className="flex flex-col gap-2" style={{ marginBottom }}>
{shouldShowTitleSection && (
<div className="flex items-center gap-2">
{shouldDisplayLabel && (
<TitleFieldTemplate
id={titleId(id)}
title={label}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
)}
{shouldDisplayLabel && rawDescription && <span>{description}</span>}
</div>
)}
{shouldShowChildren && children}
<FieldError nodeId={nodeId} fieldId={cleanUpHandleId(id)} />
</div>
</WrapIfAdditionalTemplate>
);
}

View File

@@ -0,0 +1,55 @@
import {
ADDITIONAL_PROPERTY_FLAG,
descriptionId,
getUiOptions,
TitleFieldProps,
} from "@rjsf/utils";
import { Text } from "@/components/atoms/Text/Text";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { isAnyOfSchema } from "../../utils/schema-utils";
import { cn } from "@/lib/utils";
import { isArrayItem } from "../../helpers";
import { InputNodeHandle } from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
export default function TitleField(props: TitleFieldProps) {
const { id, title, required, schema, registry, uiSchema } = props;
const { nodeId, showHandles } = registry.formContext;
const uiOptions = getUiOptions(uiSchema);
const isAnyOf = isAnyOfSchema(schema);
const { displayType, colorClass } = getTypeDisplayInfo(schema);
const description_id = descriptionId(id);
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const isArrayItemFlag = isArrayItem({ uiOptions });
const smallText = isArrayItemFlag || additional;
const showHandle = uiOptions.showHandles ?? showHandles;
return (
<div className="flex items-center">
{showHandle !== false && (
<InputNodeHandle handleId={uiOptions.handleId} nodeId={nodeId} />
)}
<Text
variant={isArrayItemFlag ? "small" : "body"}
id={id}
className={cn("line-clamp-1", smallText && "text-sm text-zinc-700")}
>
{title}
</Text>
<Text variant="small" className={"mr-1 text-red-500"}>
{required ? "*" : null}
</Text>
{!isAnyOf && (
<Text
variant="small"
className={cn("ml-2", colorClass)}
id={description_id}
>
({displayType})
</Text>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { IconButtonProps, TranslatableString } from "@rjsf/utils";
import { cn } from "@/lib/utils";
import { Button } from "@/components/atoms/Button/Button";
import { PlusIcon } from "@phosphor-icons/react";
export default function AddButton({
registry,
className,
uiSchema: _uiSchema,
...props
}: IconButtonProps) {
const { translateString } = registry;
return (
<div className="m-0 w-full p-0">
<Button
{...props}
size="small"
className={cn("w-full gap-4", className)}
variant="secondary"
type="button"
>
<PlusIcon size={16} weight="bold" />
{translateString(TranslatableString.AddItemButton)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import {
FormContextType,
IconButtonProps,
RJSFSchema,
StrictRJSFSchema,
TranslatableString,
} from "@rjsf/utils";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import type { VariantProps } from "class-variance-authority";
import { Button } from "@/components/atoms/Button/Button";
import { extendedButtonVariants } from "@/components/atoms/Button/helpers";
import { TrashIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { Text } from "@/components/atoms/Text/Text";
export type AutogptIconButtonProps<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> = IconButtonProps<T, S, F> & VariantProps<typeof extendedButtonVariants>;
export default function IconButton(props: AutogptIconButtonProps) {
const {
icon,
className,
uiSchema: _uiSchema,
registry: _registry,
iconType: _iconType,
...otherProps
} = props;
return (
<Button
size="icon"
variant="secondary"
className={cn(className, "w-fit border border-zinc-200 p-1.5 px-4")}
{...otherProps}
type="button"
>
{icon}
<Text variant="body" className="ml-2">
{" "}
Remove Item{" "}
</Text>
</Button>
);
}
export function CopyButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.CopyButton)}
{...props}
icon={<Copy className="h-4 w-4" />}
/>
);
}
export function MoveDownButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.MoveDownButton)}
{...props}
icon={<ChevronDown className="h-4 w-4" />}
/>
);
}
export function MoveUpButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.MoveUpButton)}
{...props}
icon={<ChevronUp className="h-4 w-4" />}
/>
);
}
export function RemoveButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.RemoveButton)}
{...props}
className={"border-destructive"}
icon={<TrashIcon size={16} className="!text-zinc-800" />}
/>
);
}

View File

@@ -0,0 +1,8 @@
export { default as AddButton } from "./AddButton";
export {
default as IconButton,
CopyButton,
RemoveButton,
MoveUpButton,
MoveDownButton,
} from "./IconButton";

View File

@@ -0,0 +1,24 @@
import { ErrorListProps, TranslatableString } from "@rjsf/utils";
import { AlertCircle } from "lucide-react";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/molecules/Alert/Alert";
export default function ErrorList(props: ErrorListProps) {
const { errors, registry } = props;
const { translateString } = registry;
return (
<Alert variant="error" className="mb-2">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{translateString(TranslatableString.ErrorsLabel)}</AlertTitle>
<AlertDescription className="flex flex-col gap-1">
{errors.map((error, i: number) => {
return <span key={i}>&#x2022; {error.stack}</span>;
})}
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1 @@
export { default as ErrorList } from "./ErrorList";

View File

@@ -0,0 +1,76 @@
import { RJSFSchema } from "@rjsf/utils";
export function parseFieldPath(
rootSchema: RJSFSchema,
id: string,
additional: boolean,
idSeparator: string = "_%_",
): { path: string[]; typeHints: string[] } {
const segments = id.split(idSeparator).filter(Boolean);
const typeHints: string[] = [];
let currentSchema = rootSchema;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const isNumeric = /^\d+$/.test(segment);
if (isNumeric) {
typeHints.push("array");
} else {
if (additional) {
typeHints.push("object-key");
} else {
typeHints.push("object-property");
}
currentSchema = (currentSchema.properties?.[segment] as RJSFSchema) || {};
}
}
return { path: segments, typeHints };
}
// This helper work is simple - it just help us to convert rjsf id to our backend compatible id
// Example : List[dict] = agpt_%_List_0_dict__title -> List_$_0_#_dict
// We remove the prefix and suffix and then we split id by our custom delimiter (_%_)
// then add _$_ delimiter for array and _#_ delimiter for object-key
// and for normal property we add . delimiter
export function getHandleId(
rootSchema: RJSFSchema,
id: string,
additional: boolean,
idSeparator: string = "_%_",
): string {
const idPrefix = "agpt_%_";
const idSuffix = "__title";
if (id.startsWith(idPrefix)) {
id = id.slice(idPrefix.length);
}
if (id.endsWith(idSuffix)) {
id = id.slice(0, -idSuffix.length);
}
const { path, typeHints } = parseFieldPath(
rootSchema,
id,
additional,
idSeparator,
);
return path
.map((seg, i) => {
const type = typeHints[i];
if (type === "array") {
return `_$_${seg}`;
}
if (type === "object-key") {
return `_${seg}`; // we haven't added _#_ delimiter for object-key because it's already added in the id - check WrapIfAdditionalTemplate.tsx
}
return `.${seg}`;
})
.join("")
.slice(1);
}

View File

@@ -0,0 +1,3 @@
export { default as FieldTemplate } from "./FieldTemplate";
export { default as TitleField } from "./TitleField";
export { default as DescriptionField } from "./DescriptionField";

View File

@@ -1,8 +1,9 @@
import { WidgetProps } from "@rjsf/utils";
import { Switch } from "@/components/atoms/Switch/Switch";
export function SwitchWidget(props: WidgetProps) {
export function CheckboxWidget(props: WidgetProps) {
const { value = false, onChange, disabled, readonly, autofocus, id } = props;
return (
<Switch
id={id}

View File

@@ -0,0 +1 @@
export { CheckboxWidget } from "./CheckBoxWidget";

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { WidgetProps } from "@rjsf/utils";
import { DateInput } from "@/components/atoms/DateInput/DateInput";
export const DateInputWidget = (props: WidgetProps) => {
export const DateWidget = (props: WidgetProps) => {
const {
value,
onChange,

View File

@@ -0,0 +1 @@
export { DateWidget } from "./DateWidget";

View File

@@ -1,7 +1,7 @@
import { WidgetProps } from "@rjsf/utils";
import { DateTimeInput } from "@/components/atoms/DateTimeInput/DateTimeInput";
export const DateTimeInputWidget = (props: WidgetProps) => {
export const DateTimeWidget = (props: WidgetProps) => {
const {
value,
onChange,

View File

@@ -0,0 +1 @@
export { DateTimeWidget } from "./DateTimeWidget";

View File

@@ -0,0 +1 @@
export { FileWidget } from "./FileWidget";

View File

@@ -0,0 +1,55 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import { getFieldErrorKey } from "@/components/renderers/InputRenderer/utils/helpers";
import type { GoogleDrivePickerConfig } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { WidgetProps } from "@rjsf/utils";
function hasGoogleDrivePickerConfig(
schema: unknown,
): schema is { google_drive_picker_config?: GoogleDrivePickerConfig } {
return (
typeof schema === "object" &&
schema !== null &&
"google_drive_picker_config" in schema
);
}
export function GoogleDrivePickerWidget(props: WidgetProps) {
const { onChange, disabled, readonly, value, schema, id, formContext } =
props;
const { nodeId } = formContext || {};
const nodeErrors = useNodeStore((state) => {
const node = state.nodes.find((n) => n.id === nodeId);
return node?.data?.errors;
});
const fieldErrorKey = getFieldErrorKey(id ?? "");
const fieldError =
nodeErrors?.[fieldErrorKey] ||
nodeErrors?.[fieldErrorKey.replace(/_/g, ".")] ||
nodeErrors?.[fieldErrorKey.replace(/\./g, "_")] ||
undefined;
const config: GoogleDrivePickerConfig = hasGoogleDrivePickerConfig(schema)
? schema.google_drive_picker_config || {}
: {};
function handleChange(newValue: unknown) {
onChange(newValue);
}
return (
<GoogleDrivePickerInput
config={config}
value={value}
onChange={handleChange}
error={fieldError}
className={cn(
disabled || readonly ? "pointer-events-none opacity-50" : undefined,
)}
showRemoveButton={true}
/>
);
}

View File

@@ -0,0 +1 @@
export { GoogleDrivePickerWidget } from "./GoogleDrivePicketWidget";

View File

@@ -14,8 +14,16 @@ import {
} from "@/components/__legacy__/ui/multiselect";
export const SelectWidget = (props: WidgetProps) => {
const { options, value, onChange, disabled, readonly, id, formContext } =
props;
const {
options,
value,
onChange,
disabled,
readonly,
className,
id,
formContext,
} = props;
const enumOptions = options.enumOptions || [];
const type = mapJsonSchemaTypeToInputType(props.schema);
const { size = "small" } = formContext || {};
@@ -36,7 +44,7 @@ export const SelectWidget = (props: WidgetProps) => {
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{enumOptions?.map((option) => (
{enumOptions?.map((option: any) => (
<MultiSelectorItem key={option.value} value={option.value}>
{option.label}
</MultiSelectorItem>
@@ -56,12 +64,13 @@ export const SelectWidget = (props: WidgetProps) => {
value={value ?? ""}
onValueChange={onChange}
options={
enumOptions?.map((option) => ({
enumOptions?.map((option: any) => ({
value: option.value,
label: option.label,
})) || []
}
wrapperClassName="!mb-0 "
className={className}
/>
);
};

View File

@@ -0,0 +1 @@
export { SelectWidget } from "./SelectWidget";

View File

@@ -14,15 +14,12 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { BlockUIType } from "@/lib/autogpt-server-api/types";
import { InputExpanderModal } from "./InputExpanderModal";
import { ArrowsOutIcon } from "@phosphor-icons/react";
import { InputExpanderModal } from "./TextInputExpanderModal";
export const TextInputWidget = (props: WidgetProps) => {
const { schema, formContext } = props;
const { uiType, size = "small" } = formContext as {
uiType: BlockUIType;
size?: string;
};
export default function TextWidget(props: WidgetProps) {
const { schema, placeholder, registry } = props;
const { size, uiType } = registry.formContext;
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -51,7 +48,7 @@ export const TextInputWidget = (props: WidgetProps) => {
handleChange: (v: string) => (v === "" ? undefined : Number(v)),
},
[InputType.INTEGER]: {
htmlType: "number",
htmlType: "account",
placeholder: "Enter integer value...",
handleChange: (v: string) => (v === "" ? undefined : Number(v)),
},
@@ -122,7 +119,7 @@ export const TextInputWidget = (props: WidgetProps) => {
wrapperClassName="mb-0 flex-1"
value={props.value ?? ""}
onChange={handleChange}
placeholder={schema.placeholder || config.placeholder}
placeholder={placeholder || config.placeholder}
required={props.required}
disabled={props.disabled}
className={showExpandButton ? "pr-8" : ""}
@@ -152,8 +149,8 @@ export const TextInputWidget = (props: WidgetProps) => {
title={schema.title || "Edit value"}
description={schema.description || ""}
defaultValue={props.value ?? ""}
placeholder={schema.placeholder || config.placeholder}
placeholder={placeholder || config.placeholder}
/>
</>
);
};
}

View File

@@ -0,0 +1,2 @@
export { default } from "./TextWidget";
export { InputExpanderModal } from "./TextInputExpanderModal";

View File

@@ -1,7 +1,7 @@
import { WidgetProps } from "@rjsf/utils";
import { TimeInput } from "@/components/atoms/TimeInput/TimeInput";
export const TimeInputWidget = (props: WidgetProps) => {
export const TimeWidget = (props: WidgetProps) => {
const { value, onChange, disabled, readonly, placeholder, id, formContext } =
props;
const { size = "small" } = formContext || {};

View File

@@ -0,0 +1 @@
export { TimeWidget } from "./TimeWidget";

View File

@@ -0,0 +1,8 @@
export { CheckboxWidget } from "./CheckboxInput";
export { DateWidget } from "./DateInput";
export { DateTimeWidget } from "./DateTimeInput";
export { FileWidget } from "./FileInput";
export { GoogleDrivePickerWidget } from "./GoogleDrivePicker";
export { SelectWidget } from "./SelectInput";
export { default as TextWidget } from "./TextInput";
export { TimeWidget } from "./TimeInput";

View File

@@ -0,0 +1,8 @@
export const ANY_OF_FLAG = "__anyOf";
export const ARRAY_FLAG = "__array";
export const OBJECT_FLAG = "__object";
export const KEY_PAIR_FLAG = "__keyPair";
export const TITLE_FLAG = "__title";
export const ARRAY_ITEM_FLAG = "__arrayItem";
export const ID_PREFIX = "agpt_@_";
export const ID_PREFIX_ARRAY = "agpt_%_";

View File

@@ -0,0 +1,73 @@
import React, { useMemo } from "react";
import { FieldProps, getUiOptions } from "@rjsf/utils";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useShallow } from "zustand/react/shallow";
import { CredentialFieldTitle } from "./components/CredentialFieldTitle";
export const CredentialsField = (props: FieldProps) => {
const { formData, onChange, schema, registry, fieldPathId } = props;
const formContext = registry.formContext;
const uiOptions = getUiOptions(props.uiSchema);
const nodeId = formContext?.nodeId;
// Get sibling inputs (hardcoded values) from the node store
const hardcodedValues = useNodeStore(
useShallow((state) => (nodeId ? state.getHardCodedValues(nodeId) : {})),
);
const handleChange = (newValue: any) => {
onChange(newValue, fieldPathId?.path);
};
const handleSelectCredentials = (credentialsMeta?: CredentialsMetaInput) => {
if (credentialsMeta) {
handleChange({
id: credentialsMeta.id,
provider: credentialsMeta.provider,
title: credentialsMeta.title,
type: credentialsMeta.type,
});
} else {
handleChange(undefined);
}
};
// Convert formData to CredentialsMetaInput format
const selectedCredentials: CredentialsMetaInput | undefined = useMemo(
() =>
formData?.id
? {
id: formData.id,
provider: formData.provider,
title: formData.title,
type: formData.type,
}
: undefined,
[formData?.id, formData?.provider, formData?.title, formData?.type],
);
return (
<div className="flex flex-col gap-2">
<CredentialFieldTitle
fieldPathId={fieldPathId}
registry={registry}
uiOptions={uiOptions}
schema={schema}
/>
<CredentialsInput
schema={schema as BlockIOCredentialsSubSchema}
selectedCredentials={selectedCredentials}
onSelectCredentials={handleSelectCredentials}
siblingInputs={hardcodedValues}
showTitle={false}
readOnly={formContext?.readOnly}
/>
</div>
);
};

View File

@@ -0,0 +1,66 @@
import {
getTemplate,
UiSchema,
Registry,
RJSFSchema,
FieldPathId,
titleId,
descriptionId,
} from "@rjsf/utils";
import { getCredentialProviderFromSchema, toDisplayName } from "../helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { updateUiOption } from "../../../helpers";
import { uiSchema } from "@/app/(platform)/build/components/FlowEditor/nodes/uiSchema";
export const CredentialFieldTitle = (props: {
registry: Registry;
uiOptions: UiSchema;
schema: RJSFSchema;
fieldPathId: FieldPathId;
}) => {
const { registry, uiOptions, schema, fieldPathId } = props;
const { nodeId } = registry.formContext;
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
uiOptions,
);
const credentialProvider = toDisplayName(
getCredentialProviderFromSchema(
useNodeStore.getState().getHardCodedValues(nodeId),
schema as BlockIOCredentialsSubSchema,
) ?? "",
);
const updatedUiSchema = updateUiOption(uiSchema, {
showHandles: false,
});
return (
<div className="flex items-center gap-2">
<TitleFieldTemplate
id={titleId(fieldPathId ?? "")}
title={credentialProvider ?? ""}
required={true}
schema={schema}
registry={registry}
uiSchema={updatedUiSchema}
/>
<DescriptionFieldTemplate
id={descriptionId(fieldPathId ?? "")}
description={schema.description || ""}
schema={schema}
registry={registry}
/>
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import { GoogleDrivePickerConfig } from "@/lib/autogpt-server-api";
import { FieldProps, getUiOptions } from "@rjsf/utils";
export const GoogleDrivePickerField = (props: FieldProps) => {
const { schema, uiSchema, onChange, fieldPathId, formData } = props;
const uiOptions = getUiOptions(uiSchema);
const config: GoogleDrivePickerConfig = schema.google_drive_picker_config;
return (
<div>
<GoogleDrivePickerInput
config={config}
value={formData}
onChange={(value) => onChange(value, fieldPathId.path)}
className={uiOptions.className}
showRemoveButton={true}
/>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import { FieldProps, RJSFSchema, RegistryFieldsType } from "@rjsf/utils";
import { CredentialsField } from "./CredentialField/CredentialField";
import { GoogleDrivePickerField } from "./GoogleDrivePickerField/GoogleDrivePickerField";
export interface CustomFieldDefinition {
id: string;
matcher: (schema: any) => boolean;
component: (props: FieldProps<any, RJSFSchema, any>) => JSX.Element | null;
}
export const CUSTOM_FIELDS: CustomFieldDefinition[] = [
{
id: "custom/credential_field",
matcher: (schema: any) => {
return (
typeof schema === "object" &&
schema !== null &&
"credentials_provider" in schema
);
},
component: CredentialsField,
},
{
id: "custom/google_drive_picker_field",
matcher: (schema: any) => {
return (
"google_drive_picker_config" in schema ||
("format" in schema && schema.format === "google-drive-picker")
);
},
component: GoogleDrivePickerField,
},
];
export function findCustomFieldId(schema: any): string | null {
for (const field of CUSTOM_FIELDS) {
if (field.matcher(schema)) {
return field.id;
}
}
return null;
}
export function generateCustomFields(): RegistryFieldsType {
return CUSTOM_FIELDS.reduce(
(acc, field) => {
acc[field.id] = field.component;
return acc;
},
{} as Record<string, any>,
);
}

View File

@@ -0,0 +1,291 @@
# Input Renderer 2 - Hierarchy
## Flow Overview
```
FormRenderer2 → Form (RJSF) → ObjectFieldTemplate → FieldTemplate → Widget/Field
```
---
## Component Layers
### 1. Root (FormRenderer2)
- Entry point
- Preprocesses schema
- Passes to RJSF Form
### 2. Form (registry/Form.tsx)
- RJSF themed form
- Combines: templates + widgets + fields
### 3. Templates (decide layout/structure)
| Template | When Used |
| -------------------------- | ------------------------------------------- |
| `ObjectFieldTemplate` | `type: "object"` |
| `ArrayFieldTemplate` | `type: "array"` |
| `FieldTemplate` | Wraps every field (title, errors, children) |
| `ArrayFieldItemTemplate` | Each array item |
| `WrapIfAdditionalTemplate` | Additional properties in objects |
### 4. Fields (custom rendering logic)
| Field | When Used |
| ------------------ | ---------------------------- |
| `AnyOfField` | `anyOf` or `oneOf` in schema |
| `ArraySchemaField` | Array type handling |
### 5. Widgets (actual input elements)
| Widget | Input Type |
| ---------------- | ----------------------- |
| `TextWidget` | string, number, integer |
| `SelectWidget` | enum, anyOf selector |
| `CheckboxWidget` | boolean |
| `FileWidget` | file upload |
| `DateWidget` | date |
| `TimeWidget` | time |
| `DateTimeWidget` | datetime |
---
## Your Schema Hierarchy
```
Root (type: object)
└── ObjectFieldTemplate
├── name (string, required)
│ └── FieldTemplate → TextWidget
├── value (anyOf)
│ └── FieldTemplate → AnyOfField
│ └── Selector dropdown + selected type:
│ ├── String → TextWidget
│ ├── Number → TextWidget
│ ├── Integer → TextWidget
│ ├── Boolean → CheckboxWidget
│ ├── Array → ArrayFieldTemplate → items
│ ├── Object → ObjectFieldTemplate
│ └── Null → nothing
├── title (anyOf: string | null)
│ └── FieldTemplate → AnyOfField
│ └── String → TextWidget OR Null → nothing
├── description (anyOf: string | null)
│ └── FieldTemplate → AnyOfField
│ └── String → TextWidget OR Null → nothing
├── placeholder_values (array of strings)
│ └── FieldTemplate → ArrayFieldTemplate
│ └── ArrayFieldItemTemplate (per item)
│ └── TextWidget
├── advanced (boolean)
│ └── FieldTemplate → CheckboxWidget
└── secret (boolean)
└── FieldTemplate → CheckboxWidget
```
---
## Nested Examples (up to 3 levels)
### Simple Array (strings)
```json
{ "tags": { "type": "array", "items": { "type": "string" } } }
```
```
Level 1: ObjectFieldTemplate (root)
└── Level 2: FieldTemplate → ArrayFieldTemplate
└── Level 3: ArrayFieldItemTemplate → TextWidget
```
### Array of Objects
```json
{
"users": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
}
}
}
}
```
```
Level 1: ObjectFieldTemplate (root)
└── Level 2: FieldTemplate → ArrayFieldTemplate
└── Level 3: ArrayFieldItemTemplate → ObjectFieldTemplate
├── FieldTemplate → TextWidget (name)
└── FieldTemplate → TextWidget (age)
```
### Nested Object (3 levels)
```json
{
"config": {
"type": "object",
"properties": {
"database": {
"type": "object",
"properties": {
"host": { "type": "string" },
"port": { "type": "integer" }
}
}
}
}
}
```
```
Level 1: ObjectFieldTemplate (root)
└── config
└── Level 2: FieldTemplate → ObjectFieldTemplate
└── database
└── Level 3: FieldTemplate → ObjectFieldTemplate
├── FieldTemplate → TextWidget (host)
└── FieldTemplate → TextWidget (port)
```
### Array of Arrays (nested array)
```json
{
"matrix": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "number" }
}
}
}
```
```
Level 1: ObjectFieldTemplate (root)
└── Level 2: FieldTemplate → ArrayFieldTemplate
└── Level 3: ArrayFieldItemTemplate → ArrayFieldTemplate
└── ArrayFieldItemTemplate → TextWidget
```
### Complex: Object → Array → Object
```json
{
"company": {
"type": "object",
"properties": {
"departments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"budget": { "type": "number" }
}
}
}
}
}
}
```
```
Level 1: ObjectFieldTemplate (root)
└── company
└── Level 2: FieldTemplate → ObjectFieldTemplate
└── departments
└── Level 3: FieldTemplate → ArrayFieldTemplate
└── ArrayFieldItemTemplate → ObjectFieldTemplate
├── FieldTemplate → TextWidget (name)
└── FieldTemplate → TextWidget (budget)
```
### anyOf inside Array
```json
{
"items": {
"type": "array",
"items": {
"anyOf": [
{ "type": "string" },
{ "type": "object", "properties": { "id": { "type": "string" } } }
]
}
}
}
```
```
Level 1: ObjectFieldTemplate (root)
└── Level 2: FieldTemplate → ArrayFieldTemplate
└── Level 3: ArrayFieldItemTemplate → AnyOfField
└── Selector + selected:
├── String → TextWidget
└── Object → ObjectFieldTemplate
└── FieldTemplate → TextWidget (id)
```
---
## Nesting Pattern Summary
| Parent Type | Child Wrapper |
| ----------- | ----------------------------------------------- |
| object | `ObjectFieldTemplate``FieldTemplate` |
| array | `ArrayFieldTemplate``ArrayFieldItemTemplate` |
| anyOf | `AnyOfField` → selected schema's template |
| primitive | `Widget` (leaf - no children) |
**Pattern:** Each level adds FieldTemplate wrapper except array items (use ArrayFieldItemTemplate)
---
## Key Points
1. **FieldTemplate wraps everything** - handles title, description, errors
2. **anyOf = AnyOfField** - shows dropdown to pick type, then renders selected schema
3. **ObjectFieldTemplate loops properties** - each property gets FieldTemplate
4. **ArrayFieldTemplate loops items** - each item gets ArrayFieldItemTemplate
5. **Widgets are leaf nodes** - actual input controls user interacts with
6. **Nesting repeats the pattern** - object/array/anyOf can contain object/array/anyOf recursively
---
## Decision Flow
```
Schema Type?
├── object → ObjectFieldTemplate → loop properties
├── array → ArrayFieldTemplate → loop items
├── anyOf/oneOf → AnyOfField → selector + selected schema
└── primitive (string/number/boolean) → Widget
```
---
## Template Wrapping Order
```
ObjectFieldTemplate (root)
└── FieldTemplate (per property)
└── WrapIfAdditionalTemplate (if additionalProperties)
└── TitleField + DescriptionField + children
└── Widget OR nested Template/Field
```

View File

@@ -0,0 +1,276 @@
import {
RJSFSchema,
UIOptionsType,
StrictRJSFSchema,
FormContextType,
ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils";
import {
ANY_OF_FLAG,
ARRAY_ITEM_FLAG,
ID_PREFIX,
ID_PREFIX_ARRAY,
KEY_PAIR_FLAG,
OBJECT_FLAG,
} from "./constants";
import { PathSegment } from "./types";
export function updateUiOption<T extends Record<string, any>>(
uiSchema: T | undefined,
options: Record<string, any>,
): T & { "ui:options": Record<string, any> } {
return {
...(uiSchema || {}),
"ui:options": {
...uiSchema?.["ui:options"],
...options,
},
} as T & { "ui:options": Record<string, any> };
}
export const cleanUpHandleId = (handleId: string) => {
let newHandleId = handleId;
if (handleId.includes(ANY_OF_FLAG)) {
newHandleId = newHandleId.replace(ANY_OF_FLAG, "");
}
if (handleId.includes(ARRAY_ITEM_FLAG)) {
newHandleId = newHandleId.replace(ARRAY_ITEM_FLAG, "");
}
if (handleId.includes(KEY_PAIR_FLAG)) {
newHandleId = newHandleId.replace(KEY_PAIR_FLAG, "");
}
if (handleId.includes(OBJECT_FLAG)) {
newHandleId = newHandleId.replace(OBJECT_FLAG, "");
}
if (handleId.includes(ID_PREFIX_ARRAY)) {
newHandleId = newHandleId.replace(ID_PREFIX_ARRAY, "");
}
if (handleId.includes(ID_PREFIX)) {
newHandleId = newHandleId.replace(ID_PREFIX, "");
}
return newHandleId;
};
export const isArrayItem = <
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
uiOptions,
}: {
uiOptions: UIOptionsType<T, S, F>;
}) => {
return uiOptions.handleId?.endsWith(ARRAY_ITEM_FLAG);
};
export const isKeyValuePair = ({ schema }: { schema: RJSFSchema }) => {
return ADDITIONAL_PROPERTY_FLAG in schema;
};
export const isNormal = <
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
uiOptions,
}: {
uiOptions: UIOptionsType<T, S, F>;
}) => {
return uiOptions.handleId === undefined;
};
export const isPartOfAnyOf = <
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
uiOptions,
}: {
uiOptions: UIOptionsType<T, S, F>;
}) => {
return uiOptions.handleId?.endsWith(ANY_OF_FLAG);
};
export const isObjectProperty = <
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
uiOptions,
schema,
}: {
uiOptions: UIOptionsType<T, S, F>;
schema: RJSFSchema;
}) => {
return (
!isArrayItem({ uiOptions }) &&
!isKeyValuePair({ schema }) &&
!isNormal({ uiOptions }) &&
!isPartOfAnyOf({ uiOptions })
);
};
export const getHandleId = <
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
schema,
uiOptions,
}: {
id: string;
schema: RJSFSchema;
uiOptions: UIOptionsType<T, S, F>;
}) => {
const parentHandleId = uiOptions.handleId;
if (isNormal({ uiOptions })) {
return id;
}
if (isPartOfAnyOf({ uiOptions })) {
return parentHandleId + ANY_OF_FLAG;
}
if (isKeyValuePair({ schema })) {
const key = id.split("_%_").at(-1);
let prefix = "";
if (parentHandleId) {
prefix = parentHandleId;
} else {
prefix = id.split("_%_").slice(0, -1).join("_%_");
}
const handleId = `${prefix}_#_${key}`;
return handleId + KEY_PAIR_FLAG;
}
if (isArrayItem({ uiOptions })) {
const index = id.split("_%_").at(-1);
const prefix = id.split("_%_").slice(0, -1).join("_%_");
const handleId = `${prefix}_$_${index}`;
return handleId + ARRAY_ITEM_FLAG;
}
if (isObjectProperty({ uiOptions, schema })) {
const key = id.split("_%_").at(-1);
const prefix = id.split("_%_").slice(0, -1).join("_%_");
const handleId = `${prefix}_@_${key}`;
return handleId + OBJECT_FLAG;
}
return parentHandleId;
};
export function isCredentialFieldSchema(schema: any): boolean {
return (
typeof schema === "object" &&
schema !== null &&
"credentials_provider" in schema
);
}
export function parseHandleIdToPath(handleId: string): PathSegment[] {
const cleanedId = cleanUpHandleId(handleId);
const segments: PathSegment[] = [];
const parts = cleanedId.split(/(_#_|_@_|_\$_|\.)/);
let currentType: "property" | "item" | "additional" | "normal" = "normal";
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part === "_#_") {
currentType = "additional";
} else if (part === "_@_") {
currentType = "property";
} else if (part === "_$_") {
currentType = "item";
} else if (part === ".") {
currentType = "normal";
} else if (part) {
const isNumeric = /^\d+$/.test(part);
if (currentType === "item" && isNumeric) {
segments.push({
key: part,
type: "item",
index: parseInt(part, 10),
});
} else {
segments.push({
key: part,
type: currentType,
});
}
currentType = "normal";
}
}
return segments;
}
/**
* Ensure a path exists in an object, creating intermediate objects/arrays as needed
* Returns true if any modifications were made
*/
export function ensurePathExists(
obj: Record<string, any>,
segments: PathSegment[],
): boolean {
if (segments.length === 0) return false;
let current = obj;
let modified = false;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const isLast = i === segments.length - 1;
const nextSegment = segments[i + 1];
const getDefaultValue = () => {
if (isLast) {
return "";
}
if (nextSegment?.type === "item") {
return [];
}
return {};
};
if (segment.type === "item" && segment.index !== undefined) {
if (!Array.isArray(current)) {
return modified;
}
while (current.length <= segment.index) {
current.push(isLast ? "" : {});
modified = true;
}
if (!isLast) {
if (
current[segment.index] === undefined ||
current[segment.index] === null
) {
current[segment.index] = getDefaultValue();
modified = true;
}
current = current[segment.index];
}
} else {
if (!(segment.key in current)) {
current[segment.key] = getDefaultValue();
modified = true;
} else if (!isLast && current[segment.key] === undefined) {
current[segment.key] = getDefaultValue();
modified = true;
}
if (!isLast) {
current = current[segment.key];
}
}
}
return modified;
}

View File

@@ -0,0 +1,3 @@
export { FormRenderer } from "./FormRenderer";
export { default as Form } from "./registry";
export type { ExtendedFormContextType } from "./types";

View File

@@ -0,0 +1,23 @@
import { ComponentType } from "react";
import { FormProps, withTheme, ThemeProps } from "@rjsf/core";
import {
generateBaseFields,
generateBaseTemplates,
generateBaseWidgets,
} from "../base/base-registry";
import { generateCustomFields } from "../custom/custom-registry";
export function generateForm(): ComponentType<FormProps> {
const theme: ThemeProps = {
templates: generateBaseTemplates(),
widgets: generateBaseWidgets(),
fields: {
...generateBaseFields(),
...generateCustomFields(),
},
};
return withTheme(theme);
}
export default generateForm();

View File

@@ -0,0 +1,10 @@
export { default, generateForm } from "./Form";
export {
generateBaseFields,
generateBaseTemplates,
generateBaseWidgets,
} from "../base/base-registry";
export {
generateCustomFields,
findCustomFieldId,
} from "../custom/custom-registry";

View File

@@ -0,0 +1,7 @@
import { BlockUIType } from "@/app/(platform)/build/components/types";
export type ExtraContext = {
nodeId?: string;
uiType?: BlockUIType;
size?: "small" | "medium" | "large";
};

View File

@@ -0,0 +1,15 @@
import { BlockUIType } from "@/lib/autogpt-server-api/types";
import { FormContextType } from "@rjsf/utils";
export interface ExtendedFormContextType extends FormContextType {
nodeId?: string;
uiType?: BlockUIType;
showHandles?: boolean;
size?: "small" | "medium" | "large";
}
export type PathSegment = {
key: string;
type: "property" | "item" | "additional" | "normal";
index?: number;
};

View File

@@ -1,9 +1,11 @@
import { RJSFSchema } from "@rjsf/utils";
import { findCustomFieldId } from "../custom/custom-registry";
/**
* Pre-processes the input schema to ensure all properties have a type defined.
* If a property doesn't have a type, it assigns a union of all supported JSON Schema types.
*/
export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema {
if (!schema || typeof schema !== "object") {
return schema;
@@ -19,6 +21,12 @@ export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema {
if (property && typeof property === "object") {
const processedProperty = { ...property };
// adding $id for custom field
const customFieldId = findCustomFieldId(processedProperty);
if (customFieldId) {
processedProperty.$id = customFieldId;
}
// Only add type if no type is defined AND no anyOf/oneOf/allOf is present
if (
!processedProperty.type &&
@@ -32,7 +40,7 @@ export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema {
{ type: "integer" },
{ type: "boolean" },
{ type: "array", items: { type: "string" } },
{ type: "object" },
{ type: "object", title: "Object", additionalProperties: true },
{ type: "null" },
];
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,35 @@
import { getUiOptions, RJSFSchema, UiSchema } from "@rjsf/utils";
export function isAnyOfSchema(schema: RJSFSchema | undefined): boolean {
return Array.isArray(schema?.anyOf) && schema!.anyOf.length > 0;
}
export const isAnyOfChild = (
uiSchema: UiSchema<any, RJSFSchema, any> | undefined,
): boolean => {
const uiOptions = getUiOptions(uiSchema);
return uiOptions.label === false;
};
export function isOptionalType(schema: RJSFSchema | undefined): {
isOptional: boolean;
type?: any;
} {
if (
!Array.isArray(schema?.anyOf) ||
schema!.anyOf.length !== 2 ||
!schema!.anyOf.some((opt: any) => opt.type === "null")
) {
return { isOptional: false };
}
const nonNullType = schema!.anyOf?.find((opt: any) => opt.type !== "null");
return {
isOptional: true,
type: nonNullType,
};
}
export function isAnyOfSelector(name: string) {
return name.includes("anyof_select");
}

View File

@@ -1,938 +0,0 @@
# Input-Renderer Architecture Documentation
## Overview
The Input-Renderer is a **JSON Schema-based form generation system** built on top of **React JSON Schema Form (RJSF)**. It dynamically creates form inputs for block nodes in the FlowEditor based on JSON schemas defined in the backend.
This system allows blocks to define their input requirements declaratively, and the frontend automatically generates appropriate UI components.
---
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────┐
│ FormRenderer │
│ (Entry point, wraps RJSF Form) │
└─────────────────────┬───────────────────────────────────┘
┌─────────▼─────────┐
│ RJSF Core │
│ <Form /> │
└───────┬───────────┘
┌───────────┼───────────┬──────────────┐
│ │ │ │
┌────▼────┐ ┌───▼────┐ ┌────▼─────┐ ┌────▼────┐
│ Fields │ │Templates│ │ Widgets │ │ Schemas │
└─────────┘ └─────────┘ └──────────┘ └─────────┘
│ │ │ │
│ │ │ │
Handles Wrapper Actual JSON Schema
complex layouts input (from backend)
types & labels components
```
---
## What is RJSF (React JSON Schema Form)?
**RJSF** is a library that generates React forms from JSON Schema definitions. It follows a specific hierarchy to render forms:
### **RJSF Rendering Flow:**
```
1. JSON Schema (defines data structure)
2. Schema Field (decides which Field component to use)
3. Field Component (handles specific type logic)
4. Field Template (wraps field with label, description)
5. Widget (actual input element - TextInput, Select, etc.)
```
### **Example Flow:**
```json
// JSON Schema
{
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name"
}
}
}
```
**Becomes:**
```
SchemaField (detects "string" type)
StringField (default RJSF field)
FieldTemplate (adds label "Name")
TextWidget (renders <input type="text" />)
```
---
## Core Components of Input-Renderer
### 1. **FormRenderer** (`FormRenderer.tsx`)
The main entry point that wraps RJSF `<Form />` component.
```typescript
export const FormRenderer = ({
jsonSchema, // JSON Schema from backend
handleChange, // Callback when form changes
uiSchema, // UI customization
initialValues, // Pre-filled values
formContext, // Extra context (nodeId, uiType, etc.)
}: FormRendererProps) => {
const preprocessedSchema = preprocessInputSchema(jsonSchema);
return (
<Form
schema={preprocessedSchema} // Modified schema
validator={customValidator} // Custom validation logic
fields={fields} // Custom field components
templates={templates} // Custom layout templates
widgets={widgets} // Custom input widgets
formContext={formContext} // Pass context down
onChange={handleChange} // Form change handler
uiSchema={uiSchema} // UI customization
formData={initialValues} // Initial values
/>
);
};
```
**Key Props:**
- **`fields`** - Custom components for complex types (anyOf, credentials, objects)
- **`templates`** - Layout wrappers (FieldTemplate, ArrayFieldTemplate)
- **`widgets`** - Actual input components (TextInput, Select, FileWidget)
- **`formContext`** - Shared data (nodeId, showHandles, size)
---
### 2. **Schema Pre-Processing** (`utils/input-schema-pre-processor.ts`)
Before rendering, schemas are transformed to ensure RJSF compatibility.
**Purpose:**
- Add missing `type` fields (prevents RJSF errors)
- Recursively process nested objects and arrays
- Normalize inconsistent schemas from backend
**Example:**
```typescript
// Backend schema (missing type)
{
"properties": {
"value": {} // No type defined!
}
}
// After pre-processing
{
"properties": {
"value": {
"anyOf": [
{ "type": "string" },
{ "type": "number" },
{ "type": "boolean" },
// ... all possible types
]
}
}
}
```
**Why?** RJSF requires explicit types. Without this, it would crash or render incorrectly.
---
## The Three Pillars: Fields, Templates, Widgets
### **A. Fields** (`fields/`)
Fields handle **complex type logic** that goes beyond simple inputs.
**Registered Fields:**
```typescript
export const fields: RegistryFieldsType = {
AnyOfField: AnyOfField, // Handles anyOf/oneOf
credentials: CredentialsField, // OAuth/API key handling
ObjectField: ObjectField, // Free-form objects
};
```
#### **1. AnyOfField** (`fields/AnyOfField/AnyOfField.tsx`)
Handles schemas with multiple possible types (union types).
**When Used:**
```json
{
"anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }]
}
```
**Rendering:**
```
┌─────────────────────────────────────┐
│ Parameter Name (string) ▼ │ ← Type selector dropdown
├─────────────────────────────────────┤
│ [Text Input] │ ← Widget for selected type
└─────────────────────────────────────┘
```
**Features:**
- Type selector dropdown
- Nullable types (with toggle switch)
- Recursive rendering (can contain arrays, objects)
- Connection-aware (hides input when connected)
**Special Case: Nullable Types**
```json
{
"anyOf": [{ "type": "string" }, { "type": "null" }]
}
```
**Renders as:**
```
┌─────────────────────────────────────┐
│ Parameter Name (string | null) [✓] │ ← Toggle switch
├─────────────────────────────────────┤
│ [Text Input] (only if enabled) │
└─────────────────────────────────────┘
```
---
#### **2. CredentialsField** (`fields/CredentialField/CredentialField.tsx`)
Handles authentication credentials (OAuth, API Keys, Passwords).
**When Used:**
```json
{
"type": "object",
"credentials": {
"provider": "google",
"scopes": ["email", "profile"]
}
}
```
**Flow:**
```
1. Renders SelectCredential dropdown
2. User selects existing credential OR clicks "Add New"
3. Modal opens (OAuthModal/APIKeyModal/PasswordModal)
4. User authorizes/enters credentials
5. Credential saved to backend
6. Dropdown shows selected credential
```
**Credential Types:**
- **OAuth** - 3rd party authorization (Google, GitHub, etc.)
- **API Key** - Simple key-based auth
- **Password** - Username/password pairs
---
#### **3. ObjectField** (`fields/ObjectField.tsx`)
Handles free-form objects (key-value pairs).
**When Used:**
```json
{
"type": "object",
"additionalProperties": true // Free-form
}
```
vs
```json
{
"type": "object",
"properties": {
"name": { "type": "string" } // Fixed schema
}
}
```
**Behavior:**
- **Fixed schema** → Uses default RJSF rendering
- **Free-form** → Uses ObjectEditorWidget (JSON editor)
---
### **B. Templates** (`templates/`)
Templates control **layout and wrapping** of fields.
#### **1. FieldTemplate** (`templates/FieldTemplate.tsx`)
Wraps every field with label, type indicator, and connection handle.
**Rendering Structure:**
```
┌────────────────────────────────────────┐
│ ○ Label (type) ⓘ │ ← Handle + Label + Type + Info icon
├────────────────────────────────────────┤
│ [Actual Input Widget] │ ← The input itself
└────────────────────────────────────────┘
```
**Responsibilities:**
- Shows/hides input based on connection status
- Renders connection handle (NodeHandle)
- Displays type information
- Shows tooltip with description
- Handles "advanced" field visibility
- Formats credential field labels
**Key Logic:**
```typescript
// Hide input if connected
{(isAnyOf || !isConnected) && (
<div>{children}</div>
)}
// Show handle for most fields
{shouldShowHandle && (
<NodeHandle handleId={handleId} isConnected={isConnected} />
)}
```
**Context-Aware Behavior:**
- Inside `AnyOfField` → No handle (parent handles it)
- Credential field → Special label formatting
- Array item → Uses parent handle
- INPUT/OUTPUT/WEBHOOK blocks → Different handle positioning
---
#### **2. ArrayFieldTemplate** (`templates/ArrayFieldTemplate.tsx`)
Wraps array fields to use custom ArrayEditorWidget.
**Simple Wrapper:**
```typescript
function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
const { items, canAdd, onAddClick, nodeId } = props;
return (
<ArrayEditorWidget
items={items}
nodeId={nodeId}
canAdd={canAdd}
onAddClick={onAddClick}
/>
);
}
```
---
### **C. Widgets** (`widgets/`)
Widgets are **actual input components** - the final rendered HTML elements.
**Registered Widgets:**
```typescript
export const widgets: RegistryWidgetsType = {
TextWidget: TextInputWidget, // <input type="text" />
SelectWidget: SelectWidget, // <select> dropdown
CheckboxWidget: SwitchWidget, // <Switch> toggle
FileWidget: FileWidget, // File upload
DateWidget: DateInputWidget, // Date picker
TimeWidget: TimeInputWidget, // Time picker
DateTimeWidget: DateTimeInputWidget, // Combined date+time
};
```
#### **Widget Selection Logic (RJSF)**
RJSF automatically picks the right widget based on schema:
```json
{ "type": "string" } TextWidget
{ "type": "string", "enum": [...] } SelectWidget
{ "type": "boolean" } CheckboxWidget
{ "type": "string", "format": "date" } DateWidget
{ "type": "string", "format": "time" } TimeWidget
```
#### **Special Widgets:**
**1. ArrayEditorWidget** (`widgets/ArrayEditorWidget/`)
```
┌─────────────────────────────────────┐
│ ○ Item 1 [Text Input] [X Remove] │
│ ○ Item 2 [Text Input] [X Remove] │
│ [+ Add Item] │
└─────────────────────────────────────┘
```
**Features:**
- Each array item gets its own connection handle
- Remove button per item
- Add button at bottom
- Context provider for handle management
**ArrayEditorContext:**
```typescript
{
isArrayItem: true,
arrayFieldHandleId: "input-items-0", // Unique per item
isConnected: false
}
```
**2. ObjectEditorWidget** (`widgets/ObjectEditorWidget/`)
- JSON editor for free-form objects
- Key-value pair management
- Used by ObjectField for `additionalProperties: true`
---
## The Complete Rendering Flow
### **Example: Rendering a Text Input**
```json
// Backend Schema
{
"type": "object",
"properties": {
"message": {
"type": "string",
"title": "Message",
"description": "Enter your message"
}
}
}
```
**Step-by-Step:**
```
1. FormRenderer receives schema
2. preprocessInputSchema() normalizes it
3. RJSF <Form /> starts rendering
4. SchemaField detects "string" type
5. Uses default StringField
6. FieldTemplate wraps it:
- Adds NodeHandle (connection point)
- Adds label "Message (string)"
- Adds info icon with description
7. TextWidget renders <input />
8. User types "Hello"
9. onChange callback fires
10. FormCreator updates nodeStore.updateNodeData()
```
---
### **Example: Rendering AnyOf Field**
```json
// Backend Schema
{
"anyOf": [{ "type": "string" }, { "type": "number" }],
"title": "Value"
}
```
**Rendering Flow:**
```
1. RJSF detects "anyOf"
2. Uses AnyOfField (custom field)
3. AnyOfField renders:
┌─────────────────────────────────┐
│ ○ Value (string) ▼ │ ← Self-managed handle & selector
├─────────────────────────────────┤
│ [Text Input] │ ← Recursively renders SchemaField
└─────────────────────────────────┘
4. User changes type to "number"
5. AnyOfField re-renders with NumberWidget
6. User enters "42"
7. onChange({ type: "number", value: 42 })
```
**Key Point:** AnyOfField **does NOT use FieldTemplate** for itself. It manages its own handle and label to avoid duplication. But it **recursively calls SchemaField** for the selected type, which may use FieldTemplate.
---
### **Example: Rendering Array Field**
```json
// Backend Schema
{
"type": "array",
"items": {
"type": "string"
},
"title": "Tags"
}
```
**Rendering Flow:**
```
1. RJSF detects "array" type
2. Uses ArrayFieldTemplate
3. ArrayFieldTemplate passes to ArrayEditorWidget
4. ArrayEditorWidget renders:
┌─────────────────────────────────┐
│ ○ Tag 1 [Text Input] [X] │ ← Each item wrapped in context
│ ○ Tag 2 [Text Input] [X] │
│ [+ Add Item] │
└─────────────────────────────────┘
5. Each item wrapped in ArrayEditorContext
6. FieldTemplate reads context:
- isArrayItem = true
- Uses arrayFieldHandleId instead of own handle
7. TextWidget renders for each item
```
---
## Hierarchy: What Comes First?
This is the **order of execution** from schema to rendered input:
```
1. JSON Schema (from backend)
2. preprocessInputSchema() (normalization)
3. RJSF <Form /> (library entry point)
4. SchemaField (RJSF internal - decides which field)
5. Field Component (AnyOfField, CredentialsField, or default)
6. Template (FieldTemplate or ArrayFieldTemplate)
7. Widget (TextWidget, SelectWidget, etc.)
8. Actual HTML (<input>, <select>, etc.)
```
---
## Key Concepts Explained
### **1. Why Custom Fields?**
RJSF's default fields don't handle:
- **AnyOf** - Type selection + dynamic widget switching
- **Credentials** - OAuth flows, modal management
- **Free-form Objects** - JSON editor instead of fixed fields
Custom fields fill these gaps.
---
### **2. Why Templates?**
Templates add **FlowEditor-specific UI**:
- Connection handles (left side dots)
- Type indicators
- Tooltips
- Advanced field hiding
- Connection-aware rendering
Default RJSF templates don't support these features.
---
### **3. Why Custom Widgets?**
Custom widgets provide:
- Consistent styling with design system
- Integration with Zustand stores
- Custom behaviors (e.g., FileWidget uploads)
- Better UX (e.g., SwitchWidget vs checkbox)
---
### **4. FormContext - The Shared State**
FormContext passes data down the RJSF tree:
```typescript
type FormContextType = {
nodeId?: string; // Which node this form belongs to
uiType?: BlockUIType; // Block type (INPUT, OUTPUT, etc.)
showHandles?: boolean; // Show connection handles?
size?: "small" | "large"; // Form size variant
};
```
**Why?** RJSF components don't have direct access to React props from parent. FormContext provides a channel.
**Usage:**
```typescript
// In FieldTemplate
const { nodeId, showHandles, size } = formContext;
// Check if input is connected
const isConnected = useEdgeStore().isInputConnected(nodeId, handleId);
// Hide input if connected
{!isConnected && <div>{children}</div>}
```
---
### **5. Handle Management**
Connection handles are the **left-side dots** on nodes where edges connect.
**Handle ID Format:**
```typescript
// Regular field
generateHandleId("root_message") "input-message"
// Array item
generateHandleId("root_tags", ["0"]) "input-tags-0"
generateHandleId("root_tags", ["1"]) "input-tags-1"
// Nested field
generateHandleId("root_config_api_key") "input-config-api_key"
```
**Context Provider Pattern (Arrays):**
```typescript
// ArrayEditorWidget wraps each item
<ArrayEditorContext.Provider
value={{
isArrayItem: true,
arrayFieldHandleId: "input-tags-0"
}}
>
{element.children} // ← FieldTemplate renders here
</ArrayEditorContext.Provider>
// FieldTemplate reads context
const { isArrayItem, arrayFieldHandleId } = useContext(ArrayEditorContext);
// Use array handle instead of generating own
const handleId = isArrayItem ? arrayFieldHandleId : generateHandleId(fieldId);
```
---
## Connection-Aware Rendering
One of the most important features: **hiding inputs when connected**.
**Flow:**
```
1. User connects edge to input handle
2. edgeStore.addEdge() creates connection
3. Next render cycle:
- FieldTemplate calls isInputConnected(nodeId, handleId)
- Returns true
4. FieldTemplate hides input:
{!isConnected && <div>{children}</div>}
5. Only handle visible (with blue highlight)
```
**Why?** When a value comes from another node's output, manual input is disabled. The connection provides the value.
**Exception:** AnyOf fields still show type selector when connected (but hide the input).
---
## Advanced Features
### **1. Advanced Field Toggle**
Some fields marked as `advanced: true` in schema are hidden by default.
**Logic in FieldTemplate:**
```typescript
const showAdvanced = useNodeStore((state) => state.nodeAdvancedStates[nodeId]);
if (!showAdvanced && schema.advanced === true && !isConnected) {
return null; // Hide field
}
```
**UI:** NodeAdvancedToggle button in CustomNode shows/hides these fields.
---
### **2. Nullable Type Handling**
```json
{
"anyOf": [{ "type": "string" }, { "type": "null" }]
}
```
**AnyOfField detects this pattern and renders:**
```
┌─────────────────────────────────────┐
│ Parameter (string | null) [✓] │ ← Switch to enable/disable
├─────────────────────────────────────┤
│ [Input only if enabled] │
└─────────────────────────────────────┘
```
**State Management:**
```typescript
const [isEnabled, setIsEnabled] = useState(formData !== null);
const handleNullableToggle = (checked: boolean) => {
setIsEnabled(checked);
onChange(checked ? "" : null); // Send null when disabled
};
```
---
### **3. Recursive Schema Rendering**
AnyOfField, ObjectField, and ArrayField all recursively call `SchemaField`:
```typescript
const SchemaField = registry.fields.SchemaField;
<SchemaField
schema={nestedSchema}
formData={formData}
onChange={handleChange}
// ... propagate all props
/>
```
This allows **infinite nesting**: arrays of objects, objects with anyOf fields, etc.
---
## Common Patterns
### **Adding a New Widget**
1. Create widget component in `widgets/`:
```typescript
export const MyWidget = ({ value, onChange, ...props }: WidgetProps) => {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
};
```
2. Register in `widgets/index.ts`:
```typescript
export const widgets: RegistryWidgetsType = {
// ...
MyCustomWidget: MyWidget,
};
```
3. Use in uiSchema or schema format:
```json
{
"type": "string",
"format": "my-custom-format" // RJSF maps format → widget
}
```
---
### **Adding a New Field**
1. Create field component in `fields/`:
```typescript
export const MyField = ({ schema, formData, onChange, ...props }: FieldProps) => {
// Custom logic here
return <div>...</div>;
};
```
2. Register in `fields/index.ts`:
```typescript
export const fields: RegistryFieldsType = {
// ...
MyField: MyField,
};
```
3. RJSF uses it based on schema structure (e.g., custom keyword).
---
## Integration with FlowEditor
```
CustomNode
FormCreator
FormRenderer ← YOU ARE HERE
RJSF <Form />
(Fields, Templates, Widgets)
User Input
onChange callback
FormCreator.handleChange()
nodeStore.updateNodeData(nodeId, { hardcodedValues })
historyStore.pushState() (undo/redo)
```
---
## Debugging Tips
### **Field Not Rendering**
- Check if `preprocessInputSchema()` is handling it correctly
- Verify schema has `type` field
- Check RJSF console for validation errors
### **Widget Wrong Type**
- Check schema `type` and `format` fields
- Verify widget is registered in `widgets/index.ts`
- Check if custom field is overriding default behavior
### **Handle Not Appearing**
- Check `showHandles` in formContext
- Verify not inside `fromAnyOf` context
- Check if field is credential or array item
### **Value Not Saving**
- Verify `onChange` callback is firing
- Check `handleChange` in FormCreator
- Look for console errors in `updateNodeData`
---
## Summary
The Input-Renderer is a sophisticated form system that:
1. **Uses RJSF** as the foundation for JSON Schema → React forms
2. **Extends RJSF** with custom Fields, Templates, and Widgets
3. **Integrates** with FlowEditor's connection system
4. **Handles** complex types (anyOf, credentials, free-form objects)
5. **Provides** connection-aware, type-safe input rendering
**Key Hierarchy (What Comes First):**
```
JSON Schema
→ Pre-processing
→ RJSF Form
→ SchemaField (RJSF internal)
→ Field (AnyOfField, CredentialsField, etc.)
→ Template (FieldTemplate, ArrayFieldTemplate)
→ Widget (TextWidget, SelectWidget, etc.)
→ HTML Element
```
**Mental Model:**
- **Fields** = Smart logic layers (type selection, OAuth flows)
- **Templates** = Layout wrappers (handles, labels, tooltips)
- **Widgets** = Actual inputs (text boxes, dropdowns)
**Integration Point:**
- FormRenderer receives schema from `node.data.inputSchema`
- User edits form → `onChange``nodeStore.updateNodeData()`
- Values saved as `node.data.hardcodedValues`

View File

@@ -1,232 +0,0 @@
import React from "react";
import { FieldProps, RJSFSchema } from "@rjsf/utils";
import { Text } from "@/components/atoms/Text/Text";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Select } from "@/components/atoms/Select/Select";
import {
InputType,
mapJsonSchemaTypeToInputType,
} from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { InfoIcon } from "@phosphor-icons/react";
import { useAnyOfField } from "./useAnyOfField";
import NodeHandle from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { generateHandleId } from "@/app/(platform)/build/components/FlowEditor/handlers/helpers";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import merge from "lodash/merge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { cn } from "@/lib/utils";
import { BlockUIType } from "@/app/(platform)/build/components/types";
type TypeOption = {
type: string;
title: string;
index: number;
format?: string;
enum?: any[];
secret?: boolean;
schema: RJSFSchema;
};
export const AnyOfField = ({
schema,
formData,
onChange,
name,
idSchema,
formContext,
registry,
uiSchema,
disabled,
onBlur,
onFocus,
}: FieldProps) => {
const handleId =
formContext.uiType === BlockUIType.AGENT
? (idSchema.$id ?? "")
.split("_")
.filter((p) => p !== "root" && p !== "properties" && p.length > 0)
.join("_") || ""
: generateHandleId(idSchema.$id ?? "");
const updatedFormContexrt = { ...formContext, fromAnyOf: true };
const { nodeId, showHandles = true } = updatedFormContexrt;
const { isInputConnected } = useEdgeStore();
const isConnected = showHandles ? isInputConnected(nodeId, handleId) : false;
const {
isNullableType,
nonNull,
selectedType,
handleTypeChange,
handleNullableToggle,
handleValueChange,
currentTypeOption,
isEnabled,
typeOptions,
} = useAnyOfField(schema, formData, onChange);
const renderInput = (typeOption: TypeOption) => {
const optionSchema = (typeOption.schema || {
type: typeOption.type,
format: typeOption.format,
secret: typeOption.secret,
enum: typeOption.enum,
}) as RJSFSchema;
const inputType = mapJsonSchemaTypeToInputType(optionSchema);
// Help us to tell the field under the anyOf field that you are a part of anyOf field.
// We can't use formContext in this case that's why we are using this.
// We could use context api here, but i think it's better to keep it simple.
const uiSchemaFromAnyOf = merge({}, uiSchema, {
"ui:options": { fromAnyOf: true },
});
// We are using SchemaField to render the field recursively.
if (inputType === InputType.ARRAY_EDITOR) {
const SchemaField = registry.fields.SchemaField;
return (
<div className="-ml-2">
<SchemaField
schema={optionSchema}
formData={formData}
idSchema={idSchema}
uiSchema={uiSchemaFromAnyOf}
onChange={handleValueChange}
onBlur={onBlur}
onFocus={onFocus}
name={name}
registry={registry}
disabled={disabled}
formContext={updatedFormContexrt}
/>
</div>
);
}
const SchemaField = registry.fields.SchemaField;
return (
<div className="-ml-2">
<SchemaField
schema={optionSchema}
formData={formData}
idSchema={idSchema}
uiSchema={uiSchemaFromAnyOf}
onChange={handleValueChange}
onBlur={onBlur}
onFocus={onFocus}
name={name}
registry={registry}
disabled={disabled}
formContext={updatedFormContexrt}
/>
</div>
);
};
// I am doing this, because we need different UI for optional types.
if (isNullableType && nonNull) {
const { displayType, colorClass } = getTypeDisplayInfo(nonNull);
return (
<div className="mb-0 flex flex-col">
<div className="flex items-center justify-between gap-2">
<div
className={cn(
"ml-1 flex items-center gap-1",
showHandles && "-ml-2",
)}
>
{showHandles && (
<NodeHandle
handleId={handleId}
isConnected={isConnected}
side="left"
/>
)}
<Text
variant={formContext.size === "small" ? "body" : "body-medium"}
>
{schema.title || name.charAt(0).toUpperCase() + name.slice(1)}
</Text>
<Text variant="small" className={colorClass}>
({displayType} | null)
</Text>
</div>
{!isConnected && (
<Switch
className="z-10"
checked={isEnabled}
onCheckedChange={handleNullableToggle}
/>
)}
</div>
<div className="mt-2">
{!isConnected && isEnabled && renderInput(nonNull)}
</div>
</div>
);
}
return (
<div className="mb-0 flex flex-col">
<div
className={cn("ml-1 flex items-center gap-1", showHandles && "-ml-2")}
>
{showHandles && (
<NodeHandle
handleId={handleId}
isConnected={isConnected}
side="left"
/>
)}
<Text variant={formContext.size === "small" ? "body" : "body-medium"}>
{schema.title || name.charAt(0).toUpperCase() + name.slice(1)}
</Text>
{!isConnected && (
<Select
label=""
id={`${name}-type-select`}
hideLabel={true}
value={selectedType}
onValueChange={handleTypeChange}
options={typeOptions.map((o) => {
const { displayType } = getTypeDisplayInfo(o);
return { value: o.type, label: displayType };
})}
size="small"
wrapperClassName="!mb-0 "
className="h-6 w-fit gap-1 pl-3 pr-2"
/>
)}
{schema.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
style={{ marginLeft: 6, cursor: "pointer" }}
aria-label="info"
tabIndex={0}
>
<InfoIcon />
</span>
</TooltipTrigger>
<TooltipContent>{schema.description}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="mt-2">
{!isConnected && currentTypeOption && renderInput(currentTypeOption)}
</div>
</div>
);
};

View File

@@ -1,105 +0,0 @@
import { useMemo, useState } from "react";
import { RJSFSchema } from "@rjsf/utils";
const getDefaultValueForType = (type?: string): any => {
if (!type) return "";
switch (type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
default:
return "";
}
};
export const useAnyOfField = (
schema: RJSFSchema,
formData: any,
onChange: (value: any) => void,
) => {
const typeOptions: any[] = useMemo(
() =>
schema.anyOf?.map((opt: any, i: number) => ({
type: opt.type || "string",
title: opt.title || `Option ${i + 1}`,
index: i,
format: opt.format,
enum: opt.enum,
secret: opt.secret,
schema: opt,
})) || [],
[schema.anyOf],
);
const isNullableType = useMemo(
() =>
typeOptions.length === 2 &&
typeOptions.some((o) => o.type === "null") &&
typeOptions.some((o) => o.type !== "null"),
[typeOptions],
);
const nonNull = useMemo(
() => (isNullableType ? typeOptions.find((o) => o.type !== "null") : null),
[isNullableType, typeOptions],
);
const initialSelectedType = useMemo(() => {
const def = schema.default;
const first = typeOptions[0]?.type || "string";
if (isNullableType) return nonNull?.type || "string";
if (typeof def === "string" && typeOptions.some((o) => o.type === def))
return def;
return first;
}, [schema.default, typeOptions, isNullableType, nonNull?.type]);
const [selectedType, setSelectedType] = useState<string>(initialSelectedType);
// Only check for explicit null (set by toggle off), not undefined (empty input)
// This allows users to clear number inputs without the field disappearing
const isEnabled = formData !== null;
const handleTypeChange = (t: string) => {
setSelectedType(t);
onChange(undefined); // clear current value when switching type
};
const handleNullableToggle = (checked: boolean) => {
if (checked) {
onChange(getDefaultValueForType(nonNull?.type));
} else {
onChange(null);
}
};
const handleValueChange = (value: any) => {
if (isNullableType && value === null) {
onChange(undefined);
return;
}
onChange(value);
};
const currentTypeOption = typeOptions.find((o) => o.type === selectedType);
return {
isNullableType,
nonNull,
selectedType,
handleTypeChange,
handleNullableToggle,
handleValueChange,
currentTypeOption,
isEnabled,
typeOptions,
};
};

View File

@@ -1,87 +0,0 @@
import React from "react";
import { FieldProps } from "@rjsf/utils";
import { useCredentialField } from "./useCredentialField";
import { SelectCredential } from "./SelectCredential";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { APIKeyCredentialsModal } from "./models/APIKeyCredentialModal/APIKeyCredentialModal";
import { OAuthCredentialModal } from "./models/OAuthCredentialModal/OAuthCredentialModal";
import { PasswordCredentialsModal } from "./models/PasswordCredentialModal/PasswordCredentialModal";
import { HostScopedCredentialsModal } from "./models/HostScopedCredentialsModal/HostScopedCredentialsModal";
export const CredentialsField = (props: FieldProps) => {
const {
formData = {},
onChange,
required: _required,
schema,
formContext,
} = props;
const {
credentials,
isCredentialListLoading,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsExists,
credentialProvider,
setCredential,
discriminatorValue,
} = useCredentialField({
credentialSchema: schema as BlockIOCredentialsSubSchema,
formData,
nodeId: formContext.nodeId,
onChange,
});
if (isCredentialListLoading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-full rounded-xlarge" />
<Skeleton className="h-8 w-[30%] rounded-xlarge" />
</div>
);
}
if (!credentialProvider) {
return null;
}
return (
<div className="flex flex-col gap-2">
{credentialsExists && (
<SelectCredential
credentials={credentials}
value={formData.id || ""}
onChange={setCredential}
disabled={false}
label="Credential"
placeholder="Select credential"
/>
)}
<div className="flex flex-wrap gap-2">
{supportsApiKey && (
<APIKeyCredentialsModal
schema={schema as BlockIOCredentialsSubSchema}
provider={credentialProvider}
/>
)}
{supportsOAuth2 && (
<OAuthCredentialModal provider={credentialProvider} />
)}
{supportsUserPassword && (
<PasswordCredentialsModal provider={credentialProvider} />
)}
{supportsHostScoped && discriminatorValue && (
<HostScopedCredentialsModal
schema={schema as BlockIOCredentialsSubSchema}
provider={credentialProvider}
discriminatorValue={discriminatorValue}
/>
)}
</div>
</div>
);
};

View File

@@ -1,93 +0,0 @@
import React from "react";
import { Select } from "@/components/atoms/Select/Select";
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
import {
ArrowSquareOutIcon,
KeyholeIcon,
KeyIcon,
} from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { providerIcons } from "./helpers";
type SelectCredentialProps = {
credentials: CredentialsMetaResponse[];
value?: string;
defaultValue?: string;
onChange: (credentialId: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
};
export const SelectCredential: React.FC<SelectCredentialProps> = ({
credentials,
value,
onChange,
disabled = false,
label = "Credential",
placeholder = "Select credential",
}) => {
const options = credentials.map((cred) => {
const details: string[] = [];
if (cred.title && cred.title !== cred.provider) {
details.push(cred.title);
}
if (cred.username) {
details.push(cred.username);
}
if (cred.host) {
details.push(cred.host);
}
const label =
details.length > 0
? `${cred.provider} (${details.join(" - ")})`
: cred.provider;
const Icon = providerIcons[cred.provider];
const icon =
cred.type === "oauth2" ? (
Icon ? (
<Icon />
) : (
<KeyholeIcon />
)
) : (
<KeyIcon className="h-4 w-4" />
);
return {
value: cred.id,
label,
icon,
};
});
return (
<div className="flex w-full items-center gap-2">
<Select
label={label}
id="select-credential"
wrapperClassName="!mb-0 flex-1 !max-w-[90%]"
value={value}
onValueChange={onChange}
options={options}
disabled={disabled}
placeholder={placeholder}
size="small"
hideLabel
/>
<Button
as="NextLink"
href="/profile/integrations"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="icon"
className="h-8 w-8 !min-w-8 border-zinc-300 p-0"
>
<ArrowSquareOutIcon className="h-4 w-4 text-zinc-600" />
</Button>
</div>
);
};

View File

@@ -1,123 +0,0 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormDescription,
FormField,
} from "@/components/__legacy__/ui/form";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types"; // we need to find a way to replace it with autogenerated types
import { useAPIKeyCredentialsModal } from "./useAPIKeyCredentialsModal";
import { toDisplayName } from "../../helpers";
import { KeyIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
type Props = {
schema: BlockIOCredentialsSubSchema;
provider: string;
};
export function APIKeyCredentialsModal({ schema, provider }: Props) {
const { form, schemaDescription, onSubmit, isOpen, setIsOpen } =
useAPIKeyCredentialsModal({ schema, provider });
return (
<>
<Dialog
title={`Add new API key for ${toDisplayName(provider) ?? ""}`}
controlled={{
isOpen: isOpen,
set: (isOpen) => {
if (!isOpen) setIsOpen(false);
},
}}
onClose={() => setIsOpen(false)}
styling={{
maxWidth: "25rem",
}}
>
<Dialog.Content>
{schemaDescription && (
<p className="mb-4 text-sm text-zinc-600">{schemaDescription}</p>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<>
<Input
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API key..."
size="small"
hint={
schema.credentials_scopes ? (
<FormDescription>
Required scope(s) for this block:{" "}
{schema.credentials_scopes?.map((s, i, a) => (
<span key={i}>
<code className="text-xs font-bold">{s}</code>
{i < a.length - 1 && ", "}
</span>
))}
</FormDescription>
) : null
}
{...field}
/>
</>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<Input
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API key..."
size="small"
{...field}
/>
)}
/>
<FormField
control={form.control}
name="expiresAt"
render={({ field }) => (
<Input
id="expiresAt"
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
/>
)}
/>
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
</Button>
</form>
</Form>
</Dialog.Content>
</Dialog>
<Button
type="button"
className="w-fit px-2"
size="small"
onClick={() => setIsOpen(true)}
>
<KeyIcon />
<Text variant="small" className="!text-white opacity-100">
Add API key
</Text>
</Button>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More