mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Plugin authors expose configuration via JSON schemas, including
secret fields marked `format: "secret-ref"`
> - At the same time, Paperclip already has a first-class secrets store,
and `SecretBindingPicker` is the canonical UI for binding to one of
those stored secrets
> - But `JsonSchemaForm`'s `SecretField` rendered only a plain password
input, so configuring an E2B (or Modal / Cloudflare / Daytona) sandbox
required leaving the form, copying a secret UUID, and pasting it back
> - This pull request wires `SecretBindingPicker` into `SecretField` so
every plugin secret-ref field gets the picker plus an optional raw-value
fallback
> - The benefit is that secret reuse becomes one click instead of a tab
switch, and the raw-paste path still works for one-off keys or long
SSH-style secrets
## What Changed
- `ui/src/components/JsonSchemaForm.tsx` `SecretField` now renders
`SecretBindingPicker` above the existing password/textarea input.
UUID-shaped values are treated as bound refs (no raw input shown).
Non-UUID values keep the password/textarea visible (auto-opened) for SSH
keys and other long secrets. Empty fields show the picker plus a small
"Or paste a raw value" toggle.
- Selecting a secret writes the secret UUID to the form value — the
server-side resolution in `server/src/services/environment-config.ts`
(`resolveConfigSecretRefsForRuntime` / `collectEnvironmentSecretRefs`)
is unchanged. The version selector on the picker is suppressed
(`allowVersionSelector={false}`) because plugin secret refs always
resolve at `"latest"`.
- `ui/src/components/JsonSchemaForm.test.tsx` mocks the picker (which
requires `CompanyContext` + `QueryClient` providers) and adds coverage
for: picker render, UUID-bound state hides the raw input, picker
selection writes the UUID through `onChange`, raw text keeps the
password fallback. The original multiline (SSH key) case still asserts a
textarea + no password input.
## Verification
- `pnpm --filter @paperclipai/ui test
src/components/JsonSchemaForm.test.tsx` → 4/4 passing
- `pnpm --filter @paperclipai/ui test src/pages/PluginSettings.test.tsx`
→ 5/5 passing (existing consumer of `JsonSchemaForm`)
- `pnpm --filter @paperclipai/ui exec tsc --noEmit` → clean
- Manual: in the company Environments page, edit an environment with a
sandbox driver that exposes a `secret-ref` field (e.g., E2B `apiKey`).
The field should render the secret dropdown above the raw-value toggle;
selecting an active secret persists its UUID, and saving the form
continues to resolve the secret at runtime.
Before/after screenshots: deferred — change was validated by
[@devinfoley](https://github.com/devinfoley) on the main Paperclip
instance before this PR was opened. Happy to add screenshots if a
reviewer wants them.
## Risks
- Low risk. The change is additive in the SecretField: the raw-value
password/textarea path is preserved and auto-opens whenever the stored
value is not a UUID, so existing SSH-key entries and unsaved raw values
are untouched.
- The new heuristic is "if `value` is a UUID, treat it as a bound
secret". A user who somehow pasted a UUID as a literal value (not as a
secret ref) would now see it rendered as a bound (possibly missing)
secret in the picker. The previous UI already treated UUID values as
opaque secret refs at save time (server converts UUIDs straight
through), so the runtime behavior is unchanged.
- Picker pulls company secrets via the existing `secretsApi.list` query.
No new endpoints, no migrations.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities: tool use, extended reasoning
- Surfaced through: Claude Code via Paperclip heartbeat (issue PAPA-377)
## 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 — deferred; user validated locally before opening the PR.
Will add if requested.
- [x] I have updated relevant documentation to reflect my changes (no
docs needed — internal behavior of an existing form field)
- [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>
1180 lines
32 KiB
TypeScript
1180 lines
32 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;
|
|
|
|
// 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. 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],
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|