forked from farhoodlabs/paperclip
5a64cf52a1
> _Stacked on top of #5685 → #5686 → #5687. Diff against master includes commits from earlier PRs in the stack — review focuses on the two new commits (`Add long-secret textarea variant to JsonSchemaForm SecretField` + `Add exe.dev sandbox provider plugin`)._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs in a sandbox environment, and operators choose the provider — today E2B, Daytona, and (in this stack) Cloudflare > - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful for operators who want full Linux VMs (vs container/runtime-only sandboxes) > - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`, `ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access for adapters that need it > - exe.dev VMs come up bare — `node` is not preinstalled, so the Paperclip sandbox callback bridge (a Node script) needs Node 20 installed at VM init via `--setup-script`. The plugin defaults the setup script to a Nodesource install > - The auth field accepts long SSH private keys, which need a textarea variant of the existing `SecretField` in `JsonSchemaForm` — added behind a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected > - The benefit is that operators get exe.dev as a fully working sandbox provider out of the box, with no manual VM provisioning required ## What Changed **Shared UI support (`Add long-secret textarea variant to JsonSchemaForm SecretField`):** - `ui/src/components/JsonSchemaForm.tsx` + new `JsonSchemaForm.test.tsx`: when a secret-formatted field declares `maxLength` larger than the existing single-line threshold, render a monospace textarea instead of the masked input. Short secrets (API keys, tokens) keep the existing masked-input + show/hide toggle behavior. **The exe.dev plugin (`Add exe.dev sandbox provider plugin`):** - `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest, plugin runtime, README, and 19-test Vitest suite. - Manifest fields: API token (with `secret-ref` + `/exec` permission notes — needs `new`, `ls`, `rm`), API URL override, optional SSH username, optional SSH private key (uses the new `JsonSchemaForm` textarea variant via `maxLength: 4096`), optional SSH identity-file path, optional setup script. - Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs come up bare and the Paperclip sandbox callback bridge is a Node script, so without Node preinstalled the bridge can't start. Operators can override by supplying their own setup script. - `runLifecycleCommand` redacts env values from the executed command before surfacing it in error messages, so secrets passed via `--env=KEY=VALUE` don't leak into operator-visible failures. - The plugin distinguishes exe.dev's SSH onboarding failures (`Please complete registration by running: ssh exe.dev`) from general SSH failures and surfaces a clear remediation message. - `scripts/release-package-manifest.json`: register the new plugin for CI publish alongside the existing daytona / e2b providers. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage ui/src/components/JsonSchemaForm.test.tsx` - `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19 passing For an operator-side smoke test: 1. Get an exe.dev API token with `/exec` permission for `new`, `ls`, `rm`. 2. Register the plugin in your Paperclip instance, configure an environment with the token. 3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or Claude job against it. The default Node 20 setup script should bring the VM up automatically. ## Risks - Adds a new sandbox provider plugin that follows the existing daytona / e2b shape; behavior on existing providers is unchanged. - The `JsonSchemaForm` textarea variant only engages for fields that opt in via `maxLength` larger than the existing threshold. All existing secret fields (which don't declare a `maxLength`) keep their current rendering. Test coverage pins both paths. - The redaction in `runLifecycleCommand` is a defense-in-depth measure; the test suite exercises the redaction path. If the redaction misses a future env-arg shape, the worst case is restored behavior (secrets in error messages), which is what the existing daytona / e2b plugins also do today. - Default setup script downloads from `deb.nodesource.com` over HTTPS at VM init. Operators on air-gapped networks or with a different package strategy can override the setup script. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — UI change is a textarea variant of an existing secret field; will attach screenshots before requesting merge - [x] I have updated relevant documentation to reflect my changes (plugin README, manifest descriptions) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1102 lines
29 KiB
TypeScript
1102 lines
29 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,
|
|
maxLength,
|
|
}: {
|
|
value: unknown;
|
|
onChange: (val: unknown) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
isRequired?: boolean;
|
|
description?: string;
|
|
error?: string;
|
|
defaultValue?: unknown;
|
|
maxLength?: number;
|
|
}) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD;
|
|
return (
|
|
<FieldWrapper
|
|
label={label}
|
|
description={
|
|
description ||
|
|
"This secret is stored securely via the Paperclip secret provider."
|
|
}
|
|
required={isRequired}
|
|
error={error}
|
|
disabled={disabled}
|
|
>
|
|
{isTextArea ? (
|
|
<div className="relative">
|
|
{isVisible ? (
|
|
<Textarea
|
|
value={String(value ?? "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={String(defaultValue ?? "")}
|
|
disabled={disabled}
|
|
className="min-h-[140px] pr-10 font-mono text-xs"
|
|
aria-invalid={!!error}
|
|
/>
|
|
) : (
|
|
<Textarea
|
|
// Render a placeholder summary instead of the secret content while
|
|
// hidden. This avoids exposing multi-line secrets (e.g. SSH
|
|
// private keys) on screen-shares; clicking the eye toggle reveals
|
|
// the editable textarea above.
|
|
value={
|
|
String(value ?? "").length === 0
|
|
? ""
|
|
: `Sensitive — ${String(value ?? "").length} characters hidden. Click the eye to reveal.`
|
|
}
|
|
readOnly
|
|
placeholder={String(defaultValue ?? "")}
|
|
disabled={disabled}
|
|
className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground"
|
|
aria-invalid={!!error}
|
|
/>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-0 top-0 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>
|
|
) : (
|
|
<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}
|
|
maxLength={typeof propSchema.maxLength === "number" ? propSchema.maxLength : undefined}
|
|
/>
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|