Files
deepseek-harness/packages/client/schema-form/src/SchemaForm.tsx
T
Yichen Jiang 9592c8f271 feat(schema-form): schema-driven React form renderer package
@deepseek-ai/dsh-client-schema-form rehydrates the wire's serialized
schemastery envelope (new Schema(json)) and edits a draft user section
against it: presence-in-draft marks a field overridden with a per-field
reset, inherited values render as placeholders, role('secret') slots are
write-only with configured-state placeholders from the wire's secrets
list, dict adds take a union-typed sKey as their vocabulary, and any
node the renderer cannot faithfully edit falls back to a read-only view
instead of silently disappearing. renderField(context) is the role hook
the Models page will use for the credential-ref control; validateDraft
runs the same rehydrated validator the host uses, so the browser and
host judge one schema.
2026-07-30 00:24:19 +08:00

439 lines
16 KiB
TypeScript

/**
* Schema-driven form renderer. One controlled component edits a draft user
* section against a rehydrated schemastery schema: every schema-declared
* field renders a control, a field's presence in the draft marks it
* overridden (with a per-field reset back to the inherited layer), secret
* slots render write-only, and nodes the renderer cannot faithfully edit
* fall back to a read-only view instead of disappearing.
* @module @deepseek-ai/dsh-client-schema-form/SchemaForm
*/
import { useMemo } from 'react'
import type { ReactNode } from 'react'
import {
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices,
} from './model.ts'
import type { SchemaNode } from './model.ts'
import styles from './SchemaForm.module.css'
/** One secret slot reported by the wire (`SettingsSecretView` shape). */
export interface SchemaFormSecret {
/** Path from the section root to the write-only field. */
path: readonly string[]
/** Whether a value is currently stored (it never rides the wire). */
set: boolean
}
/** User-visible strings; override to localize. */
export interface SchemaFormLabels {
reset: string
add: string
remove: string
secretSet: string
secretUnset: string
inherited: string
unsupported: string
}
const DEFAULT_LABELS: SchemaFormLabels = {
reset: 'Reset',
add: 'Add',
remove: 'Remove',
secretSet: 'Configured — enter a new value to replace',
secretUnset: 'Not configured',
inherited: 'Default',
unsupported: 'This field has no form control; edit the settings document directly.',
}
/** Everything a custom field renderer learns about one leaf position. */
export interface SchemaFieldContext {
/** Path from the section root. */
path: readonly string[]
/** Live schema node at this position. */
node: SchemaNode
/** `meta.role` of the node, when declared (`secret`, `credential-ref`, …). */
role: string | undefined
/** Current draft value at the path (`undefined` while inherited). */
draftValue: unknown
/** Inherited (resolved) value shown while the draft has no override. */
fallbackValue: unknown
/** Whether the draft carries this path. */
overridden: boolean
/** Whether the whole form is disabled. */
disabled: boolean
/** Store a draft value at this path. */
setValue: (value: unknown) => void
/** Remove this path from the draft (fall back to the inherited layer). */
clearValue: () => void
}
/** Props of {@link SchemaForm}. */
export interface SchemaFormProps {
/** Serialized schemastery envelope (`SettingsNamespaceView.schema`). */
schema: unknown
/** Draft user section being edited; never mutated. */
draft: Record<string, unknown>
/** Resolved value (defaults→base→user) used for inherited display. */
fallback?: unknown
/** Secret slots from the wire; matched by path for write-only placeholders. */
secrets?: readonly SchemaFormSecret[]
/** Disable every control (read-only provider or in-flight write). */
disabled?: boolean
/** Receives the complete next draft after each edit. */
onChange: (next: Record<string, unknown>) => void
/**
* Role-aware override hook: return a node to replace the default control
* for one leaf (the credential-ref control), or `undefined` to keep it.
*/
renderField?: (context: SchemaFieldContext) => ReactNode | undefined
/** String overrides for localization. */
labels?: Partial<SchemaFormLabels>
}
function pathKey(path: readonly string[]): string {
return path.join('')
}
interface RenderEnv {
draft: Record<string, unknown>
fallback: unknown
secrets: readonly SchemaFormSecret[]
disabled: boolean
labels: SchemaFormLabels
onChange: (next: Record<string, unknown>) => void
renderField?: (context: SchemaFieldContext) => ReactNode | undefined
}
function contextFor(env: RenderEnv, node: SchemaNode, path: readonly string[]): SchemaFieldContext {
return {
path,
node,
role: typeof node.meta.role === 'string' ? node.meta.role : undefined,
draftValue: getPath(env.draft, path),
fallbackValue: getPath(env.fallback, path),
overridden: hasPath(env.draft, path),
disabled: env.disabled,
setValue: (value) => { env.onChange(setDraft(env.draft, path, value)) },
clearValue: () => { env.onChange(deletePath(env.draft, [...path])) },
}
}
// setPath forbids empty paths; the root object node never calls setValue.
function setDraft(draft: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
return setPath(draft, [...path], value)
}
function secretStateAt(env: RenderEnv, path: readonly string[]): boolean | undefined {
const match = env.secrets.find(secret => pathKey(secret.path) === pathKey(path))
return match?.set
}
function SecretField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const stored = secretStateAt(env, context.path)
const placeholder = stored === true ? env.labels.secretSet : env.labels.secretUnset
return (
<input
className={styles['control']}
type="password"
autoComplete="off"
value={typeof context.draftValue === 'string' ? context.draftValue : ''}
placeholder={placeholder}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(next)
}}
/>
)
}
function StringField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const fallback = context.fallbackValue
return (
<input
className={styles['control']}
type="text"
value={typeof context.draftValue === 'string' ? context.draftValue : ''}
placeholder={typeof fallback === 'string' ? `${env.labels.inherited}: ${fallback}` : undefined}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(next)
}}
/>
)
}
function NumberField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const meta = context.node.meta as { min?: number; max?: number; step?: number }
const fallback = context.fallbackValue
return (
<input
className={styles['control']}
type="number"
value={typeof context.draftValue === 'number' ? context.draftValue : ''}
placeholder={typeof fallback === 'number' ? `${env.labels.inherited}: ${String(fallback)}` : undefined}
min={meta.min}
max={meta.max}
step={meta.step}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(Number(next))
}}
/>
)
}
function BooleanField({ context }: { context: SchemaFieldContext }): ReactNode {
const effective = context.overridden ? context.draftValue === true : context.fallbackValue === true
return (
<input
type="checkbox"
checked={effective}
disabled={context.disabled}
onChange={(event) => { context.setValue(event.target.checked) }}
/>
)
}
function UnionField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const choices = unionChoices(context.node)
const fallback = context.fallbackValue
const inheritedLabel = typeof fallback === 'string' || typeof fallback === 'number' || typeof fallback === 'boolean'
? `${env.labels.inherited}: ${String(fallback)}`
: `(${env.labels.inherited})`
return (
<select
className={styles['control']}
value={context.overridden ? String(context.draftValue) : ''}
disabled={context.disabled}
onChange={(event) => {
const next = event.target.value
if (next === '') context.clearValue()
else context.setValue(choices.find(choice => String(choice) === next))
}}
>
<option value="">{inheritedLabel}</option>
{choices.map(choice => (
<option key={String(choice)} value={String(choice)}>{String(choice)}</option>
))}
</select>
)
}
function emptyValueFor(node: SchemaNode): unknown {
switch (nodeKind(node)) {
case 'object':
case 'dict':
return {}
case 'array':
return []
case 'number':
return 0
case 'boolean':
return false
default:
return ''
}
}
function ArrayField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const inner = context.node.inner as SchemaNode | undefined
const items: readonly unknown[] = Array.isArray(context.draftValue)
? context.draftValue as unknown[]
: Array.isArray(context.fallbackValue) ? context.fallbackValue as unknown[] : []
const materialize = (): unknown[] => [...items]
return (
<div className={styles['stack']}>
{items.map((_item, index) => (
// Index keys are correct here: rows are positional slots of one draft array.
<div key={index} className={styles['row']}>
<FieldControl env={env} node={inner as SchemaNode} path={[...context.path, String(index)]} />
<button
type="button"
disabled={context.disabled}
onClick={() => {
const next = materialize()
next.splice(index, 1)
context.setValue(next)
}}
>
{env.labels.remove}
</button>
</div>
))}
<button
type="button"
disabled={context.disabled || inner === undefined}
onClick={() => {
context.setValue([...materialize(), emptyValueFor(inner as SchemaNode)])
}}
>
{env.labels.add}
</button>
</div>
)
}
function DictField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const inner = context.node.inner as SchemaNode | undefined
const draftKeys = typeof context.draftValue === 'object' && context.draftValue !== null
? Object.keys(context.draftValue)
: []
const fallbackKeys = typeof context.fallbackValue === 'object' && context.fallbackValue !== null
? Object.keys(context.fallbackValue)
: []
const keys = [...new Set([...fallbackKeys, ...draftKeys])]
const sKey = (context.node as { sKey?: SchemaNode }).sKey
const keyChoices = sKey !== undefined && nodeKind(sKey) === 'union-const'
? unionChoices(sKey).map(String).filter(choice => !keys.includes(choice))
: undefined
return (
<div className={styles['stack']}>
{keys.map(key => (
<div key={key} className={styles['row']}>
<span className={styles['dictKey']}>{key}</span>
<FieldControl env={env} node={inner as SchemaNode} path={[...context.path, key]} />
<button
type="button"
disabled={context.disabled || !hasPath(env.draft, [...context.path, key])}
onClick={() => { env.onChange(deletePath(env.draft, [...context.path, key])) }}
>
{env.labels.remove}
</button>
</div>
))}
<DictAdd context={context} env={env} keyChoices={keyChoices} />
</div>
)
}
function DictAdd({ context, env, keyChoices }: {
context: SchemaFieldContext
env: RenderEnv
keyChoices: string[] | undefined
}): ReactNode {
const inner = context.node.inner as SchemaNode | undefined
const add = (key: string): void => {
if (key === '') return
env.onChange(setDraft(env.draft, [...context.path, key], emptyValueFor(inner as SchemaNode)))
}
if (keyChoices !== undefined) {
return (
<select
className={styles['control']}
value=""
disabled={context.disabled}
aria-label={env.labels.add}
onChange={(event) => { add(event.target.value) }}
>
<option value="">{env.labels.add}</option>
{keyChoices.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
)
}
return (
<input
className={styles['control']}
type="text"
disabled={context.disabled}
aria-label={env.labels.add}
placeholder={`${env.labels.add}…`}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
add(event.currentTarget.value)
event.currentTarget.value = ''
}}
/>
)
}
function UnsupportedField({ context, env }: { context: SchemaFieldContext; env: RenderEnv }): ReactNode {
const shown = context.overridden ? context.draftValue : context.fallbackValue
return (
<div className={styles['unsupported']}>
<pre>{shown === undefined ? '' : JSON.stringify(shown)}</pre>
<span>{env.labels.unsupported}</span>
</div>
)
}
function FieldControl({ env, node, path }: { env: RenderEnv; node: SchemaNode; path: readonly string[] }): ReactNode {
const context = contextFor(env, node, path)
const custom = env.renderField?.(context)
if (custom !== undefined) return custom
if (context.role === 'secret') return <SecretField context={context} env={env} />
switch (nodeKind(node)) {
case 'object': return <ObjectFields env={env} node={node} path={path} />
case 'dict': return <DictField context={context} env={env} />
case 'array': return <ArrayField context={context} env={env} />
case 'string': return <StringField context={context} env={env} />
case 'number': return <NumberField context={context} env={env} />
case 'boolean': return <BooleanField context={context} />
case 'union-const': return <UnionField context={context} env={env} />
case 'unsupported': return <UnsupportedField context={context} env={env} />
}
}
function ObjectFields({ env, node, path }: { env: RenderEnv; node: SchemaNode; path: readonly string[] }): ReactNode {
const properties = Object.entries((node.dict ?? {}) as Record<string, SchemaNode>)
return (
<div className={styles['fields']}>
{properties.map(([key, child]) => {
const childPath = [...path, key]
const overridden = hasPath(env.draft, childPath)
const description = typeof child.meta.description === 'string' ? child.meta.description : undefined
const group = nodeKind(child) === 'object'
return (
<div key={key} className={`${styles['field']}${group ? ` ${styles['group']}` : ''}`}>
<div className={styles['labelRow']}>
<label className={styles['label']} title={description}>
{key}
{child.meta.required === true ? <span aria-hidden="true"> *</span> : null}
</label>
{overridden && !group
? (
<button
type="button"
className={styles['resetButton']}
disabled={env.disabled}
onClick={() => { env.onChange(deletePath(env.draft, childPath)) }}
>
{env.labels.reset}
</button>
)
: null}
</div>
{description !== undefined ? <p className={styles['description']}>{description}</p> : null}
<FieldControl env={env} node={child} path={childPath} />
</div>
)
})}
</div>
)
}
/**
* Render a settings section as an editable form.
* @param props - schema, draft, inherited layer, and edit sinks.
* @returns the form contents (no surrounding `<form>`; the page owns submit).
*/
export function SchemaForm(props: SchemaFormProps): ReactNode {
const { schema } = props
const root = useMemo(() => rehydrateSchema(schema), [schema])
const env: RenderEnv = {
draft: props.draft,
fallback: props.fallback,
secrets: props.secrets ?? [],
disabled: props.disabled === true,
labels: { ...DEFAULT_LABELS, ...props.labels },
onChange: props.onChange,
...props.renderField === undefined ? {} : { renderField: props.renderField },
}
return <FieldControl env={env} node={root} path={[]} />
}