1049 lines
27 KiB
TypeScript
1049 lines
27 KiB
TypeScript
import React, { useCallback, useMemo, useState } from "react";
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Eye,
|
|
EyeOff,
|
|
Plus,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Threshold for string length above which a Textarea is used instead of a standard Input.
|
|
*/
|
|
const TEXTAREA_THRESHOLD = 200;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Subset of JSON Schema properties we understand for form rendering.
|
|
* We intentionally keep this loose (`Record<string, unknown>`) at the top
|
|
* level to match the `JsonSchema` type in shared, but narrow internally.
|
|
*/
|
|
export interface JsonSchemaNode {
|
|
type?: string | string[];
|
|
title?: string;
|
|
description?: string;
|
|
default?: unknown;
|
|
enum?: unknown[];
|
|
const?: unknown;
|
|
format?: string;
|
|
|
|
// String constraints
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
pattern?: string;
|
|
|
|
// Number constraints
|
|
minimum?: number;
|
|
maximum?: number;
|
|
exclusiveMinimum?: number;
|
|
exclusiveMaximum?: number;
|
|
multipleOf?: number;
|
|
|
|
// Object
|
|
properties?: Record<string, JsonSchemaNode>;
|
|
required?: string[];
|
|
additionalProperties?: boolean | JsonSchemaNode;
|
|
|
|
// Array
|
|
items?: JsonSchemaNode;
|
|
minItems?: number;
|
|
maxItems?: number;
|
|
|
|
// Metadata
|
|
readOnly?: boolean;
|
|
writeOnly?: boolean;
|
|
|
|
// Allow extra keys
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface JsonSchemaFormProps {
|
|
/** The JSON Schema to render. */
|
|
schema: JsonSchemaNode;
|
|
/** Current form values. */
|
|
values: Record<string, unknown>;
|
|
/** Called whenever any field value changes. */
|
|
onChange: (values: Record<string, unknown>) => void;
|
|
/** Validation errors keyed by JSON pointer path (e.g. "/apiKey"). */
|
|
errors?: Record<string, string>;
|
|
/** If true, all fields are disabled. */
|
|
disabled?: boolean;
|
|
/** Additional CSS class for the root container. */
|
|
className?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Resolve the primary type string from a schema node. */
|
|
export function resolveType(schema: JsonSchemaNode): string {
|
|
if (schema.enum) return "enum";
|
|
if (schema.const !== undefined) return "const";
|
|
if (schema.format === "secret-ref") return "secret-ref";
|
|
if (Array.isArray(schema.type)) {
|
|
// Use the first non-null type
|
|
return schema.type.find((t) => t !== "null") ?? "string";
|
|
}
|
|
return schema.type ?? "string";
|
|
}
|
|
|
|
/** Human-readable label from schema title or property key. */
|
|
export function labelFromKey(key: string, schema: JsonSchemaNode): string {
|
|
if (schema.title) return schema.title;
|
|
// Convert camelCase / snake_case to Title Case
|
|
return key
|
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
.replace(/[_-]+/g, " ")
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
/** Produce a sensible default value for a schema node. */
|
|
export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
|
|
if (schema.default !== undefined) return schema.default;
|
|
|
|
const type = resolveType(schema);
|
|
switch (type) {
|
|
case "string":
|
|
case "secret-ref":
|
|
return "";
|
|
case "number":
|
|
case "integer":
|
|
return schema.minimum ?? 0;
|
|
case "boolean":
|
|
return false;
|
|
case "enum":
|
|
return schema.enum?.[0] ?? "";
|
|
case "array":
|
|
return [];
|
|
case "object": {
|
|
if (!schema.properties) return {};
|
|
const obj: Record<string, unknown> = {};
|
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
obj[key] = getDefaultForSchema(propSchema);
|
|
}
|
|
return obj;
|
|
}
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Validate a single field value against schema constraints. Returns error string or null. */
|
|
export function validateField(
|
|
value: unknown,
|
|
schema: JsonSchemaNode,
|
|
isRequired: boolean,
|
|
): string | null {
|
|
const type = resolveType(schema);
|
|
|
|
// Required check
|
|
if (isRequired && (value === undefined || value === null || value === "")) {
|
|
return "This field is required";
|
|
}
|
|
|
|
// Skip further validation if empty and not required
|
|
if (value === undefined || value === null || value === "") return null;
|
|
|
|
if (type === "string" || type === "secret-ref") {
|
|
const str = String(value);
|
|
if (schema.minLength != null && str.length < schema.minLength) {
|
|
return `Must be at least ${schema.minLength} characters`;
|
|
}
|
|
if (schema.maxLength != null && str.length > schema.maxLength) {
|
|
return `Must be at most ${schema.maxLength} characters`;
|
|
}
|
|
if (schema.pattern) {
|
|
// Guard against ReDoS: reject overly complex patterns from plugin JSON Schemas.
|
|
// Limit pattern length and run the regex with a defensive try/catch.
|
|
const MAX_PATTERN_LENGTH = 512;
|
|
if (schema.pattern.length <= MAX_PATTERN_LENGTH) {
|
|
try {
|
|
const re = new RegExp(schema.pattern);
|
|
if (!re.test(str)) {
|
|
return `Must match pattern: ${schema.pattern}`;
|
|
}
|
|
} catch {
|
|
// Invalid regex in schema — skip
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (type === "number" || type === "integer") {
|
|
const num = Number(value);
|
|
if (isNaN(num)) return "Must be a valid number";
|
|
if (schema.minimum != null && num < schema.minimum) {
|
|
return `Must be at least ${schema.minimum}`;
|
|
}
|
|
if (schema.maximum != null && num > schema.maximum) {
|
|
return `Must be at most ${schema.maximum}`;
|
|
}
|
|
if (schema.exclusiveMinimum != null && num <= schema.exclusiveMinimum) {
|
|
return `Must be greater than ${schema.exclusiveMinimum}`;
|
|
}
|
|
if (schema.exclusiveMaximum != null && num >= schema.exclusiveMaximum) {
|
|
return `Must be less than ${schema.exclusiveMaximum}`;
|
|
}
|
|
if (type === "integer" && !Number.isInteger(num)) {
|
|
return "Must be a whole number";
|
|
}
|
|
if (schema.multipleOf != null && num % schema.multipleOf !== 0) {
|
|
return `Must be a multiple of ${schema.multipleOf}`;
|
|
}
|
|
}
|
|
|
|
if (type === "array") {
|
|
const arr = value as unknown[];
|
|
if (schema.minItems != null && arr.length < schema.minItems) {
|
|
return `Must have at least ${schema.minItems} items`;
|
|
}
|
|
if (schema.maxItems != null && arr.length > schema.maxItems) {
|
|
return `Must have at most ${schema.maxItems} items`;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Public API for validation */
|
|
export function validateJsonSchemaForm(
|
|
schema: JsonSchemaNode,
|
|
values: Record<string, unknown>,
|
|
path: string[] = [],
|
|
): Record<string, string> {
|
|
const errors: Record<string, string> = {};
|
|
const properties = schema.properties ?? {};
|
|
const requiredFields = new Set(schema.required ?? []);
|
|
|
|
for (const [key, propSchema] of Object.entries(properties)) {
|
|
const fieldPath = [...path, key];
|
|
const errorKey = `/${fieldPath.join("/")}`;
|
|
const value = values[key];
|
|
const isRequired = requiredFields.has(key);
|
|
const type = resolveType(propSchema);
|
|
|
|
// Per-field validation
|
|
const fieldErr = validateField(value, propSchema, isRequired);
|
|
if (fieldErr) {
|
|
errors[errorKey] = fieldErr;
|
|
}
|
|
|
|
// Recurse into objects
|
|
if (type === "object" && propSchema.properties && typeof value === "object" && value !== null) {
|
|
Object.assign(
|
|
errors,
|
|
validateJsonSchemaForm(propSchema, value as Record<string, unknown>, fieldPath),
|
|
);
|
|
}
|
|
|
|
// Recurse into arrays
|
|
if (type === "array" && propSchema.items && Array.isArray(value)) {
|
|
const itemSchema = propSchema.items as JsonSchemaNode;
|
|
const isObjectItem = resolveType(itemSchema) === "object";
|
|
|
|
value.forEach((item, index) => {
|
|
const itemPath = [...fieldPath, String(index)];
|
|
const itemErrorKey = `/${itemPath.join("/")}`;
|
|
|
|
if (isObjectItem) {
|
|
Object.assign(
|
|
errors,
|
|
validateJsonSchemaForm(
|
|
itemSchema,
|
|
item as Record<string, unknown>,
|
|
itemPath,
|
|
),
|
|
);
|
|
} else {
|
|
const itemErr = validateField(item, itemSchema, false);
|
|
if (itemErr) {
|
|
errors[itemErrorKey] = itemErr;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/** Public API for default values */
|
|
export function getDefaultValues(schema: JsonSchemaNode): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
const properties = schema.properties ?? {};
|
|
|
|
for (const [key, propSchema] of Object.entries(properties)) {
|
|
const def = getDefaultForSchema(propSchema);
|
|
if (def !== undefined) {
|
|
result[key] = def;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal Components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FieldWrapperProps {
|
|
label: string;
|
|
description?: string;
|
|
required?: boolean;
|
|
error?: string;
|
|
disabled?: boolean;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Common wrapper for form fields that handles labels, descriptions, and error messages.
|
|
*/
|
|
const FieldWrapper = React.memo(({
|
|
label,
|
|
description,
|
|
required,
|
|
error,
|
|
disabled,
|
|
children,
|
|
}: FieldWrapperProps) => {
|
|
return (
|
|
<div className={cn("space-y-2", disabled && "opacity-60")}>
|
|
<div className="flex items-center justify-between">
|
|
{label && (
|
|
<Label className="text-sm font-medium">
|
|
{label}
|
|
{required && <span className="ml-1 text-destructive">*</span>}
|
|
</Label>
|
|
)}
|
|
</div>
|
|
{children}
|
|
{description && (
|
|
<p className="text-[12px] text-muted-foreground leading-relaxed">
|
|
{description}
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p className="text-[12px] font-medium text-destructive">{error}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
FieldWrapper.displayName = "FieldWrapper";
|
|
|
|
interface FormFieldProps {
|
|
propSchema: JsonSchemaNode;
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
error?: string;
|
|
disabled?: boolean;
|
|
label: string;
|
|
isRequired?: boolean;
|
|
errors: Record<string, string>; // needed for recursion
|
|
path: string; // needed for recursion error filtering
|
|
}
|
|
|
|
/**
|
|
* Specialized field for boolean (checkbox) values.
|
|
*/
|
|
const BooleanField = React.memo(({
|
|
id,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
label,
|
|
isRequired,
|
|
description,
|
|
error,
|
|
}: {
|
|
id: string;
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
isRequired?: boolean;
|
|
description?: string;
|
|
error?: string;
|
|
}) => (
|
|
<div className="flex items-start space-x-3 space-y-0">
|
|
<Checkbox
|
|
id={id}
|
|
checked={!!value}
|
|
onCheckedChange={onChange}
|
|
disabled={disabled}
|
|
/>
|
|
<div className="grid gap-1.5 leading-none">
|
|
{label && (
|
|
<Label
|
|
htmlFor={id}
|
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
>
|
|
{label}
|
|
{isRequired && <span className="ml-1 text-destructive">*</span>}
|
|
</Label>
|
|
)}
|
|
{description && (
|
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
)}
|
|
{error && (
|
|
<p className="text-xs font-medium text-destructive">{error}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
));
|
|
|
|
BooleanField.displayName = "BooleanField";
|
|
|
|
/**
|
|
* Specialized field for enum (select) values.
|
|
*/
|
|
const EnumField = React.memo(({
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
label,
|
|
isRequired,
|
|
description,
|
|
error,
|
|
options,
|
|
}: {
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
isRequired?: boolean;
|
|
description?: string;
|
|
error?: string;
|
|
options: unknown[];
|
|
}) => (
|
|
<FieldWrapper
|
|
label={label}
|
|
description={description}
|
|
required={isRequired}
|
|
error={error}
|
|
disabled={disabled}
|
|
>
|
|
<Select
|
|
value={String(value ?? "")}
|
|
onValueChange={onChange}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select an option" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem key={String(option)} value={String(option)}>
|
|
{String(option)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FieldWrapper>
|
|
));
|
|
|
|
EnumField.displayName = "EnumField";
|
|
|
|
/**
|
|
* Specialized field for secret-ref values, providing a toggleable password input.
|
|
*/
|
|
const SecretField = React.memo(({
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
label,
|
|
isRequired,
|
|
description,
|
|
error,
|
|
defaultValue,
|
|
}: {
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
isRequired?: boolean;
|
|
description?: string;
|
|
error?: string;
|
|
defaultValue?: unknown;
|
|
}) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
return (
|
|
<FieldWrapper
|
|
label={label}
|
|
description={
|
|
description ||
|
|
"This secret is stored securely via the Paperclip secret provider."
|
|
}
|
|
required={isRequired}
|
|
error={error}
|
|
disabled={disabled}
|
|
>
|
|
<div className="relative">
|
|
<Input
|
|
type={isVisible ? "text" : "password"}
|
|
value={String(value ?? "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={String(defaultValue ?? "")}
|
|
disabled={disabled}
|
|
className="pr-10"
|
|
aria-invalid={!!error}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
onClick={() => setIsVisible(!isVisible)}
|
|
disabled={disabled}
|
|
>
|
|
{isVisible ? (
|
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<span className="sr-only">
|
|
{isVisible ? "Hide secret" : "Show secret"}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</FieldWrapper>
|
|
);
|
|
});
|
|
|
|
SecretField.displayName = "SecretField";
|
|
|
|
/**
|
|
* Specialized field for numeric (number/integer) values.
|
|
*/
|
|
const NumberField = React.memo(({
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
label,
|
|
isRequired,
|
|
description,
|
|
error,
|
|
defaultValue,
|
|
type,
|
|
}: {
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
isRequired?: boolean;
|
|
description?: string;
|
|
error?: string;
|
|
defaultValue?: unknown;
|
|
type: "number" | "integer";
|
|
}) => (
|
|
<FieldWrapper
|
|
label={label}
|
|
description={description}
|
|
required={isRequired}
|
|
error={error}
|
|
disabled={disabled}
|
|
>
|
|
<Input
|
|
type="number"
|
|
step={type === "integer" ? "1" : "any"}
|
|
value={value !== undefined ? String(value) : ""}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
onChange(val === "" ? undefined : Number(val));
|
|
}}
|
|
placeholder={String(defaultValue ?? "")}
|
|
disabled={disabled}
|
|
aria-invalid={!!error}
|
|
/>
|
|
</FieldWrapper>
|
|
));
|
|
|
|
NumberField.displayName = "NumberField";
|
|
|
|
/**
|
|
* Specialized field for string values, rendering either an Input or Textarea based on length or format.
|
|
*/
|
|
const StringField = React.memo(({
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
label,
|
|
isRequired,
|
|
description,
|
|
error,
|
|
defaultValue,
|
|
format,
|
|
maxLength,
|
|
}: {
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
isRequired?: boolean;
|
|
description?: string;
|
|
error?: string;
|
|
defaultValue?: unknown;
|
|
format?: string;
|
|
maxLength?: number;
|
|
}) => {
|
|
const isTextArea = format === "textarea" || (maxLength && maxLength > TEXTAREA_THRESHOLD);
|
|
return (
|
|
<FieldWrapper
|
|
label={label}
|
|
description={description}
|
|
required={isRequired}
|
|
error={error}
|
|
disabled={disabled}
|
|
>
|
|
{isTextArea ? (
|
|
<Textarea
|
|
value={String(value ?? "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={String(defaultValue ?? "")}
|
|
disabled={disabled}
|
|
className="min-h-[100px]"
|
|
aria-invalid={!!error}
|
|
/>
|
|
) : (
|
|
<Input
|
|
type="text"
|
|
value={String(value ?? "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={String(defaultValue ?? "")}
|
|
disabled={disabled}
|
|
aria-invalid={!!error}
|
|
/>
|
|
)}
|
|
</FieldWrapper>
|
|
);
|
|
});
|
|
|
|
StringField.displayName = "StringField";
|
|
|
|
/**
|
|
* Specialized field for array values, handling dynamic addition and removal of items.
|
|
*/
|
|
const ArrayField = React.memo(({
|
|
propSchema,
|
|
value,
|
|
onChange,
|
|
error,
|
|
disabled,
|
|
label,
|
|
errors,
|
|
path,
|
|
}: {
|
|
propSchema: JsonSchemaNode;
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
error?: string;
|
|
disabled: boolean;
|
|
label: string;
|
|
errors: Record<string, string>;
|
|
path: string;
|
|
}) => {
|
|
const items = Array.isArray(value) ? value : [];
|
|
const itemSchema = propSchema.items as JsonSchemaNode;
|
|
const isComplex = resolveType(itemSchema) === "object";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm font-medium">{label}</Label>
|
|
{propSchema.description && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{propSchema.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={
|
|
disabled ||
|
|
(propSchema.maxItems !== undefined &&
|
|
items.length >= (propSchema.maxItems as number))
|
|
}
|
|
onClick={() => {
|
|
const newItem = getDefaultForSchema(itemSchema);
|
|
onChange([...items, newItem]);
|
|
}}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{isComplex ? "Add item" : "Add"}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={index}
|
|
className="group relative flex items-start space-x-2 rounded-lg border p-3"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
|
Item {index + 1}
|
|
</div>
|
|
<FormField
|
|
propSchema={itemSchema}
|
|
value={item}
|
|
label=""
|
|
path={`${path}/${index}`}
|
|
onChange={(newVal) => {
|
|
const newItems = [...items];
|
|
newItems[index] = newVal;
|
|
onChange(newItems);
|
|
}}
|
|
disabled={disabled}
|
|
errors={errors}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
disabled={
|
|
disabled ||
|
|
(propSchema.minItems !== undefined &&
|
|
items.length <= (propSchema.minItems as number))
|
|
}
|
|
onClick={() => {
|
|
const newItems = [...items];
|
|
newItems.splice(index, 1);
|
|
onChange(newItems);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span className="sr-only">Remove item</span>
|
|
</Button>
|
|
</div>
|
|
))}
|
|
{items.length === 0 && (
|
|
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
|
No items added yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<p className="text-xs font-medium text-destructive">{error}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
ArrayField.displayName = "ArrayField";
|
|
|
|
/**
|
|
* Specialized field for object values, handling recursive rendering of nested properties.
|
|
*/
|
|
const ObjectField = React.memo(({
|
|
propSchema,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
label,
|
|
errors,
|
|
path,
|
|
}: {
|
|
propSchema: JsonSchemaNode;
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
errors: Record<string, string>;
|
|
path: string;
|
|
}) => {
|
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
const handleObjectChange = (newVal: Record<string, unknown>) => {
|
|
onChange(newVal);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3 rounded-lg border p-4">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between"
|
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
>
|
|
<div className="text-left">
|
|
<Label className="cursor-pointer text-sm font-semibold">
|
|
{label}
|
|
</Label>
|
|
{propSchema.description && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{propSchema.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{isCollapsed ? (
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
|
|
{!isCollapsed && (
|
|
<div className="pt-2">
|
|
<JsonSchemaForm
|
|
schema={propSchema}
|
|
values={(value as Record<string, unknown>) ?? {}}
|
|
onChange={handleObjectChange}
|
|
disabled={disabled}
|
|
errors={Object.fromEntries(
|
|
Object.entries(errors)
|
|
.filter(([errPath]) => errPath.startsWith(`${path}/`))
|
|
.map(([errPath, err]) => [errPath.replace(path, ""), err]),
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
ObjectField.displayName = "ObjectField";
|
|
|
|
/**
|
|
* Orchestrator component that selects and renders the appropriate field type based on the schema node.
|
|
*/
|
|
const FormField = React.memo(({
|
|
propSchema,
|
|
value,
|
|
onChange,
|
|
error,
|
|
disabled,
|
|
label,
|
|
isRequired,
|
|
errors,
|
|
path,
|
|
}: FormFieldProps) => {
|
|
const type = resolveType(propSchema);
|
|
const isReadOnly = disabled || propSchema.readOnly === true;
|
|
|
|
switch (type) {
|
|
case "boolean":
|
|
return (
|
|
<BooleanField
|
|
id={path}
|
|
value={value}
|
|
onChange={onChange}
|
|
disabled={isReadOnly}
|
|
label={label}
|
|
isRequired={isRequired}
|
|
description={propSchema.description}
|
|
error={error}
|
|
/>
|
|
);
|
|
|
|
case "enum":
|
|
return (
|
|
<EnumField
|
|
value={value}
|
|
onChange={onChange}
|
|
disabled={isReadOnly}
|
|
label={label}
|
|
isRequired={isRequired}
|
|
description={propSchema.description}
|
|
error={error}
|
|
options={propSchema.enum ?? []}
|
|
/>
|
|
);
|
|
|
|
case "secret-ref":
|
|
return (
|
|
<SecretField
|
|
value={value}
|
|
onChange={onChange}
|
|
disabled={isReadOnly}
|
|
label={label}
|
|
isRequired={isRequired}
|
|
description={propSchema.description}
|
|
error={error}
|
|
defaultValue={propSchema.default}
|
|
/>
|
|
);
|
|
|
|
case "number":
|
|
case "integer":
|
|
return (
|
|
<NumberField
|
|
value={value}
|
|
onChange={onChange}
|
|
disabled={isReadOnly}
|
|
label={label}
|
|
isRequired={isRequired}
|
|
description={propSchema.description}
|
|
error={error}
|
|
defaultValue={propSchema.default}
|
|
type={type as "number" | "integer"}
|
|
/>
|
|
);
|
|
|
|
case "array":
|
|
return (
|
|
<ArrayField
|
|
propSchema={propSchema}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
disabled={isReadOnly}
|
|
label={label}
|
|
errors={errors}
|
|
path={path}
|
|
/>
|
|
);
|
|
|
|
case "object":
|
|
return (
|
|
<ObjectField
|
|
propSchema={propSchema}
|
|
value={value}
|
|
onChange={onChange}
|
|
disabled={isReadOnly}
|
|
label={label}
|
|
errors={errors}
|
|
path={path}
|
|
/>
|
|
);
|
|
|
|
default: // string
|
|
return (
|
|
<StringField
|
|
value={value}
|
|
onChange={onChange}
|
|
disabled={isReadOnly}
|
|
label={label}
|
|
isRequired={isRequired}
|
|
description={propSchema.description}
|
|
error={error}
|
|
defaultValue={propSchema.default}
|
|
format={propSchema.format}
|
|
maxLength={propSchema.maxLength}
|
|
/>
|
|
);
|
|
}
|
|
});
|
|
|
|
FormField.displayName = "FormField";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Main JsonSchemaForm component.
|
|
* Renders a form based on a subset of JSON Schema specification.
|
|
* Supports primitive types, enums, secrets, objects, and arrays with recursion.
|
|
*/
|
|
export function JsonSchemaForm({
|
|
schema,
|
|
values,
|
|
onChange,
|
|
errors = {},
|
|
disabled,
|
|
className,
|
|
}: JsonSchemaFormProps) {
|
|
const type = resolveType(schema);
|
|
|
|
const handleRootScalarChange = useCallback((newVal: unknown) => {
|
|
// If root is a scalar, values IS the value
|
|
onChange(newVal as Record<string, unknown>);
|
|
}, [onChange]);
|
|
|
|
// If it's a scalar at root, render a single FormField
|
|
if (type !== "object") {
|
|
return (
|
|
<div className={className}>
|
|
<FormField
|
|
propSchema={schema}
|
|
value={values}
|
|
label=""
|
|
path=""
|
|
onChange={handleRootScalarChange}
|
|
disabled={disabled}
|
|
errors={errors}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Memoize to avoid re-renders when parent provides new object references
|
|
const properties = useMemo(() => schema.properties ?? {}, [schema.properties]);
|
|
const requiredFields = useMemo(
|
|
() => new Set(schema.required ?? []),
|
|
[schema.required],
|
|
);
|
|
|
|
const handleFieldChange = useCallback(
|
|
(key: string, value: unknown) => {
|
|
onChange({ ...values, [key]: value });
|
|
},
|
|
[onChange, values],
|
|
);
|
|
|
|
if (Object.keys(properties).length === 0) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"py-4 text-center text-sm text-muted-foreground",
|
|
className,
|
|
)}
|
|
>
|
|
No configuration options available.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("space-y-6", className)}>
|
|
{Object.entries(properties).map(([key, propSchema]) => {
|
|
const value = values[key];
|
|
const isRequired = requiredFields.has(key);
|
|
const error = errors[`/${key}`];
|
|
const label = labelFromKey(key, propSchema);
|
|
const path = `/${key}`;
|
|
|
|
return (
|
|
<FormField
|
|
key={key}
|
|
propSchema={propSchema}
|
|
value={value}
|
|
onChange={(val) => handleFieldChange(key, val)}
|
|
error={error}
|
|
disabled={disabled}
|
|
label={label}
|
|
isRequired={isRequired}
|
|
errors={errors}
|
|
path={path}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|