diff --git a/packages/client/schema-form/README.md b/packages/client/schema-form/README.md new file mode 100644 index 0000000000..d6819ccf29 --- /dev/null +++ b/packages/client/schema-form/README.md @@ -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. diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json new file mode 100644 index 0000000000..29adb51133 --- /dev/null +++ b/packages/client/schema-form/package.json @@ -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" + ] +} diff --git a/packages/client/schema-form/src/SchemaForm.module.css b/packages/client/schema-form/src/SchemaForm.module.css new file mode 100644 index 0000000000..42c2a4c22e --- /dev/null +++ b/packages/client/schema-form/src/SchemaForm.module.css @@ -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; +} diff --git a/packages/client/schema-form/src/SchemaForm.tsx b/packages/client/schema-form/src/SchemaForm.tsx new file mode 100644 index 0000000000..45bd62f506 Binary files /dev/null and b/packages/client/schema-form/src/SchemaForm.tsx differ diff --git a/packages/client/schema-form/src/css-modules.d.ts b/packages/client/schema-form/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/schema-form/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/schema-form/src/index.ts b/packages/client/schema-form/src/index.ts new file mode 100644 index 0000000000..e83d180b5e --- /dev/null +++ b/packages/client/schema-form/src/index.ts @@ -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' diff --git a/packages/client/schema-form/src/invariant.ts b/packages/client/schema-form/src/invariant.ts new file mode 100644 index 0000000000..ffb435b4cf --- /dev/null +++ b/packages/client/schema-form/src/invariant.ts @@ -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 */ diff --git a/packages/client/schema-form/src/model.ts b/packages/client/schema-form/src/model.ts new file mode 100644 index 0000000000..8415762940 --- /dev/null +++ b/packages/client/schema-form/src/model.ts @@ -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)[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 | unknown[] { + if (Array.isArray(container)) return [...container as unknown[]] + if (typeof container === 'object' && container !== null) return { ...container as Record } + // 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, path: readonly string[], value: unknown): Record { + if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path') + const result = { ...root } + let target: Record | 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, path: readonly string[]): Record { + 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 | 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 +} diff --git a/packages/client/schema-form/tests/invariant.spec.ts b/packages/client/schema-form/tests/invariant.spec.ts new file mode 100644 index 0000000000..7f7ba10dd8 --- /dev/null +++ b/packages/client/schema-form/tests/invariant.spec.ts @@ -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() + }) +}) diff --git a/packages/client/schema-form/tests/model.spec.ts b/packages/client/schema-form/tests/model.spec.ts new file mode 100644 index 0000000000..03dd6c8ef1 --- /dev/null +++ b/packages/client/schema-form/tests/model.spec.ts @@ -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[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 }) + }) +}) diff --git a/packages/client/schema-form/tests/schema-form.spec.tsx b/packages/client/schema-form/tests/schema-form.spec.tsx new file mode 100644 index 0000000000..b836daf4b3 --- /dev/null +++ b/packages/client/schema-form/tests/schema-form.spec.tsx @@ -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): Record { + return onChange.mock.calls.at(-1)?.[0] as Record +} + +describe('leaf controls', () => { + it('renders strings with inherited placeholders, writes on input, clears on empty', () => { + const onChange = vi.fn() + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + const input = container.querySelector('input[type="password"]') as HTMLInputElement + expect(input.placeholder).toBe('Not configured') + }) + + it('lets renderField replace a role-tagged control', () => { + render( { + if (context.role !== 'credential-ref') return undefined + return
{String(context.draftValue)}
+ }} + />) + expect(screen.getByTestId('credential-control').textContent).toBe('OPENAI_API_KEY') + }) + + it('disables every control under disabled', () => { + const { container } = render() + 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() + 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() + 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() + 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() + expect(screen.getByText('anthropic')).toBeTruthy() + expect(screen.getByText('openai')).toBeTruthy() + const removes = screen.getAllByText('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() + const add = screen.getByLabelText('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() + const add = screen.getByLabelText('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() + const adds = screen.getAllByText('Add') + const expected: Record = { + 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() + 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() + expect(screen.getByText('"overridden"')).toBeTruthy() + cleanup() + const empty = render().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() + expect(container.querySelectorAll('input')).toHaveLength(0) + }) +}) diff --git a/packages/client/schema-form/tsconfig.json b/packages/client/schema-form/tsconfig.json new file mode 100644 index 0000000000..44a9376434 --- /dev/null +++ b/packages/client/schema-form/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 210b112588..0611cab2c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/tsconfig.client.json b/tsconfig.client.json index f4063d52a6..149db0bc9f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -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" },