mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
This commit is contained in:
30
packages/client/schema-form/README.md
Normal file
30
packages/client/schema-form/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# @deepseek-ai/dsh-client-schema-form
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Schema-driven React form renderer for settings sections. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `SchemaForm` rehydrates it with `new Schema(json)` and renders every declared field as an editable control — the same schema object that validates a section on the host validates and drives the form in the browser, so there is no second form definition to drift.
|
||||
|
||||
## Contract
|
||||
|
||||
`SchemaForm` is a controlled component over a **draft user section**: `draft` is the object being edited (never mutated; every edit calls `onChange` with a new root), and `fallback` is the resolved value (schema defaults → composition base → user layer) used for inherited display. A field's presence in the draft marks it **overridden** and shows a per-field Reset that deletes the key, falling back to the inherited layer — presence semantics, not value comparison, exactly mirroring the settings seam's layering.
|
||||
|
||||
Controls by schema node: `object` → labeled field groups (JSDoc `description` rendered, `required` starred), `string`/`number`/`boolean` → inputs with the inherited value as placeholder, `union` of literals → select whose empty option means "inherit", `array` → positional rows with add/remove (arrays replace wholesale on write), `dict` → keyed rows where a union-typed `sKey` becomes the add-select's vocabulary. `role('secret')` renders a **write-only** password input: the stored value never arrives (the wire strips it), and the `secrets` slot list (`{path, set}`) supplies the placeholder state. A node the renderer cannot faithfully edit (non-literal unions, transforms) renders a read-only JSON view with a notice instead of disappearing — a schema field is never silently dropped.
|
||||
|
||||
`renderField(context)` is the role-aware override hook: return a node to replace the default control for one leaf. The Models settings page uses it to mount the credential-reference control (`role('credential-ref')`) that talks to `credentials.*` — this package stays wire-free and side-effect-free.
|
||||
|
||||
`validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages validate before writing; the path helpers (`getPath`/`hasPath`/`setPath`/`deletePath`) expose the same immutable draft editing the controls use.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package renders browser configuration forms; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Validation is form-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); inline per-field error placement is deferred until a second consumer needs it.
|
||||
- **Strings are built-in English** — the `labels` prop overrides every user-visible string, but there is no locale-dictionary wiring inside this package; the embedding page owns localization.
|
||||
- **Non-literal unions and transforms render read-only** — faithful editing of those shapes needs per-shape controls; today they fall back to the JSON view with a notice.
|
||||
- **Array editing replaces wholesale** — element-level merge does not exist at the settings seam either; the form mirrors that contract rather than hiding it.
|
||||
43
packages/client/schema-form/package.json
Normal file
43
packages/client/schema-form/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-schema-form",
|
||||
"description": "Schema-driven React form renderer: rehydrates a serialized schemastery schema and renders/edits a settings draft against it",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
99
packages/client/schema-form/src/SchemaForm.module.css
Normal file
99
packages/client/schema-form/src/SchemaForm.module.css
Normal file
@@ -0,0 +1,99 @@
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field.group {
|
||||
border: 1px solid var(--border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.control:focus {
|
||||
outline: 2px solid var(--accent, #3964fe);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #3964fe);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dictKey {
|
||||
min-width: 96px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unsupported {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.unsupported pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken, #f5f5f5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
BIN
packages/client/schema-form/src/SchemaForm.tsx
Normal file
BIN
packages/client/schema-form/src/SchemaForm.tsx
Normal file
Binary file not shown.
6
packages/client/schema-form/src/css-modules.d.ts
vendored
Normal file
6
packages/client/schema-form/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
16
packages/client/schema-form/src/index.ts
Normal file
16
packages/client/schema-form/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Schema-driven React form renderer for settings sections. `SchemaForm`
|
||||
* rehydrates the wire's serialized schemastery envelope and edits a draft
|
||||
* user section against it; the model helpers expose the same introspection
|
||||
* and immutable path editing for page-level composition.
|
||||
* @module @deepseek-ai/dsh-client-schema-form
|
||||
*/
|
||||
|
||||
export { SchemaForm } from './SchemaForm.tsx'
|
||||
export type {
|
||||
SchemaFieldContext, SchemaFormLabels, SchemaFormProps, SchemaFormSecret,
|
||||
} from './SchemaForm.tsx'
|
||||
export {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from './model.ts'
|
||||
export type { NodeKind, SchemaNode } from './model.ts'
|
||||
32
packages/client/schema-form/src/invariant.ts
Normal file
32
packages/client/schema-form/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-schema-form`.
|
||||
* @module @deepseek-ai/dsh-client-schema-form/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-schema-form-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure React rendering library — it emits no cordis
|
||||
* events and owns no cross-plugin mutable relation; draft immutability,
|
||||
* schema rehydration, and control/edit round trips are asserted directly by
|
||||
* this package's component and model specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
171
packages/client/schema-form/src/model.ts
Normal file
171
packages/client/schema-form/src/model.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Schema introspection and draft-editing helpers behind the form renderer.
|
||||
* The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a
|
||||
* live validator whose node relations (`dict`/`inner`/`list`) the renderer
|
||||
* walks; drafts are edited immutably by path.
|
||||
* @module @deepseek-ai/dsh-client-schema-form/model
|
||||
*/
|
||||
|
||||
import Schema from 'schemastery'
|
||||
|
||||
/** Live schemastery node; the renderer reads only its structural relations. */
|
||||
export type SchemaNode = Schema
|
||||
|
||||
/**
|
||||
* Rehydrate a serialized schema envelope into a live validator/node tree.
|
||||
* @param serialized - `schema.toJSON()` output received over the wire.
|
||||
* @returns the root schema node.
|
||||
*/
|
||||
export function rehydrateSchema(serialized: unknown): SchemaNode {
|
||||
return new Schema(serialized as Schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a draft against a rehydrated schema.
|
||||
* @param schema - rehydrated root node.
|
||||
* @param draft - candidate value.
|
||||
* @returns the validation failure message, or `undefined` when the draft passes.
|
||||
*/
|
||||
export function validateDraft(schema: SchemaNode, draft: unknown): string | undefined {
|
||||
try {
|
||||
;(schema as unknown as (value: unknown) => unknown)(draft)
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** The renderable classification of one schema node. */
|
||||
export type NodeKind =
|
||||
| 'object'
|
||||
| 'dict'
|
||||
| 'array'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'union-const'
|
||||
| 'unsupported'
|
||||
|
||||
/**
|
||||
* Classify one node into the renderer's vocabulary. A union renders as a
|
||||
* select only when every branch is a literal; everything else the renderer
|
||||
* cannot faithfully edit is `unsupported` and falls back to a read-only view
|
||||
* (never silently dropped).
|
||||
* @param node - live schema node.
|
||||
* @returns the control family for this node.
|
||||
*/
|
||||
export function nodeKind(node: SchemaNode): NodeKind {
|
||||
switch (node.type) {
|
||||
case 'object': return 'object'
|
||||
case 'dict': return 'dict'
|
||||
case 'array': return 'array'
|
||||
case 'string': return 'string'
|
||||
case 'number': return 'number'
|
||||
case 'boolean': return 'boolean'
|
||||
case 'union':
|
||||
return (node.list ?? []).every(branch => branch.type === 'const') ? 'union-const' : 'unsupported'
|
||||
default:
|
||||
return 'unsupported'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Literal choices of a `union-const` node, in declaration order.
|
||||
* @param node - a node classified `union-const`.
|
||||
* @returns each branch's literal value.
|
||||
*/
|
||||
export function unionChoices(node: SchemaNode): unknown[] {
|
||||
return (node.list ?? []).map(branch => (branch as { value?: unknown }).value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value by path.
|
||||
* @param value - root value (draft or fallback layer).
|
||||
* @param path - key path from the root; array indexes as strings.
|
||||
* @returns the value at the path, or `undefined` along a missing branch.
|
||||
*/
|
||||
export function getPath(value: unknown, path: readonly string[]): unknown {
|
||||
let current: unknown = value
|
||||
for (const key of path) {
|
||||
if (Array.isArray(current)) {
|
||||
current = current[Number(key)]
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object' || current === null) return undefined
|
||||
current = (current as Record<string, unknown>)[key]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** Whether a draft explicitly carries the path (its presence marks a user override). */
|
||||
export function hasPath(value: unknown, path: readonly string[]): boolean {
|
||||
if (path.length === 0) return value !== undefined
|
||||
const parent = getPath(value, path.slice(0, -1))
|
||||
const key = path[path.length - 1] as string
|
||||
if (Array.isArray(parent)) return Number(key) < parent.length
|
||||
if (typeof parent !== 'object' || parent === null) return false
|
||||
return key in parent
|
||||
}
|
||||
|
||||
function cloneContainer(container: unknown, key: string): Record<string, unknown> | unknown[] {
|
||||
if (Array.isArray(container)) return [...container as unknown[]]
|
||||
if (typeof container === 'object' && container !== null) return { ...container as Record<string, unknown> }
|
||||
// A missing intermediate materializes as the container the next key needs.
|
||||
return /^\d+$/.test(key) ? [] : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably set a nested value, materializing missing intermediate containers.
|
||||
* @param root - draft root (never mutated).
|
||||
* @param path - non-empty key path.
|
||||
* @param value - value to store at the path.
|
||||
* @returns the new draft root.
|
||||
*/
|
||||
export function setPath(root: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
|
||||
if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path')
|
||||
const result = { ...root }
|
||||
let target: Record<string, unknown> | unknown[] = result
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i] as string
|
||||
const child = cloneContainer(
|
||||
Array.isArray(target) ? target[Number(key)] : (target)[key],
|
||||
path[i + 1] as string,
|
||||
)
|
||||
if (Array.isArray(target)) target[Number(key)] = child
|
||||
else (target)[key] = child
|
||||
target = child
|
||||
}
|
||||
const leaf = path[path.length - 1] as string
|
||||
if (Array.isArray(target)) target[Number(leaf)] = value
|
||||
else (target)[leaf] = value
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably remove a nested key (the per-field reset: the resolved value
|
||||
* falls back to the composition base and schema defaults). Removing along a
|
||||
* missing branch returns the root unchanged.
|
||||
* @param root - draft root (never mutated).
|
||||
* @param path - non-empty key path.
|
||||
* @returns the new draft root.
|
||||
*/
|
||||
export function deletePath(root: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
|
||||
if (path.length === 0) throw new Error('schema-form: deletePath needs a non-empty path')
|
||||
if (!hasPath(root, path)) return root
|
||||
const result = { ...root }
|
||||
let target: Record<string, unknown> | unknown[] = result
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i] as string
|
||||
const child = cloneContainer(
|
||||
Array.isArray(target) ? target[Number(key)] : (target)[key],
|
||||
path[i + 1] as string,
|
||||
)
|
||||
if (Array.isArray(target)) target[Number(key)] = child
|
||||
else (target)[key] = child
|
||||
target = child
|
||||
}
|
||||
const leaf = path[path.length - 1] as string
|
||||
if (Array.isArray(target)) target.splice(Number(leaf), 1)
|
||||
else Reflect.deleteProperty(target, leaf)
|
||||
return result
|
||||
}
|
||||
12
packages/client/schema-form/tests/invariant.spec.ts
Normal file
12
packages/client/schema-form/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(SchemaFormInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
103
packages/client/schema-form/tests/model.spec.ts
Normal file
103
packages/client/schema-form/tests/model.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from '../src/model.ts'
|
||||
|
||||
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
|
||||
describe('rehydration and validation', () => {
|
||||
it('rehydrates a serialized envelope into a working validator', () => {
|
||||
const root = rehydrateSchema(Wire(Schema.object({ name: Schema.string().required() })))
|
||||
expect(validateDraft(root, { name: 'ok' })).toBeUndefined()
|
||||
expect(validateDraft(root, { name: 42 })).toContain('name')
|
||||
})
|
||||
|
||||
it('stringifies non-Error validation throws', () => {
|
||||
const hostile = (() => {
|
||||
throw 'plain-string failure'
|
||||
}) as unknown as Parameters<typeof validateDraft>[0]
|
||||
expect(validateDraft(hostile, {})).toBe('plain-string failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeKind', () => {
|
||||
it.each([
|
||||
[Schema.object({}), 'object'],
|
||||
[Schema.dict(Schema.string()), 'dict'],
|
||||
[Schema.array(Schema.string()), 'array'],
|
||||
[Schema.string(), 'string'],
|
||||
[Schema.number(), 'number'],
|
||||
[Schema.natural(), 'number'],
|
||||
[Schema.boolean(), 'boolean'],
|
||||
[Schema.union(['a', 'b']), 'union-const'],
|
||||
[Schema.union([Schema.string(), Schema.number()]), 'unsupported'],
|
||||
[Schema.transform(Schema.string(), value => value), 'unsupported'],
|
||||
])('classifies %#', (schema, expected) => {
|
||||
expect(nodeKind(rehydrateSchema(Wire(schema as Schema)))).toBe(expected)
|
||||
})
|
||||
|
||||
it('lists union choices in declaration order', () => {
|
||||
const node = rehydrateSchema(Wire(Schema.union(['off', 'high', 'max'])))
|
||||
expect(unionChoices(node)).toEqual(['off', 'high', 'max'])
|
||||
})
|
||||
|
||||
it('tolerates structural union nodes missing their branch list', () => {
|
||||
expect(nodeKind({ type: 'union', meta: {} } as never)).toBe('union-const')
|
||||
expect(unionChoices({ type: 'union', meta: {} } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('path helpers', () => {
|
||||
const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] }
|
||||
|
||||
it('reads nested object and array paths', () => {
|
||||
expect(getPath(root, [])).toBe(root)
|
||||
expect(getPath(root, ['providers', 'openai', 'baseURL'])).toBe('https://x')
|
||||
expect(getPath(root, ['models', '0', 'id'])).toBe('a')
|
||||
expect(getPath(root, ['providers', 'missing', 'x'])).toBeUndefined()
|
||||
expect(getPath(root, ['providers', 'openai', 'baseURL', 'deep'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports draft presence by key existence, not value truthiness', () => {
|
||||
expect(hasPath({ flag: false }, ['flag'])).toBe(true)
|
||||
expect(hasPath({ nested: { key: undefined } }, ['nested', 'key'])).toBe(true)
|
||||
expect(hasPath({}, ['missing'])).toBe(false)
|
||||
expect(hasPath({ leaf: 'x' }, ['leaf', 'deeper'])).toBe(false)
|
||||
expect(hasPath({ models: ['a'] }, ['models', '0'])).toBe(true)
|
||||
expect(hasPath({ models: ['a'] }, ['models', '1'])).toBe(false)
|
||||
expect(hasPath({ root: true }, [])).toBe(true)
|
||||
expect(hasPath(undefined, [])).toBe(false)
|
||||
})
|
||||
|
||||
it('sets nested paths immutably, materializing containers by key shape', () => {
|
||||
const draft = {}
|
||||
const next = setPath(draft, ['providers', 'openai', 'baseURL'], 'https://y')
|
||||
expect(draft).toEqual({})
|
||||
expect(next).toEqual({ providers: { openai: { baseURL: 'https://y' } } })
|
||||
const withArray = setPath(next, ['models', '0'], { id: 'a' })
|
||||
expect(withArray).toEqual({ providers: { openai: { baseURL: 'https://y' } }, models: [{ id: 'a' }] })
|
||||
const replaced = setPath(withArray, ['models', '0', 'id'], 'b')
|
||||
expect(replaced.models).toEqual([{ id: 'b' }])
|
||||
expect((withArray as { models: unknown[] }).models).toEqual([{ id: 'a' }])
|
||||
expect(() => setPath({}, [], 'x')).toThrow(/non-empty path/)
|
||||
})
|
||||
|
||||
it('deletes nested paths immutably and splices array indexes', () => {
|
||||
const draft = { providers: { openai: { baseURL: 'https://x', apiKey: 'k' } }, models: ['a', 'b'] }
|
||||
const withoutKey = deletePath(draft, ['providers', 'openai', 'apiKey'])
|
||||
expect(withoutKey).toEqual({ providers: { openai: { baseURL: 'https://x' } }, models: ['a', 'b'] })
|
||||
expect(draft.providers.openai.apiKey).toBe('k')
|
||||
const withoutModel = deletePath(withoutKey, ['models', '0'])
|
||||
expect(withoutModel.models).toEqual(['b'])
|
||||
expect(deletePath(draft, ['providers', 'missing', 'x'])).toBe(draft)
|
||||
expect(() => deletePath({}, [])).toThrow(/non-empty path/)
|
||||
})
|
||||
|
||||
it('deletes keys through array intermediates immutably', () => {
|
||||
const draft = { models: [{ id: 'a', contextWindow: 1 }] }
|
||||
const next = deletePath(draft, ['models', '0', 'contextWindow'])
|
||||
expect(next).toEqual({ models: [{ id: 'a' }] })
|
||||
expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 })
|
||||
})
|
||||
})
|
||||
346
packages/client/schema-form/tests/schema-form.spec.tsx
Normal file
346
packages/client/schema-form/tests/schema-form.spec.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { SchemaForm } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
|
||||
const Profile = Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string().description('Endpoint override'),
|
||||
reasoning: Schema.union(['off', 'high', 'max']),
|
||||
timeoutMs: Schema.number().min(0).max(1000).step(1),
|
||||
verbose: Schema.boolean(),
|
||||
name: Schema.string().required(),
|
||||
})
|
||||
|
||||
function lastDraft(onChange: ReturnType<typeof vi.fn>): Record<string, unknown> {
|
||||
return onChange.mock.calls.at(-1)?.[0] as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('leaf controls', () => {
|
||||
it('renders strings with inherited placeholders, writes on input, clears on empty', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ baseURL: 'https://mine' }}
|
||||
fallback={{ baseURL: 'https://base', reasoning: 'high' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = screen.getByDisplayValue('https://mine')
|
||||
fireEvent.change(input, { target: { value: 'https://next' } })
|
||||
expect(lastDraft(onChange)).toEqual({ baseURL: 'https://next' })
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
const inherited = screen.getByPlaceholderText('Default: https://base')
|
||||
expect(inherited).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders numbers with bounds and parses edits', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
fallback={{ timeoutMs: 500 }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="number"]') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Default: 500')
|
||||
expect(input.min).toBe('0')
|
||||
expect(input.max).toBe('1000')
|
||||
fireEvent.change(input, { target: { value: '250' } })
|
||||
expect(lastDraft(onChange)).toEqual({ timeoutMs: 250 })
|
||||
})
|
||||
|
||||
it('clears a number override back to inherited on empty input', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ timeoutMs: 250 }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="number"]') as HTMLInputElement
|
||||
expect(input.value).toBe('250')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
|
||||
it('prefers an overridden boolean over the fallback', () => {
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ verbose: false }}
|
||||
fallback={{ verbose: true }}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement
|
||||
expect(box.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects booleans from the fallback until overridden', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
fallback={{ verbose: true }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement
|
||||
expect(box.checked).toBe(true)
|
||||
fireEvent.click(box)
|
||||
expect(lastDraft(onChange)).toEqual({ verbose: false })
|
||||
})
|
||||
|
||||
it('renders literal unions as selects with an inherit option', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
fallback={{ reasoning: 'high' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
expect([...select.options].map(option => option.text)).toEqual(['Default: high', 'off', 'high', 'max'])
|
||||
fireEvent.change(select, { target: { value: 'max' } })
|
||||
expect(lastDraft(onChange)).toEqual({ reasoning: 'max' })
|
||||
})
|
||||
|
||||
it('clears a union override back to inherit', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ reasoning: 'max' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
expect(select.value).toBe('max')
|
||||
fireEvent.change(select, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
|
||||
it('marks required fields and surfaces descriptions', () => {
|
||||
render(<SchemaForm schema={Wire(Profile)} draft={{}} onChange={vi.fn()} />)
|
||||
expect(screen.getByText('Endpoint override')).toBeTruthy()
|
||||
expect(screen.getByText('name').textContent).toContain('name')
|
||||
expect(screen.getByText('*')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the per-field reset only for overridden fields and deletes on click', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ baseURL: 'https://mine' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const resets = screen.getAllByText('Reset')
|
||||
expect(resets).toHaveLength(1)
|
||||
fireEvent.click(resets[0] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('secrets and custom renderers', () => {
|
||||
it('renders secrets write-only with the stored-state placeholder', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
secrets={[{ path: ['apiKey'], set: true }]}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="password"]') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Configured — enter a new value to replace')
|
||||
expect(input.value).toBe('')
|
||||
fireEvent.change(input, { target: { value: 'sk-new' } })
|
||||
expect(lastDraft(onChange)).toEqual({ apiKey: 'sk-new' })
|
||||
})
|
||||
|
||||
it('clears a typed-but-unsaved secret back to unset', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ apiKey: 'sk-draft' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="password"]') as HTMLInputElement
|
||||
expect(input.value).toBe('sk-draft')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
|
||||
it('reports an unset secret slot', () => {
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
secrets={[{ path: ['apiKey'], set: false }]}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="password"]') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Not configured')
|
||||
})
|
||||
|
||||
it('lets renderField replace a role-tagged control', () => {
|
||||
render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ apiKeyEnv: 'OPENAI_API_KEY' }}
|
||||
onChange={vi.fn()}
|
||||
renderField={(context) => {
|
||||
if (context.role !== 'credential-ref') return undefined
|
||||
return <div data-testid="credential-control">{String(context.draftValue)}</div>
|
||||
}}
|
||||
/>)
|
||||
expect(screen.getByTestId('credential-control').textContent).toBe('OPENAI_API_KEY')
|
||||
})
|
||||
|
||||
it('disables every control under disabled', () => {
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
disabled
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
for (const input of container.querySelectorAll('input, select, button')) {
|
||||
expect((input as HTMLInputElement).disabled).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('containers', () => {
|
||||
const Catalog = Schema.object({
|
||||
models: Schema.array(Schema.object({ id: Schema.string().required() })),
|
||||
retryPolicy: Schema.object({ maxRetries: Schema.number() }),
|
||||
})
|
||||
|
||||
it('renders nested object groups', () => {
|
||||
render(<SchemaForm schema={Wire(Catalog)} draft={{}} onChange={vi.fn()} />)
|
||||
expect(screen.getByText('retryPolicy')).toBeTruthy()
|
||||
expect(screen.getByText('maxRetries')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('materializes fallback rows into the draft on add and edit', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Catalog)}
|
||||
draft={{}}
|
||||
fallback={{ models: [{ id: 'flash' }] }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(lastDraft(onChange)).toEqual({ models: [{ id: 'flash' }, {}] })
|
||||
fireEvent.change(screen.getByPlaceholderText('Default: flash'), { target: { value: 'pro' } })
|
||||
expect(lastDraft(onChange)).toEqual({ models: [{ id: 'pro' }] })
|
||||
})
|
||||
|
||||
it('removes draft array rows wholesale', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Catalog)}
|
||||
draft={{ models: [{ id: 'flash' }, { id: 'pro' }] }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
fireEvent.click(screen.getAllByText('Remove')[0] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({ models: [{ id: 'pro' }] })
|
||||
})
|
||||
|
||||
it('renders dict rows from both layers with removal only for draft keys', () => {
|
||||
const Providers = Schema.object({ providers: Schema.dict(Schema.object({ baseURL: Schema.string() })) })
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Providers)}
|
||||
draft={{ providers: { openai: { baseURL: 'https://o' } } }}
|
||||
fallback={{ providers: { anthropic: { baseURL: 'https://a' }, openai: { baseURL: 'https://o' } } }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
expect(screen.getByText('anthropic')).toBeTruthy()
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
const removes = screen.getAllByText<HTMLButtonElement>('Remove')
|
||||
expect(removes.map(button => button.disabled)).toEqual([true, false])
|
||||
fireEvent.click(removes[1] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({ providers: {} })
|
||||
})
|
||||
|
||||
it('adds dict entries through a free-text key input', () => {
|
||||
const Providers = Schema.object({ providers: Schema.dict(Schema.object({ baseURL: Schema.string() })) })
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm schema={Wire(Providers)} draft={{}} onChange={onChange} />)
|
||||
const add = screen.getByLabelText<HTMLInputElement>('Add')
|
||||
fireEvent.keyDown(add, { key: 'a' })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
add.value = 'openai'
|
||||
fireEvent.keyDown(add, { key: 'Enter' })
|
||||
expect(lastDraft(onChange)).toEqual({ providers: { openai: {} } })
|
||||
add.value = ''
|
||||
fireEvent.keyDown(add, { key: 'Enter' })
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers remaining sKey vocabulary as the add select', () => {
|
||||
const Providers = Schema.object({
|
||||
providers: Schema.dict(Schema.object({ baseURL: Schema.string() }), Schema.union(['openai', 'anthropic'])),
|
||||
})
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Providers)}
|
||||
draft={{ providers: { openai: {} } }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const add = screen.getByLabelText<HTMLSelectElement>('Add')
|
||||
expect([...add.options].map(option => option.value)).toEqual(['', 'anthropic'])
|
||||
fireEvent.change(add, { target: { value: 'anthropic' } })
|
||||
expect(lastDraft(onChange)).toEqual({ providers: { openai: {}, anthropic: {} } })
|
||||
})
|
||||
|
||||
it('materializes type-shaped empty values for every array inner kind', () => {
|
||||
const Kinds = Schema.object({
|
||||
tags: Schema.array(Schema.string()),
|
||||
nums: Schema.array(Schema.number()),
|
||||
flags: Schema.array(Schema.boolean()),
|
||||
lists: Schema.array(Schema.array(Schema.string())),
|
||||
dicts: Schema.array(Schema.dict(Schema.string())),
|
||||
})
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm schema={Wire(Kinds)} draft={{}} onChange={onChange} />)
|
||||
const adds = screen.getAllByText('Add')
|
||||
const expected: Record<string, unknown> = {
|
||||
tags: [''], nums: [0], flags: [false], lists: [[]], dicts: [{}],
|
||||
}
|
||||
Object.entries(expected).forEach(([key, value], index) => {
|
||||
fireEvent.click(adds[index] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({ [key]: value })
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a read-only view for unsupported nodes instead of dropping them', () => {
|
||||
const Mixed = Schema.object({ weird: Schema.union([Schema.string(), Schema.number()]) })
|
||||
render(<SchemaForm
|
||||
schema={Wire(Mixed)}
|
||||
draft={{}}
|
||||
fallback={{ weird: 42 }}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
expect(screen.getByText('42')).toBeTruthy()
|
||||
expect(screen.getByText(/no form control/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the draft value in the read-only fallback view, and nothing when both layers are empty', () => {
|
||||
const Mixed = Schema.object({ weird: Schema.union([Schema.string(), Schema.number()]) })
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Mixed)}
|
||||
draft={{ weird: 'overridden' }}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
expect(screen.getByText('"overridden"')).toBeTruthy()
|
||||
cleanup()
|
||||
const empty = render(<SchemaForm schema={Wire(Mixed)} draft={{}} onChange={vi.fn()} />).container
|
||||
expect((empty.querySelector('pre') as HTMLElement).textContent).toBe('')
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a structural object node without declared properties as an empty group', () => {
|
||||
const { container } = render(<SchemaForm schema={{ type: 'object' }} draft={{}} onChange={vi.fn()} />)
|
||||
expect(container.querySelectorAll('input')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
21
packages/client/schema-form/tsconfig.json
Normal file
21
packages/client/schema-form/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -945,6 +945,28 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/schema-form:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/test-runtime:
|
||||
dependencies:
|
||||
'@testing-library/dom':
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
// so it cannot drag host-side Context augmentation into this program.
|
||||
{ "path": "./packages/host/webserver" },
|
||||
{ "path": "./packages/client/ui-slots" },
|
||||
{ "path": "./packages/client/schema-form" },
|
||||
{ "path": "./packages/client/ui-primitives" },
|
||||
{ "path": "./packages/client/web-react" },
|
||||
{ "path": "./packages/client/modules" },
|
||||
|
||||
Reference in New Issue
Block a user