mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge remote-tracking branch 'origin/dev' into fix/run-modal-layout-fixes
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
from .blog import WordPressCreatePostBlock
|
||||
from .blog import WordPressCreatePostBlock, WordPressGetAllPostsBlock
|
||||
|
||||
__all__ = ["WordPressCreatePostBlock"]
|
||||
__all__ = ["WordPressCreatePostBlock", "WordPressGetAllPostsBlock"]
|
||||
|
||||
@@ -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)), # 1–100 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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user