Merge remote-tracking branch 'origin/dev' into fix/run-modal-layout-fixes

This commit is contained in:
Lluis Agusti
2026-01-13 15:37:53 +07:00
14 changed files with 710 additions and 21 deletions

View File

@@ -1,3 +1,3 @@
from .blog import WordPressCreatePostBlock
from .blog import WordPressCreatePostBlock, WordPressGetAllPostsBlock
__all__ = ["WordPressCreatePostBlock"]
__all__ = ["WordPressCreatePostBlock", "WordPressGetAllPostsBlock"]

View File

@@ -161,7 +161,7 @@ async def oauth_exchange_code_for_tokens(
grant_type="authorization_code",
).model_dump(exclude_none=True)
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL}oauth2/token",
headers=headers,
data=data,
@@ -205,7 +205,7 @@ async def oauth_refresh_tokens(
grant_type="refresh_token",
).model_dump(exclude_none=True)
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL}oauth2/token",
headers=headers,
data=data,
@@ -252,7 +252,7 @@ async def validate_token(
"token": token,
}
response = await Requests().get(
response = await Requests(raise_for_status=False).get(
f"{WORDPRESS_BASE_URL}oauth2/token-info",
params=params,
)
@@ -296,7 +296,7 @@ async def make_api_request(
url = f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}"
request_method = getattr(Requests(), method.lower())
request_method = getattr(Requests(raise_for_status=False), method.lower())
response = await request_method(
url,
headers=headers,
@@ -476,6 +476,7 @@ async def create_post(
data["tags"] = ",".join(str(t) for t in data["tags"])
# Make the API request
site = normalize_site(site)
endpoint = f"/rest/v1.1/sites/{site}/posts/new"
headers = {
@@ -483,7 +484,7 @@ async def create_post(
"Content-Type": "application/x-www-form-urlencoded",
}
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
headers=headers,
data=data,
@@ -499,3 +500,132 @@ async def create_post(
)
error_message = error_data.get("message", response.text)
raise ValueError(f"Failed to create post: {response.status} - {error_message}")
class Post(BaseModel):
"""Response model for individual posts in a posts list response.
This is a simplified version compared to PostResponse, as the list endpoint
returns less detailed information than the create/get single post endpoints.
"""
ID: int
site_ID: int
author: PostAuthor
date: datetime
modified: datetime
title: str
URL: str
short_URL: str
content: str | None = None
excerpt: str | None = None
slug: str
guid: str
status: str
sticky: bool
password: str | None = ""
parent: Union[Dict[str, Any], bool, None] = None
type: str
discussion: Dict[str, Union[str, bool, int]] | None = None
likes_enabled: bool | None = None
sharing_enabled: bool | None = None
like_count: int | None = None
i_like: bool | None = None
is_reblogged: bool | None = None
is_following: bool | None = None
global_ID: str | None = None
featured_image: str | None = None
post_thumbnail: Dict[str, Any] | None = None
format: str | None = None
geo: Union[Dict[str, Any], bool, None] = None
menu_order: int | None = None
page_template: str | None = None
publicize_URLs: List[str] | None = None
terms: Dict[str, Dict[str, Any]] | None = None
tags: Dict[str, Dict[str, Any]] | None = None
categories: Dict[str, Dict[str, Any]] | None = None
attachments: Dict[str, Dict[str, Any]] | None = None
attachment_count: int | None = None
metadata: List[Dict[str, Any]] | None = None
meta: Dict[str, Any] | None = None
capabilities: Dict[str, bool] | None = None
revisions: List[int] | None = None
other_URLs: Dict[str, Any] | None = None
class PostsResponse(BaseModel):
"""Response model for WordPress posts list."""
found: int
posts: List[Post]
meta: Dict[str, Any]
def normalize_site(site: str) -> str:
"""
Normalize a site identifier by stripping protocol and trailing slashes.
Args:
site: Site URL, domain, or ID (e.g., "https://myblog.wordpress.com/", "myblog.wordpress.com", "123456789")
Returns:
Normalized site identifier (domain or ID only)
"""
site = site.strip()
if site.startswith("https://"):
site = site[8:]
elif site.startswith("http://"):
site = site[7:]
return site.rstrip("/")
async def get_posts(
credentials: Credentials,
site: str,
status: PostStatus | None = None,
number: int = 100,
offset: int = 0,
) -> PostsResponse:
"""
Get posts from a WordPress site.
Args:
credentials: OAuth credentials
site: Site ID or domain (e.g., "myblog.wordpress.com" or "123456789")
status: Filter by post status using PostStatus enum, or None for all
number: Number of posts to retrieve (max 100)
offset: Number of posts to skip (for pagination)
Returns:
PostsResponse with the list of posts
"""
site = normalize_site(site)
endpoint = f"/rest/v1.1/sites/{site}/posts"
headers = {
"Authorization": credentials.auth_header(),
}
params: Dict[str, Any] = {
"number": max(1, min(number, 100)), # 1100 posts per request
"offset": offset,
}
if status:
params["status"] = status.value
response = await Requests(raise_for_status=False).get(
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
headers=headers,
params=params,
)
if response.ok:
return PostsResponse.model_validate(response.json())
error_data = (
response.json()
if response.headers.get("content-type", "").startswith("application/json")
else {}
)
error_message = error_data.get("message", response.text)
raise ValueError(f"Failed to get posts: {response.status} - {error_message}")

View File

@@ -9,7 +9,15 @@ from backend.sdk import (
SchemaField,
)
from ._api import CreatePostRequest, PostResponse, PostStatus, create_post
from ._api import (
CreatePostRequest,
Post,
PostResponse,
PostsResponse,
PostStatus,
create_post,
get_posts,
)
from ._config import wordpress
@@ -49,8 +57,15 @@ class WordPressCreatePostBlock(Block):
media_urls: list[str] = SchemaField(
description="URLs of images to sideload and attach to the post", default=[]
)
publish_as_draft: bool = SchemaField(
description="If True, publishes the post as a draft. If False, publishes it publicly.",
default=False,
)
class Output(BlockSchemaOutput):
site: str = SchemaField(
description="The site ID or domain (pass-through for chaining with other blocks)"
)
post_id: int = SchemaField(description="The ID of the created post")
post_url: str = SchemaField(description="The full URL of the created post")
short_url: str = SchemaField(description="The shortened wp.me URL")
@@ -78,7 +93,9 @@ class WordPressCreatePostBlock(Block):
tags=input_data.tags,
featured_image=input_data.featured_image,
media_urls=input_data.media_urls,
status=PostStatus.PUBLISH,
status=(
PostStatus.DRAFT if input_data.publish_as_draft else PostStatus.PUBLISH
),
)
post_response: PostResponse = await create_post(
@@ -87,7 +104,69 @@ class WordPressCreatePostBlock(Block):
post_data=post_request,
)
yield "site", input_data.site
yield "post_id", post_response.ID
yield "post_url", post_response.URL
yield "short_url", post_response.short_URL
yield "post_data", post_response.model_dump()
class WordPressGetAllPostsBlock(Block):
"""
Fetches all posts from a WordPress.com site or Jetpack-enabled site.
Supports filtering by status and pagination.
"""
class Input(BlockSchemaInput):
credentials: CredentialsMetaInput = wordpress.credentials_field()
site: str = SchemaField(
description="Site ID or domain (e.g., 'myblog.wordpress.com' or '123456789')"
)
status: PostStatus | None = SchemaField(
description="Filter by post status, or None for all",
default=None,
)
number: int = SchemaField(
description="Number of posts to retrieve (max 100 per request)", default=20
)
offset: int = SchemaField(
description="Number of posts to skip (for pagination)", default=0
)
class Output(BlockSchemaOutput):
site: str = SchemaField(
description="The site ID or domain (pass-through for chaining with other blocks)"
)
found: int = SchemaField(description="Total number of posts found")
posts: list[Post] = SchemaField(
description="List of post objects with their details"
)
post: Post = SchemaField(
description="Individual post object (yielded for each post)"
)
def __init__(self):
super().__init__(
id="97728fa7-7f6f-4789-ba0c-f2c114119536",
description="Fetch all posts from WordPress.com or Jetpack sites",
categories={BlockCategory.SOCIAL},
input_schema=self.Input,
output_schema=self.Output,
)
async def run(
self, input_data: Input, *, credentials: Credentials, **kwargs
) -> BlockOutput:
posts_response: PostsResponse = await get_posts(
credentials=credentials,
site=input_data.site,
status=input_data.status,
number=input_data.number,
offset=input_data.offset,
)
yield "site", input_data.site
yield "found", posts_response.found
yield "posts", posts_response.posts
for post in posts_response.posts:
yield "post", post

View File

@@ -36,6 +36,7 @@ type Props = {
readOnly?: boolean;
isOptional?: boolean;
showTitle?: boolean;
variant?: "default" | "node";
};
export function CredentialsInput({
@@ -48,6 +49,7 @@ export function CredentialsInput({
readOnly = false,
isOptional = false,
showTitle = true,
variant = "default",
}: Props) {
const hookData = useCredentialsInput({
schema,
@@ -123,6 +125,7 @@ export function CredentialsInput({
onClearCredential={() => onSelectCredential(undefined)}
readOnly={readOnly}
allowNone={isOptional}
variant={variant}
/>
) : (
<div className="mb-4 space-y-2">

View File

@@ -30,6 +30,8 @@ type CredentialRowProps = {
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
/** When "node", applies compact styling for node context */
variant?: "default" | "node";
};
export function CredentialRow({
@@ -41,14 +43,22 @@ export function CredentialRow({
readOnly = false,
showCaret = false,
asSelectTrigger = false,
variant = "default",
}: CredentialRowProps) {
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const isNodeVariant = variant === "node";
return (
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
asSelectTrigger ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
asSelectTrigger && isNodeVariant
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: asSelectTrigger
? "border-0 bg-transparent"
: readOnly
? "w-fit"
: "",
)}
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
style={
@@ -61,19 +71,31 @@ export function CredentialRow({
<ProviderIcon className="h-3 w-3 text-white" />
</div>
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
isNodeVariant && "overflow-hidden",
)}
>
<Text
variant="body"
className="line-clamp-1 flex-[0_0_50%] text-ellipsis tracking-tight"
className={cn(
"tracking-tight",
isNodeVariant
? "truncate"
: "line-clamp-1 flex-[0_0_50%] text-ellipsis",
)}
>
{getCredentialDisplayName(credential, displayName)}
</Text>
<Text
variant="large"
className="lex-[0_0_40%] relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
{!(asSelectTrigger && isNodeVariant) && (
<Text
variant="large"
className="relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
)}
</div>
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />

View File

@@ -7,6 +7,7 @@ import {
} from "@/components/__legacy__/ui/select";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { getCredentialDisplayName } from "../../helpers";
import { CredentialRow } from "../CredentialRow/CredentialRow";
@@ -26,6 +27,8 @@ interface Props {
onClearCredential?: () => void;
readOnly?: boolean;
allowNone?: boolean;
/** When "node", applies compact styling for node context */
variant?: "default" | "node";
}
export function CredentialsSelect({
@@ -37,6 +40,7 @@ export function CredentialsSelect({
onClearCredential,
readOnly = false,
allowNone = true,
variant = "default",
}: Props) {
// Auto-select first credential if none is selected (only if allowNone is false)
useEffect(() => {
@@ -59,7 +63,12 @@ export function CredentialsSelect({
value={selectedCredentials?.id || (allowNone ? "__none__" : "")}
onValueChange={handleValueChange}
>
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
<SelectTrigger
className={cn(
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden",
)}
>
{selectedCredentials ? (
<SelectValue key={selectedCredentials.id} asChild>
<CredentialRow
@@ -75,6 +84,7 @@ export function CredentialsSelect({
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
variant={variant}
/>
</SelectValue>
) : (

View File

@@ -30,6 +30,8 @@ export const FormRenderer = ({
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
}, [preprocessedSchema, uiSchema]);
console.log("preprocessedSchema", preprocessedSchema);
return (
<div className={"mb-6 mt-4"}>
<Form

View File

@@ -17,6 +17,7 @@ interface InputExpanderModalProps {
defaultValue: string;
description?: string;
placeholder?: string;
inputType?: "text" | "json";
}
export const InputExpanderModal: FC<InputExpanderModalProps> = ({
@@ -27,6 +28,7 @@ export const InputExpanderModal: FC<InputExpanderModalProps> = ({
defaultValue,
description,
placeholder,
inputType = "text",
}) => {
const [tempValue, setTempValue] = useState(defaultValue);
const [isCopied, setIsCopied] = useState(false);
@@ -78,7 +80,10 @@ export const InputExpanderModal: FC<InputExpanderModalProps> = ({
hideLabel
id="input-expander-modal"
value={tempValue}
className="!min-h-[300px] rounded-2xlarge"
className={cn(
"!min-h-[300px] rounded-2xlarge",
inputType === "json" && "font-mono text-sm",
)}
onChange={(e) => setTempValue(e.target.value)}
placeholder={placeholder || "Enter text..."}
autoFocus

View File

@@ -88,6 +88,8 @@ export const CredentialsField = (props: FieldProps) => {
showTitle={false}
readOnly={formContext?.readOnly}
isOptional={!isRequired}
className="w-full"
variant="node"
/>
{/* Optional credentials toggle - only show in builder canvas, not run dialogs */}

View File

@@ -0,0 +1,124 @@
"use client";
import { FieldProps, getTemplate, getUiOptions } from "@rjsf/utils";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { ArrowsOutIcon } from "@phosphor-icons/react";
import { InputExpanderModal } from "../../base/standard/widgets/TextInput/TextInputExpanderModal";
import { getHandleId, updateUiOption } from "../../helpers";
import { useJsonTextField } from "./useJsonTextField";
import { getPlaceholder } from "./helpers";
export const JsonTextField = (props: FieldProps) => {
const {
formData,
onChange,
schema,
registry,
uiSchema,
required,
name,
fieldPathId,
} = props;
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const fieldId = fieldPathId?.$id ?? props.id ?? "json-field";
const handleId = getHandleId({
uiOptions,
id: fieldId,
schema: schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
const {
textValue,
isModalOpen,
handleChange,
handleModalOpen,
handleModalClose,
handleModalSave,
} = useJsonTextField({
formData,
onChange,
path: fieldPathId?.path,
});
const placeholder = getPlaceholder(schema);
const title = schema.title || name || "JSON Value";
return (
<div className="flex flex-col gap-2">
<TitleFieldTemplate
id={fieldId}
title={title}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
<div className="nodrag relative flex items-center gap-2">
<Input
id={fieldId}
hideLabel={true}
type="textarea"
label=""
size="small"
wrapperClassName="mb-0 flex-1 "
value={textValue}
onChange={handleChange}
placeholder={placeholder}
required={required}
disabled={props.disabled}
className="min-h-[60px] pr-8 font-mono text-xs"
/>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleModalOpen}
type="button"
className="p-1"
>
<ArrowsOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand input</TooltipContent>
</Tooltip>
</div>
{schema.description && (
<span className="text-xs text-gray-500">{schema.description}</span>
)}
<InputExpanderModal
isOpen={isModalOpen}
onClose={handleModalClose}
onSave={handleModalSave}
title={`Edit ${title}`}
description={schema.description || "Enter valid JSON"}
defaultValue={textValue}
placeholder={placeholder}
inputType="json"
/>
</div>
);
};
export default JsonTextField;

View File

@@ -0,0 +1,67 @@
import { RJSFSchema } from "@rjsf/utils";
/**
* Converts form data to a JSON string for display
* @param formData - The data to stringify
* @returns JSON string or empty string if data is null/undefined
*/
export function stringifyFormData(formData: unknown): string {
if (formData === undefined || formData === null) {
return "";
}
try {
return JSON.stringify(formData, null, 2);
} catch {
return "";
}
}
/**
* Parses a JSON string into an object/array
* @param value - The JSON string to parse
* @returns Parsed value or undefined if parsing fails or empty
*/
export function parseJsonValue(value: string): unknown | undefined {
const trimmed = value.trim();
if (trimmed === "") {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
/**
* Gets the appropriate placeholder text based on schema type
* @param schema - The JSON schema
* @returns Placeholder string
*/
export function getPlaceholder(schema: RJSFSchema): string {
if (schema.type === "array") {
return '["item1", "item2"] or [{"key": "value"}]';
}
if (schema.type === "object") {
return '{"key": "value"}';
}
return "Enter JSON value...";
}
/**
* Checks if a JSON string is valid
* @param value - The JSON string to validate
* @returns true if valid JSON, false otherwise
*/
export function isValidJson(value: string): boolean {
if (value.trim() === "") {
return true; // Empty is considered valid (will be undefined)
}
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from "react";
import { FieldProps } from "@rjsf/utils";
import { stringifyFormData, parseJsonValue, isValidJson } from "./helpers";
type FieldOnChange = FieldProps["onChange"];
type FieldPathId = FieldProps["fieldPathId"];
interface UseJsonTextFieldOptions {
formData: unknown;
onChange: FieldOnChange;
path?: FieldPathId["path"];
}
interface UseJsonTextFieldReturn {
textValue: string;
isModalOpen: boolean;
hasError: boolean;
handleChange: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
handleModalOpen: () => void;
handleModalClose: () => void;
handleModalSave: (value: string) => void;
}
/**
* Custom hook for managing JSON text field state and handlers
*/
export function useJsonTextField({
formData,
onChange,
path,
}: UseJsonTextFieldOptions): UseJsonTextFieldReturn {
const [textValue, setTextValue] = useState(() => stringifyFormData(formData));
const [isModalOpen, setIsModalOpen] = useState(false);
const [hasError, setHasError] = useState(false);
// Update text value when formData changes externally
useEffect(() => {
const newValue = stringifyFormData(formData);
setTextValue(newValue);
setHasError(false);
}, [formData]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setTextValue(value);
// Validate JSON and update error state
const valid = isValidJson(value);
setHasError(!valid);
// Try to parse and update formData
if (value.trim() === "") {
onChange(undefined, path ?? []);
return;
}
const parsed = parseJsonValue(value);
if (parsed !== undefined) {
onChange(parsed, path ?? []);
}
},
[onChange, path],
);
const handleModalOpen = useCallback(() => {
setIsModalOpen(true);
}, []);
const handleModalClose = useCallback(() => {
setIsModalOpen(false);
}, []);
const handleModalSave = useCallback(
(value: string) => {
setTextValue(value);
setIsModalOpen(false);
// Validate and update
const valid = isValidJson(value);
setHasError(!valid);
if (value.trim() === "") {
onChange(undefined, path ?? []);
return;
}
const parsed = parseJsonValue(value);
if (parsed !== undefined) {
onChange(parsed, path ?? []);
}
},
[onChange, path],
);
return {
textValue,
isModalOpen,
hasError,
handleChange,
handleModalOpen,
handleModalClose,
handleModalSave,
};
}

View File

@@ -1,6 +1,7 @@
import { FieldProps, RJSFSchema, RegistryFieldsType } from "@rjsf/utils";
import { CredentialsField } from "./CredentialField/CredentialField";
import { GoogleDrivePickerField } from "./GoogleDrivePickerField/GoogleDrivePickerField";
import { JsonTextField } from "./JsonTextField/JsonTextField";
import { MultiSelectField } from "./MultiSelectField/MultiSelectField";
import { isMultiSelectSchema } from "../utils/schema-utils";
import { TableField } from "./TableField/TableField";
@@ -11,6 +12,9 @@ export interface CustomFieldDefinition {
component: (props: FieldProps<any, RJSFSchema, any>) => JSX.Element | null;
}
/** Field ID for JsonTextField - used to render nested complex types as text input */
export const JSON_TEXT_FIELD_ID = "custom/json_text_field";
export const CUSTOM_FIELDS: CustomFieldDefinition[] = [
{
id: "custom/credential_field",
@@ -33,6 +37,12 @@ export const CUSTOM_FIELDS: CustomFieldDefinition[] = [
},
component: GoogleDrivePickerField,
},
{
id: "custom/json_text_field",
// Not matched by schema - assigned via uiSchema for nested complex types
matcher: () => false,
component: JsonTextField,
},
{
id: "custom/multi_select_field",
matcher: isMultiSelectSchema,

View File

@@ -1,19 +1,46 @@
import { RJSFSchema, UiSchema } from "@rjsf/utils";
import { findCustomFieldId } from "../custom/custom-registry";
import {
findCustomFieldId,
JSON_TEXT_FIELD_ID,
} from "../custom/custom-registry";
function isComplexType(schema: RJSFSchema): boolean {
return schema.type === "object" || schema.type === "array";
}
function hasComplexAnyOfOptions(schema: RJSFSchema): boolean {
const options = schema.anyOf || schema.oneOf;
if (!Array.isArray(options)) return false;
return options.some(
(opt: any) =>
opt &&
typeof opt === "object" &&
(opt.type === "object" || opt.type === "array"),
);
}
/**
* Generates uiSchema with ui:field settings for custom fields based on schema matchers.
* This is the standard RJSF way to route fields to custom components.
*
* Nested complex types (arrays/objects inside arrays/objects) are rendered as JsonTextField
* to avoid deeply nested form UIs. Users can enter raw JSON for these fields.
*
* @param schema - The JSON schema
* @param existingUiSchema - Existing uiSchema to merge with
* @param insideComplexType - Whether we're already inside a complex type (object/array)
*/
export function generateUiSchemaForCustomFields(
schema: RJSFSchema,
existingUiSchema: UiSchema = {},
insideComplexType: boolean = false,
): UiSchema {
const uiSchema: UiSchema = { ...existingUiSchema };
if (schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (propSchema && typeof propSchema === "object") {
// First check for custom field matchers (credentials, google drive, etc.)
const customFieldId = findCustomFieldId(propSchema);
if (customFieldId) {
@@ -21,8 +48,33 @@ export function generateUiSchemaForCustomFields(
...(uiSchema[key] as object),
"ui:field": customFieldId,
};
// Skip further processing for custom fields
continue;
}
// Handle nested complex types - render as JsonTextField
if (insideComplexType && isComplexType(propSchema as RJSFSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
"ui:field": JSON_TEXT_FIELD_ID,
};
// Don't recurse further - this field is now a text input
continue;
}
// Handle anyOf/oneOf inside complex types
if (
insideComplexType &&
hasComplexAnyOfOptions(propSchema as RJSFSchema)
) {
uiSchema[key] = {
...(uiSchema[key] as object),
"ui:field": JSON_TEXT_FIELD_ID,
};
continue;
}
// Recurse into object properties
if (
propSchema.type === "object" &&
propSchema.properties &&
@@ -31,6 +83,7 @@ export function generateUiSchemaForCustomFields(
const nestedUiSchema = generateUiSchemaForCustomFields(
propSchema as RJSFSchema,
(uiSchema[key] as UiSchema) || {},
true, // Now inside a complex type
);
uiSchema[key] = {
...(uiSchema[key] as object),
@@ -38,9 +91,11 @@ export function generateUiSchemaForCustomFields(
};
}
// Handle arrays
if (propSchema.type === "array" && propSchema.items) {
const itemsSchema = propSchema.items as RJSFSchema;
if (itemsSchema && typeof itemsSchema === "object") {
// Check for custom field on array items
const itemsCustomFieldId = findCustomFieldId(itemsSchema);
if (itemsCustomFieldId) {
uiSchema[key] = {
@@ -49,10 +104,28 @@ export function generateUiSchemaForCustomFields(
"ui:field": itemsCustomFieldId,
},
};
} else if (isComplexType(itemsSchema)) {
// Array items that are complex types become JsonTextField
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (hasComplexAnyOfOptions(itemsSchema)) {
// Array items with anyOf containing complex types become JsonTextField
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (itemsSchema.properties) {
// Recurse into object items (but they're now inside a complex type)
const itemsUiSchema = generateUiSchemaForCustomFields(
itemsSchema,
((uiSchema[key] as UiSchema)?.items as UiSchema) || {},
true, // Inside complex type (array)
);
if (Object.keys(itemsUiSchema).length > 0) {
uiSchema[key] = {
@@ -63,6 +136,61 @@ export function generateUiSchemaForCustomFields(
}
}
}
// Handle anyOf/oneOf at root level - process complex options
if (!insideComplexType) {
const anyOfOptions = propSchema.anyOf || propSchema.oneOf;
if (Array.isArray(anyOfOptions)) {
for (let i = 0; i < anyOfOptions.length; i++) {
const option = anyOfOptions[i] as RJSFSchema;
if (option && typeof option === "object") {
// Handle anyOf array options with complex items
if (option.type === "array" && option.items) {
const itemsSchema = option.items as RJSFSchema;
if (itemsSchema && typeof itemsSchema === "object") {
// Array items that are complex types become JsonTextField
if (isComplexType(itemsSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (hasComplexAnyOfOptions(itemsSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
}
}
}
// Recurse into anyOf object options with properties
if (
option.type === "object" &&
option.properties &&
typeof option.properties === "object"
) {
const optionUiSchema = generateUiSchemaForCustomFields(
option,
{},
true, // Inside complex type (anyOf object option)
);
if (Object.keys(optionUiSchema).length > 0) {
// Store under the property key - RJSF will apply it
uiSchema[key] = {
...(uiSchema[key] as object),
...optionUiSchema,
};
}
}
}
}
}
}
}
}
}