From 9592c8f2718cfe0803e4b1045f4d2415d44cdb53 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 00:24:19 +0800 Subject: [PATCH] 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. --- packages/client/schema-form/README.md | 30 ++ packages/client/schema-form/package.json | 43 +++ .../schema-form/src/SchemaForm.module.css | 99 +++++ .../client/schema-form/src/SchemaForm.tsx | Bin 0 -> 15963 bytes .../client/schema-form/src/css-modules.d.ts | 6 + packages/client/schema-form/src/index.ts | 16 + packages/client/schema-form/src/invariant.ts | 32 ++ packages/client/schema-form/src/model.ts | 171 +++++++++ .../schema-form/tests/invariant.spec.ts | 12 + .../client/schema-form/tests/model.spec.ts | 103 ++++++ .../schema-form/tests/schema-form.spec.tsx | 346 ++++++++++++++++++ packages/client/schema-form/tsconfig.json | 21 ++ pnpm-lock.yaml | 22 ++ tsconfig.client.json | 1 + 14 files changed, 902 insertions(+) create mode 100644 packages/client/schema-form/README.md create mode 100644 packages/client/schema-form/package.json create mode 100644 packages/client/schema-form/src/SchemaForm.module.css create mode 100644 packages/client/schema-form/src/SchemaForm.tsx create mode 100644 packages/client/schema-form/src/css-modules.d.ts create mode 100644 packages/client/schema-form/src/index.ts create mode 100644 packages/client/schema-form/src/invariant.ts create mode 100644 packages/client/schema-form/src/model.ts create mode 100644 packages/client/schema-form/tests/invariant.spec.ts create mode 100644 packages/client/schema-form/tests/model.spec.ts create mode 100644 packages/client/schema-form/tests/schema-form.spec.tsx create mode 100644 packages/client/schema-form/tsconfig.json 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 0000000000000000000000000000000000000000..45bd62f50618b581c248463bf8c8a7fdc7c74959 GIT binary patch literal 15963 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 + 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" },