feat(ui-models)!: single-key hand-written provider editors with derived credential references

The Models page drops the generic schema renderer and the visible
environment-variable field: each editor is a curated per-family card whose
primary input is one write-only API key stored under a derived
<ROUTE>_API_KEY reference (recorded as apiKeyEnv in the pi-ai profile), an
unkeyed whole-section provider opens as its setup card, and the collapsed
customized-settings fold carries baseURL/reasoningEffort (deepseek) or
reasoning (pi-ai). dsh-client-schema-form reduces to the schema/draft model
layer (no React).
This commit is contained in:
Yichen Jiang
2026-07-30 12:17:56 +08:00
parent 65bd54f8b4
commit d1bfdbff84
24 changed files with 779 additions and 1156 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/schema-form/README.md
README.md: d6819ccf29cf58667d518c43c43b875eb20e02e9
README.zh.md: 2f2e07d41db2af5f0afc3352072e7d49129ce6f2
README.md: 23e69f80914b400a77c036192f564d32bc148310
README.zh.md: b26593d971d0c53d1fd8d0778200914a90b9b891

View File

@@ -2,21 +2,15 @@
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.
Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the seam's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering.
## 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.
The unit of editing is a **draft user section**: a plain object edited immutably (`setPath` materializes intermediates, `deletePath` is the per-field reset — dropping the key falls the resolved value back to the composition base and schema defaults). A field's presence in the draft marks it **overridden** (`hasPath`) — presence semantics, not value comparison, exactly mirroring the settings seam's layering. `nodeAtPath` resolves the schema node addressed by a configurable-provider directory `settingsPath` (object properties by name, dict entries through `inner`), so an editor can probe which fields a provider's profile carries (and their `meta.role`) before deciding what to render; an unresolvable path returns `undefined` so the caller degrades loudly instead of rendering a wrong subtree. `validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages reject an invalid draft before writing.
## Model Experience
None, as this package renders browser configuration forms; nothing here reaches a model request.
None, as this package backs browser configuration editors; nothing here reaches a model request.
#### KV Cache effect
@@ -24,7 +18,5 @@ 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.
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.

View File

@@ -2,21 +2,15 @@
[English](README.md) | 中文
面向 settings 分节的 schema 驱动 React 表单渲染器。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema`schema.toJSON()` 的 ref 信封);`SchemaForm``new Schema(json)` 将其还原rehydrate,并把每个已声明的字段渲染为可编辑控件——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验并驱动表单的那份对象,因此不存在第二份会漂移的表单定义
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema`schema.toJSON()` 的 ref 信封);`rehydrateSchema``new Schema(json)` 将其还原rehydrate为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件Models 页围绕它在此探测到的字段手写自己的卡片该包package不含任何 React也不做任何渲染
## 契约
`SchemaForm` 是围绕**用户分节草稿**的受控组件:`draft` 是正在编辑的对象(绝不被原地修改;每次编辑都以新的根对象调用 `onChange``fallback` 则是用于展示继承值的解析值schema 默认值 → 组合 base → 用户层)。字段只要出现在草稿中就被标记为**已覆盖**,并显示一个删除该键、回退到继承层的逐字段 Reset——判定采用存在性语义而非值比较与 settings seam 的分层方式严格对应
控件按 schema 节点分派:`object` → 带标签的字段组(渲染 JSDoc `description``required` 字段加星标);`string`/`number`/`boolean` → 以继承值为占位符的输入框;字面量 `union` → 下拉框,空选项表示「继承」;`array` → 按位置排列、可增删的行(数组在写入时整体替换);`dict` → 按键排列的行,其中联合类型的 `sKey` 成为「新增」下拉框的词汇。`role('secret')` 渲染为**只写**的密码输入框已存储的值永远不会送达wire 会剥除它),占位状态由 `secrets` 槽位列表(`{path, set}`提供。渲染器无法忠实编辑的节点非字面量联合、转换transform节点渲染为带提示的只读 JSON 视图而不是直接消失——schema 字段绝不会被静默丢弃。
`renderField(context)` 是感知角色的覆盖钩子返回一个节点即可替换单个叶子字段的默认控件。Models 设置页用它挂载与 `credentials.*` 通信的凭据引用控件(`role('credential-ref')`——该包package自身始终不接触 wire也没有副作用。
`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以先校验再写入;路径辅助函数(`getPath`/`hasPath`/`setPath`/`deletePath`)对外暴露的不可变草稿编辑,与控件内部使用的是同一套。
编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点object 属性按名称解析dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会大声降级,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿
## Model Experience
无。该包渲染的是浏览器配置表单;这里没有任何内容进入模型请求。
无。该包支撑的是浏览器配置编辑器;这里没有任何内容进入模型请求。
#### KV Cache effect
@@ -24,7 +18,5 @@
## Known Limitations and Deferred Work
- **校验是表单级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的内联报错展示延后到出现需要它的第二个消费方再做。
- **字符串内置为英文**——`labels` prop 可以覆盖每一条用户可见字符串,但包内没有接入语言环境词典的接线;本地化归嵌入它的页面所有。
- **非字面量联合与转换节点只读渲染**——忠实编辑这些形状需要逐形状的控件;目前它们回退为带提示的 JSON 视图。
- **数组编辑整体替换**——settings seam 同样不存在元素级合并;表单如实呈现该契约,而不是把它藏起来。
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。

View File

@@ -1,6 +1,6 @@
{
"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",
"description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -20,7 +20,6 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"react": "^18.2.0",
"schemastery": "^3.18.0"
},
"peerDependencies": {
@@ -29,7 +28,6 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [

View File

@@ -1,99 +0,0 @@
.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;
}

View File

@@ -1,6 +0,0 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -1,16 +1,12 @@
/**
* 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.
* Schema/draft model layer for settings editors: rehydrate the wire's
* serialized schemastery envelope, resolve nodes by settings path, validate
* drafts, and edit them immutably by path. Editors render their own controls
* (the Models page hand-writes its layout) on top of these helpers.
* @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, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from './model.ts'
export type { NodeKind, SchemaNode } from './model.ts'
export type { SchemaNode } from './model.ts'

View File

@@ -15,10 +15,10 @@ export const name = 'client-schema-form-invariant'
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.
* No runtime invariant: a pure schema/draft helper library — it emits no
* cordis events and owns no cross-plugin mutable relation; draft
* immutability, schema rehydration, and path-edit round trips are asserted
* directly by this package's model specs.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,8 +1,8 @@
/**
* Schema introspection and draft-editing helpers behind the form renderer.
* Schema introspection and draft-editing helpers behind settings editors.
* 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.
* live validator whose node relations (`dict`/`inner`) editors probe for
* field presence and roles; drafts are edited immutably by path.
* @module @deepseek-ai/dsh-client-schema-form/model
*/
@@ -35,49 +35,6 @@ export function validateDraft(schema: SchemaNode, draft: unknown): string | unde
}
}
/** 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)
}
/**
* Resolve the schema node at a settings path (the configurable-provider
* directory's `settingsPath` vocabulary): object properties by name, dict

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import Schema from 'schemastery'
import {
deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '../src/model.ts'
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
@@ -21,33 +21,6 @@ describe('rehydration and validation', () => {
})
})
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' }] }

View File

@@ -1,346 +0,0 @@
// @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)
})
})

View File

@@ -1,29 +0,0 @@
import { defineConfig } from 'tsdown'
/**
* schema-form is browser-only, but its lib bundle is imported under plain
* Node through consumer lib chains (same posture as ui-primitives). CSS
* imports are stubbed to empty modules: the hashed class maps only matter in
* bundler contexts, which compile src directly and never read lib.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
plugins: [{
name: 'dsh-css-stub',
resolveId(source: string) {
if (!source.endsWith('.css')) return null
return `\0dsh-css-stub:${source}.mjs`
},
load(id: string) {
if (!id.startsWith('\0dsh-css-stub:')) return null
return 'export default {};'
},
}],
})