/** * 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 /** 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) => 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 } function pathKey(path: readonly string[]): string { return path.join('') } interface RenderEnv { draft: Record fallback: unknown secrets: readonly SchemaFormSecret[] disabled: boolean labels: SchemaFormLabels onChange: (next: Record) => 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, path: readonly string[], value: unknown): Record { 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 ( { 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 ( { 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 ( { 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 ( { 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 ( ) } 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 (
{items.map((_item, index) => ( // Index keys are correct here: rows are positional slots of one draft array.
))}
) } 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 (
{keys.map(key => (
{key}
))}
) } 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 ( ) } return ( { 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 (
{shown === undefined ? '' : JSON.stringify(shown)}
{env.labels.unsupported}
) } 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 switch (nodeKind(node)) { case 'object': return case 'dict': return case 'array': return case 'string': return case 'number': return case 'boolean': return case 'union-const': return case 'unsupported': return } } function ObjectFields({ env, node, path }: { env: RenderEnv; node: SchemaNode; path: readonly string[] }): ReactNode { const properties = Object.entries((node.dict ?? {}) as Record) return (
{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 (
{overridden && !group ? ( ) : null}
{description !== undefined ?

{description}

: null}
) })}
) } /** * Render a settings section as an editable form. * @param props - schema, draft, inherited layer, and edit sinks. * @returns the form contents (no surrounding `
`; 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 }