Files
paperclip/ui/src/components/JsonSchemaForm.tsx
T
Devin Foley aea35fe695 exe.dev config UX: advanced-options disclosure, form-default fix, SSH key handling (PAPA-407) (#7025)
## Thinking Path

> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.

## What Changed

- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.

## Verification

CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.

Run locally:

```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.

# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```

Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):

1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).

## Risks

- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination

## Checklist

- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 18:19:37 -07:00

1294 lines
35 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronDown,
ChevronRight,
Eye,
EyeOff,
Plus,
Trash2,
} from "lucide-react";
import { isUuidLike } from "@paperclipai/shared";
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";
import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker";
// ---------------------------------------------------------------------------
// 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;
// Paperclip extensions
/**
* When true, the field is hidden behind an "Advanced options" disclosure
* in the top-level `JsonSchemaForm`. Defaults to false (essential).
*/
"x-paperclip-advanced"?: boolean;
/**
* Optional sub-section name used to group advanced fields under headings
* inside the disclosure (e.g. "SSH access", "VM resources"). Ignored when
* `x-paperclip-advanced` is not true.
*/
"x-paperclip-group"?: string;
// 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.
*
* Optional scalar fields (string, number, integer, secret-ref) without an
* explicit `default` return `undefined` so they stay out of the submitted
* payload — otherwise an empty field would round-trip as `""` or `0` and
* trip server-side "X must be greater than 0 when provided" style validators.
*/
export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
if (schema.default !== undefined) return schema.default;
const type = resolveType(schema);
switch (type) {
case "string":
case "secret-ref":
case "number":
case "integer":
return undefined;
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)) {
const def = getDefaultForSchema(propSchema);
if (def !== undefined) obj[key] = def;
}
return obj;
}
default:
return undefined;
}
}
/** 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. Renders a picker for existing
* company secrets plus a raw-value fallback. A UUID-shaped value is treated
* as a bound secret reference; anything else is a raw value that the server
* converts to a stored secret on save.
*/
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;
const stringValue = typeof value === "string" ? value : "";
const trimmed = stringValue.trim();
const isBoundToSecret = trimmed.length > 0 && isUuidLike(trimmed);
const hasRawValue = stringValue.length > 0 && !isBoundToSecret;
const [showRawInput, setShowRawInput] = useState(hasRawValue);
// Keep the raw-input panel open when the parent loads a raw value after
// mount (e.g. an environment-config form rendering with empty defaults
// before its API response arrives). We only promote to `true` here; manual
// toggles off are still preserved as long as `hasRawValue` is false.
useEffect(() => {
if (hasRawValue) setShowRawInput(true);
}, [hasRawValue]);
const bindingValue: SecretBindingValue | null = isBoundToSecret
? { secretId: trimmed }
: null;
const handlePickerChange = useCallback(
(next: SecretBindingValue | null) => {
if (next) {
onChange(next.secretId);
setShowRawInput(false);
setIsVisible(false);
} else {
onChange("");
}
},
[onChange],
);
const rawInput = isTextArea ? (
<div className="relative">
{isVisible ? (
<Textarea
value={stringValue}
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={
stringValue.length === 0
? ""
: `Sensitive — ${stringValue.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={stringValue}
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>
);
return (
<FieldWrapper
label={label}
description={
description ||
"Pick an existing company secret, or paste a raw value (Paperclip will store it as a secret on save)."
}
required={isRequired}
error={error}
disabled={disabled}
>
<div className="space-y-2">
<SecretBindingPicker
value={bindingValue}
onChange={handlePickerChange}
label=""
placeholder="Select an existing secret"
allowVersionSelector={false}
emptyHint="No active secrets yet. Create one or paste a raw value below."
disabled={disabled}
/>
{!isBoundToSecret ? (
showRawInput ? (
<div className="space-y-1">
{rawInput}
{!hasRawValue ? (
<button
type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => {
setShowRawInput(false);
setIsVisible(false);
}}
disabled={disabled}
>
Hide raw value input
</button>
) : null}
</div>
) : (
<button
type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setShowRawInput(true)}
disabled={disabled}
>
Or paste a raw value
</button>
)
) : null}
</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],
);
const { essentials, advancedGroups, advancedKeys } = useMemo(() => {
const essentials: Array<[string, JsonSchemaNode]> = [];
// Preserve original key order while bucketing into groups.
const groupOrder: string[] = [];
const groups = new Map<string, Array<[string, JsonSchemaNode]>>();
const advancedKeys = new Set<string>();
const DEFAULT_GROUP = "More options";
for (const entry of Object.entries(properties)) {
const [key, propSchema] = entry;
if (propSchema["x-paperclip-advanced"] === true) {
advancedKeys.add(key);
const rawGroup = propSchema["x-paperclip-group"];
const group = typeof rawGroup === "string" && rawGroup.length > 0
? rawGroup
: DEFAULT_GROUP;
if (!groups.has(group)) {
groups.set(group, []);
groupOrder.push(group);
}
groups.get(group)!.push(entry);
} else {
essentials.push(entry);
}
}
return {
essentials,
advancedGroups: groupOrder.map((group) => ({
group,
fields: groups.get(group)!,
})),
advancedKeys,
};
}, [properties]);
const hasAdvanced = advancedGroups.length > 0;
const hasAdvancedError = useMemo(() => {
if (!hasAdvanced) return false;
for (const errorKey of Object.keys(errors)) {
// Top-level errors arrive as "/<key>" or "/<key>/<...>".
const stripped = errorKey.startsWith("/") ? errorKey.slice(1) : errorKey;
const topKey = stripped.split("/")[0];
if (advancedKeys.has(topKey)) return true;
}
return false;
}, [errors, advancedKeys, hasAdvanced]);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
// Force the disclosure open when a validation error lands on a hidden field
// so the user can see and fix it. Never auto-close — once open, the user
// controls collapse.
useEffect(() => {
if (hasAdvancedError) setIsAdvancedOpen(true);
}, [hasAdvancedError]);
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>
);
}
const renderField = ([key, propSchema]: [string, JsonSchemaNode]) => {
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}
/>
);
};
return (
<div className={cn("space-y-6", className)}>
{essentials.map(renderField)}
{hasAdvanced && (
<div className="space-y-3 rounded-lg border border-dashed">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left"
onClick={() => setIsAdvancedOpen((open) => !open)}
aria-expanded={isAdvancedOpen}
>
<span className="text-sm font-medium">Advanced options</span>
{isAdvancedOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
{isAdvancedOpen && (
<div className="space-y-6 px-4 pb-4">
{advancedGroups.map(({ group, fields }) => (
<div key={group} className="space-y-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group}
</div>
<div className="space-y-6">
{fields.map(renderField)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}