From 1437ca816ff894819dba909790d7769763f6ce52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:29:09 +0000 Subject: [PATCH 1/3] test(schema-catalog): runtime census of the form-field channel, before any declaration (objectui#12130) Measures, through the real SchemaRenderer, every key of a form `fields[]` entry the runtime reads by name: a recording proxy over the entry (the form renderer and every field widget's metadata carrier) and over each registered field widget's props, three render states per type. The keys read and not declared on FormFieldSchema are pinned as a ledger with their readers, so a new read-but-undeclared key turns the census red. Beside it, what each ledgered key does (with and without it, through the real registrations), and the two keys the card named: neither `defaultChecked` nor `defaultValue` is read by name, and neither becomes the submitted value, with or without `@object-ui/fields` registered. Claude-Session: https://claude.ai/code/session_01V8qYZseSLfKH3V8tPu2f73 Co-authored-by: Claude --- .../form-field-channel-census-12130.test.tsx | 379 ++++++++++++++++++ ...eld-channel-components-only-12130.test.tsx | 106 +++++ .../form-field-channel-effects-12130.test.tsx | 308 ++++++++++++++ 3 files changed, 793 insertions(+) create mode 100644 examples/schema-catalog/test/form-field-channel-census-12130.test.tsx create mode 100644 examples/schema-catalog/test/form-field-channel-components-only-12130.test.tsx create mode 100644 examples/schema-catalog/test/form-field-channel-effects-12130.test.tsx diff --git a/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx b/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx new file mode 100644 index 0000000000..abf7beea36 --- /dev/null +++ b/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx @@ -0,0 +1,379 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The form-field channel census (objectui#12130): every key of a `form` + * `fields[]` entry that the runtime reads BY NAME, measured through the real + * `SchemaRenderer`, compared with what the authoring face declares. + * + * ## The channel + * + * The `form` renderer hands each `fields[]` entry to its field widget twice: + * as the widget's metadata carrier (`field: field.field || field`, the one + * `renderFieldComponent` call in `renderers/form/form.tsx`), and as props, + * because the entry's remaining keys are spread into the widget's props bag. + * A key a widget reads there works, whether or not `FormFieldSchema` declares + * it, and the strict authoring face (`objectui validate`, `objectui check`) + * tells the author to remove every key it does not declare. objectui#11070 + * graded this channel from a source read and missed keys on it; this file + * replaces that grade with a measurement. + * + * ## The instrument + * + * Two recording proxies, both answering every property read with the real + * value and recording the reads of keys that are ABSENT (a key the author did + * not write, asked for by name): + * + * 1. over the entry itself. It reaches the form renderer, and every + * registered field widget as its carrier. Each read is attributed to the + * source module on top of the stack (`PKG/src/…`, under `packages/`). + * 2. over the props bag each registered field widget receives. Every + * `field:` registration is replaced, at module scope, by the same widget + * function called with recorded props (`withFieldCarrier` is kept, the + * registration meta is kept). A key read off props is counted only when + * the entry can DELIVER it: the census re-renders that widget with the key + * written on the entry and keeps it only if the widget then receives the + * written value. Plumbing the form sets or strips (`value`, `dataSource`, + * `emptyHint`, …) fails that check and is not a field key. + * + * Every type is rendered in three states (empty and editable; read-only with a + * value; disabled with a value), because several widgets read some keys only + * when a value is drawn. + * + * ## The assertion + * + * The keys read and not declared on `FormFieldSchema` must be exactly the keys + * of {@link LEDGER}, each with the readers recorded there. A widget that starts + * reading a new undeclared key turns this file red, naming the key and its + * readers; so does a ledger row whose read is gone. + * + * ## What it does not see (bounds, said per AGENTS.md #9) + * + * - Reads made only inside an interaction handler (a click, a search, an + * upload) are not enumerated: the census renders, it does not interact. + * - The built-in branches (`input`, `textarea`, `checkbox`, `switch`, + * `select`) and the unregistered-widget default branch spread the entry's + * leftover keys onto a native control or a Radix primitive. That tail is an + * open set read by no name, and no recorder sees it: a key that works only + * there is measured by `form-field-channel-effects-12130.test.tsx`, never + * by this census. + * - The development-mode schema validator `SchemaRenderer` runs on every node + * reads the entry too. It is not the channel, so its reads are dropped. + * + * Module-scope imports and resolution, not `beforeAll` (AGENTS.md §测试纪律): + * the widgets sit behind `React.lazy`, and resolving them is an unbounded + * module load. + */ +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, act } from '@testing-library/react'; +import '@object-ui/components'; +import { FORM_FIELD_TYPES, getLazyFieldWidget, withFieldCarrier } from '@object-ui/fields'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '@object-ui/react'; +import { FormFieldSchema } from '@object-ui/types/zod'; + +// Deep enough to reach the reading module from inside React's render frames. +Error.stackTraceLimit = 100; + +/* ── the recorders ───────────────────────────────────────────────────────── */ + +interface Read { + key: string; + reader: string; + type: string; +} + +let currentType = ''; +let reads: Read[] = []; +/** What each recorded widget received, per type, for the deliverability check. */ +const lastProps = new Map>(); + +/** The repository source module that made the read, or `?` when none is on the stack. */ +function readerOf(stack: string): string { + for (const line of stack.split('\n').slice(1)) { + if (line.includes('/examples/schema-catalog/test/')) continue; + const m = line.match(/\/packages\/([a-z0-9-]+\/src\/[^:)?]+)/); + if (m) return m[1]; + } + return '?'; +} + +function recorded(target: T, via: 'entry' | 'props'): T { + return new Proxy(target, { + get(t, key, receiver) { + if (typeof key === 'string' && !Reflect.has(t, key)) { + reads.push({ key, reader: `${via}@${readerOf(new Error().stack ?? '')}`, type: currentType }); + } + return Reflect.get(t, key, receiver); + }, + }); +} + +/* ── every field widget, re-registered with recorded props ───────────────── */ + +interface LazyInternals { + _init: (payload: unknown) => unknown; + _payload: unknown; +} +type WidgetFn = (props: Record) => React.ReactNode; + +/** The component a `React.lazy` resolves to. Module loading, not rendering. */ +async function resolveLazy(lazy: unknown): Promise { + const internals = lazy as LazyInternals; + for (;;) { + try { + return internals._init(internals._payload); + } catch (pending) { + if (pending && typeof (pending as PromiseLike).then === 'function') await pending; + else throw pending; + } + } +} + +const MEMO = Symbol.for('react.memo'); +const FORWARD_REF = Symbol.for('react.forward_ref'); + +/** The plain render function behind a widget, or `undefined` for a shape this file does not unwrap. */ +function renderFunctionOf(component: unknown): WidgetFn | undefined { + if (typeof component === 'function') { + return (component as { prototype?: { isReactComponent?: unknown } }).prototype?.isReactComponent + ? undefined + : (component as WidgetFn); + } + const shaped = component as { $$typeof?: symbol; type?: unknown; render?: (p: unknown, r: unknown) => React.ReactNode }; + if (shaped?.$$typeof === MEMO) return renderFunctionOf(shaped.type); + if (shaped?.$$typeof === FORWARD_REF && shaped.render) { + const renderFn = shaped.render; + return (props) => renderFn(props, null); + } + return undefined; +} + +const unwrapped = new Map(); +for (const type of FORM_FIELD_TYPES) { + const widget = renderFunctionOf(await resolveLazy(getLazyFieldWidget(type))); + unwrapped.set(type, !!widget); + if (!widget) continue; + const config = ComponentRegistry.getConfig(type, 'field'); + if (!config) continue; + const { type: _fullType, component: _component, ...meta } = config; + const Recorded: React.FC> = (props) => { + lastProps.set(type, props); + return <>{widget(recorded({ ...props }, 'props'))}; + }; + Recorded.displayName = `CensusRecorded(${type})`; + ComponentRegistry.register(type, withFieldCarrier(Recorded), { ...meta, namespace: 'field', skipFallback: true }); +} + +/* ── the population ──────────────────────────────────────────────────────── */ + +/** + * The built-in branches (`BUILTIN_FIELD_TYPES` in `renderers/form/form.tsx`), + * every registered field widget, and one type nothing registers, which takes + * the default branch. + */ +const TYPES = ['input', 'textarea', 'checkbox', 'switch', 'select', 'census-unregistered', ...FORM_FIELD_TYPES]; + +const OPTIONS = [ + { label: 'Alpha', value: 'a' }, + { label: 'Beta', value: 'b' }, +]; + +/** A value each type can draw, for the two valued states. `undefined` leaves the field empty. */ +const SAMPLE_VALUE: Readonly> = { + input: 'Value', textarea: 'Value', checkbox: true, switch: true, select: 'a', 'census-unregistered': 'Value', + text: 'Value', number: 3, boolean: true, date: '2026-01-02', datetime: '2026-01-02T03:04:05Z', time: '03:04', + email: 'a@b.co', phone: '123', url: 'https://x.dev', multiselect: ['a'], radio: 'a', checkboxes: ['a'], + tags: ['a'], currency: 3, percent: 0.3, password: 'pw', markdown: 'm', html: 'h', richtext: 'r', + lookup: 'id1', master_detail: 'id1', user: 'u1', location: { lat: 1, lng: 2 }, formula: 3, summary: 3, + auto_number: 'AN-1', object: { a: 1 }, vector: [0.1, 0.2], grid: [{ product: 'x' }], color: '#ff0000', + slider: 3, rating: 3, code: 'x=1', address: { street: 's' }, geolocation: { lat: 1, lng: 2 }, qrcode: 'q', + 'object-ref': 'account', +}; + +/** Keys a type needs on its entry to render at all. */ +function baseEntry(type: string): Record { + const entry: Record = { name: 'f', label: 'F', type }; + if (['select', 'radio', 'multiselect', 'checkboxes'].includes(type)) entry.options = OPTIONS; + if (type === 'grid') entry.columns = [{ name: 'product', type: 'text' }]; + return entry; +} + +const STATES = ['empty', 'readonly', 'disabled'] as const; + +const settle = async () => { + for (let i = 0; i < 5; i++) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); +}; + +async function renderEntry(type: string, entry: Record, value?: unknown) { + currentType = type; + const node: Record = { type: 'form', fields: [entry] }; + if (value !== undefined) node.defaultValues = { f: value }; + const { unmount } = render(); + await settle(); + unmount(); +} + +/** Readers whose reads are not the channel. */ +const NOT_THE_CHANNEL = /^entry@core\/src\/validation\/schema-validator\.ts$/; + +/** key → readers (`entry@MODULE` / `props@MODULE`) and the field types whose render read it. */ +type Census = Map; types: Set }>; + +async function takeCensus(): Promise<{ census: Census; unattributed: Read[] }> { + reads = []; + for (const type of TYPES) { + for (const state of STATES) { + const entry = baseEntry(type); + if (state !== 'empty') entry[state] = true; + await renderEntry(type, recorded(entry, 'entry'), state === 'empty' ? undefined : SAMPLE_VALUE[type]); + } + } + const all = reads; + + // Deliverability of each props read: write the key on the entry, and keep it + // only if the widget then receives exactly the written value. A string, not + // an object: ``'s Slot joins `className` into a string, which + // would turn any non-string canary into `[object Object]`. A handler-shaped + // key (`onX`) gets a function, which is what its reader calls. + const propsReads = all.filter((r) => r.reader.startsWith('props@')); + const deliverable = new Set(); + for (const pair of new Set(propsReads.map((r) => `${r.type}\u0000${r.key}`))) { + const [type, key] = pair.split('\u0000'); + const canary: unknown = /^on[A-Z]/.test(key) ? () => undefined : `census-canary-${key}`; + await renderEntry(type, { ...baseEntry(type), [key]: canary }); + if (lastProps.get(type)?.[key] === canary) deliverable.add(pair); + } + + const census: Census = new Map(); + const unattributed: Read[] = []; + for (const r of all) { + if (NOT_THE_CHANNEL.test(r.reader)) continue; + if (r.reader.endsWith('@?')) { unattributed.push(r); continue; } + if (r.reader.startsWith('props@') && !deliverable.has(`${r.type}\u0000${r.key}`)) continue; + const row = census.get(r.key) ?? { readers: new Set(), types: new Set() }; + row.readers.add(r.reader); + row.types.add(r.type); + census.set(r.key, row); + } + return { census, unattributed }; +} + +/* ── the ledger: read, and not declared ──────────────────────────────────── */ + +interface LedgerRow { + /** Every reader the census records for the key, sorted. */ + readers: readonly string[]; + /** Why the strict face refusing the key is right, or why it is still open. */ + why: string; +} + +const LOOKUP = 'entry@fields/src/widgets/LookupField.tsx'; +const USER = 'entry@fields/src/widgets/UserField.tsx'; +const CURRENCY = 'entry@i18n/src/currency.ts'; + +/** + * Every key the channel reads that `FormFieldSchema` does not declare, with its + * readers. The strict face refuses each by name; the `why` says whether that + * refusal is right. + */ +const LEDGER: Readonly> = { + // ── read, and the widget acts on it: the strict face's refusal is false ── + step: { readers: ['entry@fields/src/widgets/NumberField.tsx', 'entry@fields/src/widgets/SliderField.tsx'], why: 'READ AND EFFECTIVE, refused' }, + language: { readers: ['entry@fields/src/widgets/CodeField.tsx'], why: 'READ AND EFFECTIVE, refused' }, + maxSize: { readers: ['entry@fields/src/widgets/FileField.tsx', 'entry@fields/src/widgets/ImageField.tsx'], why: 'READ AND EFFECTIVE, refused' }, + capture: { readers: ['entry@fields/src/widgets/FileField.tsx'], why: 'READ AND EFFECTIVE, refused' }, + crop: { readers: ['entry@fields/src/widgets/ImageField.tsx'], why: 'READ AND EFFECTIVE, refused' }, + defaultName: { readers: ['entry@fields/src/widgets/AvatarField.tsx'], why: 'READ AND EFFECTIVE, refused' }, + allowCreate: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, + descriptionField: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, + displayField: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, + idField: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, + lookupColumns: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, + lookupFilters: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, + lookupPageSize: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, + picker: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, + subtitle: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, + avatarField: { readers: [LOOKUP, USER], why: 'READ, refused' }, + avatar_field: { readers: [LOOKUP, USER], why: 'READ, refused' }, + className: { + readers: [ + 'props@fields/src/widgets/CheckboxesField.tsx', 'props@fields/src/widgets/CurrencyField.tsx', + 'props@fields/src/widgets/FilterConditionField.tsx', 'props@fields/src/widgets/MultiSelectField.tsx', + 'props@fields/src/widgets/ObjectRefField.tsx', 'props@fields/src/widgets/PasswordField.tsx', + 'props@fields/src/widgets/PercentField.tsx', 'props@fields/src/widgets/RadioField.tsx', + 'props@fields/src/widgets/RatingField.tsx', 'props@fields/src/widgets/RecipientPickerField.tsx', + 'props@fields/src/widgets/TagsField.tsx', + ], + why: 'READ, refused', + }, + // ── second spellings of a key the spec or this face spells otherwise ──── + allow_create: { readers: [LOOKUP], why: 'READ, refused' }, + lookup_columns: { readers: [LOOKUP], why: 'READ, refused' }, + lookup_page_size: { readers: [LOOKUP], why: 'READ, refused' }, + reference_field: { readers: [LOOKUP, USER], why: 'READ, refused' }, + defaultCurrency: { readers: [CURRENCY], why: 'READ, refused' }, + // ── runtime values no document can carry, and host-stamped keys ───────── + dataSource: { readers: [LOOKUP], why: 'READ, refused' }, + onCreateNew: { readers: [LOOKUP], why: 'READ, refused' }, + onUploadingChange: { + readers: ['props@fields/src/widgets/AvatarField.tsx', 'props@fields/src/widgets/FileField.tsx', 'props@fields/src/widgets/ImageField.tsx'], + why: 'READ, refused', + }, + field: { + readers: ['entry@components/src/renderers/form/form.tsx', 'entry@core/src/validation/server-owned-value.ts', LOOKUP, USER], + why: 'READ, refused', + }, + mobile_fullscreen: { readers: ['entry@fields/src/widgets/RichTextField.tsx'], why: 'READ, refused' }, + currency: { readers: [CURRENCY], why: 'READ, refused' }, +}; + +/* ── the census ──────────────────────────────────────────────────────────── */ + +const { census, unattributed } = await takeCensus(); +const declared = new Set(Object.keys(FormFieldSchema.shape)); +const readers = (key: string) => [...(census.get(key)?.readers ?? [])].sort(); + +describe('the instrument sees the channel (objectui#12130)', () => { + it('every registered field widget was unwrapped and re-registered with recorded props', () => { + expect([...unwrapped].filter(([, ok]) => !ok).map(([type]) => type)).toEqual([]); + }); + + it('every read on the channel is attributed to a source module', () => { + expect(unattributed.map((r) => `${r.type}: ${r.key}`)).toEqual([]); + }); + + it('CONTROL: it records reads by all three readers — the form renderer, a widget off its carrier, a widget off its props', () => { + // Declared keys, so these rows say nothing about the ledger: they say the + // recorders reached each reader the ledger could name. + expect(readers('visibleWhen')).toContain('entry@components/src/renderers/form/form.tsx'); + expect(readers('scale')).toContain('entry@fields/src/widgets/NumberField.tsx'); + expect(readers('reference')).toContain('entry@fields/src/widgets/LookupField.tsx'); + expect(readers('dependsOn')).toContain('props@fields/src/widgets/MultiSelectField.tsx'); + }); + + it('CONTROL: neither key objectui#12130 was filed for is read by name anywhere on the channel', () => { + expect(census.has('defaultValue')).toBe(false); + expect(census.has('defaultChecked')).toBe(false); + }); +}); + +describe('every key the channel reads is declared on FormFieldSchema, or ledgered (objectui#12130)', () => { + it('the undeclared reads are exactly the ledger', () => { + const undeclared = [...census.keys()].filter((k) => !declared.has(k)).sort(); + const unexpected = undeclared.filter((k) => !(k in LEDGER)).map((k) => `${k} ← ${readers(k).join(', ')}`); + const gone = Object.keys(LEDGER).filter((k) => !undeclared.includes(k)); + expect({ unexpected, gone }).toEqual({ unexpected: [], gone: [] }); + }); + + it.each(Object.entries(LEDGER))('ledger row `%s` names exactly the readers the census records', (key, row) => { + expect(readers(key)).toEqual([...row.readers].sort()); + }); +}); + diff --git a/examples/schema-catalog/test/form-field-channel-components-only-12130.test.tsx b/examples/schema-catalog/test/form-field-channel-components-only-12130.test.tsx new file mode 100644 index 0000000000..ed74def5af --- /dev/null +++ b/examples/schema-catalog/test/form-field-channel-components-only-12130.test.tsx @@ -0,0 +1,106 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `defaultChecked` and `defaultValue` on a form field, with + * `@object-ui/components` registered and `@object-ui/fields` NOT registered + * (objectui#12130). + * + * The other registry set of the measurement in + * `form-field-channel-effects-12130.test.tsx`. Without `@object-ui/fields` no + * `field:` widget exists, so every type outside the built-in branches takes + * the form renderer's unregistered-widget default branch: a native `` + * that receives the entry's leftover keys. Neither key becomes the field's + * value there either: the box stays unchecked, and the submitted payload + * carries no value. Each case's control seeds the same value through + * `FormSchema.defaultValues`, the form's initial-values channel, and reads it + * back from the same places. + * + * This file imports no field widgets on purpose. That absence IS the registry + * set it measures, so a module-scope `@object-ui/fields` import added here + * would silently turn it into a copy of the file beside it. + */ +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import '@object-ui/components'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '@object-ui/react'; + +const settle = async (rounds = 8) => { + for (let i = 0; i < rounds; i++) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 25)); }); +}; + +type Row = Record; + +async function mount(field: Row, defaultValues?: Row) { + let submitted: Row | undefined; + const node = { + type: 'form', + showSubmit: true, + submitLabel: 'Go', + ...(defaultValues ? { defaultValues } : {}), + fields: [{ name: 'f', label: 'F', ...field }], + onSubmit: (data: Row) => { submitted = data; }, + }; + const utils = render(); + await settle(); + const row = () => utils.container.querySelector('[data-field="f"]') as HTMLElement; + const submit = async () => { + fireEvent.click(utils.getByRole('button', { name: 'Go' })); + await settle(); + return submitted; + }; + return { ...utils, row, submit }; +} + +describe('the registry set this file measures (objectui#12130)', () => { + it('CONTROL: no field widget is registered, so these types take the default branch', () => { + for (const type of ['text', 'email', 'number', 'radio', 'boolean']) { + expect(ComponentRegistry.get(type, 'field'), type).toBeUndefined(); + } + }); +}); + +describe('`defaultChecked` on a form field is not honored without `@object-ui/fields` (objectui#12130)', () => { + it.each(['checkbox', 'switch'])('%s', async (type) => { + const box = (m: Awaited>) => m.row().querySelector('[role=checkbox],[role=switch]'); + const control = await mount({ type }, { f: true }); + expect(box(control)?.getAttribute('aria-checked')).toBe('true'); + expect((await control.submit())?.f).toBe(true); + control.unmount(); + + const withKey = await mount({ type, defaultChecked: true }); + expect(box(withKey)?.getAttribute('aria-checked')).toBe('false'); + expect((await withKey.submit())?.f).toBe(false); + }); +}); + +describe('`defaultValue` on a form field never becomes the submitted value without `@object-ui/fields` (objectui#12130)', () => { + const CASES: ReadonlyArray = [ + ['input', 'preset@x.co'], + ['textarea', 'preset@x.co'], + ['text', 'preset@x.co'], + ['email', 'preset@x.co'], + ['number', 42], + ['select', 'b'], + ['radio', 'b'], + ]; + const OPTIONS = [{ label: 'Alpha', value: 'a' }, { label: 'Beta', value: 'b' }]; + + it.each(CASES)('%s', async (type, value) => { + const field = { type, ...(type === 'select' || type === 'radio' ? { options: OPTIONS } : {}) }; + const control = await mount(field, { f: value }); + // The default branch draws a native input, which hands back a string. + expect(String((await control.submit())?.f)).toBe(String(value)); + control.unmount(); + + const withKey = await mount({ ...field, defaultValue: value }); + expect((await withKey.submit())?.f).toBeUndefined(); + }); +}); diff --git a/examples/schema-catalog/test/form-field-channel-effects-12130.test.tsx b/examples/schema-catalog/test/form-field-channel-effects-12130.test.tsx new file mode 100644 index 0000000000..01cf35e738 --- /dev/null +++ b/examples/schema-catalog/test/form-field-channel-effects-12130.test.tsx @@ -0,0 +1,308 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * What the form-field channel's keys DO, measured through the real + * `SchemaRenderer`, the real `form` renderer and the real `@object-ui/fields` + * registrations (objectui#12130). + * + * `form-field-channel-census-12130.test.tsx` beside this file records which + * keys of a `fields[]` entry the runtime reads by name. A read is not yet an + * effect, so this file renders each key the census finds read and undeclared + * twice, without it and with it, and asserts the widget does something + * different. Each case's first render is its control: it shows the observable + * the case reads is there to change. + * + * It also measures the two keys objectui#12130 was filed for, `defaultChecked` + * and `defaultValue`. The census records no read of either by name. They reach + * a control only through the built-in branches' pass-through of leftover + * entry keys, and neither becomes the field's VALUE: the box stays unchecked, + * and the submitted payload carries no value, on every type measured here. + * A form's initial values are `FormSchema.defaultValues`; each case's control + * seeds the same value through it and reads it back from the same channels. + * The registry without `@object-ui/fields` is measured by + * `form-field-channel-components-only-12130.test.tsx`. + * + * Module-scope import of `@object-ui/fields`, not `beforeAll` (AGENTS.md + * §测试纪律): the widgets sit behind `React.lazy`. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import '@object-ui/components'; +import '@object-ui/fields'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; + +afterEach(() => { + // The lookup widgets remember picked ids in `localStorage`; a remembered id + // adds a query to the next case's log. + try { localStorage.clear(); } catch { /* storage unavailable: nothing remembered */ } +}); + +const settle = async (rounds = 8) => { + for (let i = 0; i < rounds; i++) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 25)); }); +}; + +type Row = Record; +type Params = { $top?: number; $skip?: number; $filter?: unknown }; + +const ACCOUNTS: Row[] = [ + { id: 'r1', name: 'Name One', title: 'Title One', industry: 'Retail', code: 'C-1', email: 'one@x.co' }, + { id: 'r2', name: 'Name Two', title: 'Title Two', industry: 'Energy', code: 'C-2', email: 'two@x.co' }, +]; + +/** A data source whose `find` answers `rows` and logs every query. */ +function dataSourceOf(rows: Row[] = ACCOUNTS) { + const find = vi.fn(async (_object: string, params?: Params) => ({ + data: rows.slice(params?.$skip ?? 0, (params?.$skip ?? 0) + (params?.$top ?? rows.length)), + total: rows.length, + })); + const findOne = vi.fn(async (_object: string, id: unknown) => rows.find((r) => r.id === id) ?? null); + return { find, findOne, create: vi.fn(async () => ({ id: 'new' })) }; +} + +/** Render a one-field form; `submit()` clicks its button and returns what the form submitted. */ +async function mount(field: Row, opts: { dataSource?: unknown; defaultValues?: Row } = {}) { + let submitted: Row | undefined; + const node = { + type: 'form', + showSubmit: true, + submitLabel: 'Go', + ...(opts.defaultValues ? { defaultValues: opts.defaultValues } : {}), + fields: [{ name: 'f', label: 'F', ...field }], + onSubmit: (data: Row) => { submitted = data; }, + }; + const utils = render( + + + , + ); + await settle(); + const row = () => utils.container.querySelector('[data-field="f"]') as HTMLElement; + const submit = async () => { + fireEvent.click(utils.getByRole('button', { name: 'Go' })); + await settle(); + return submitted; + }; + return { ...utils, row, submit }; +} + +const click = async (el: Element | null) => { + expect(el).toBeTruthy(); + await act(async () => { fireEvent.click(el as Element); }); + await settle(); +}; + +const queriesOf = (ds: ReturnType) => ds.find.mock.calls.map((call) => (call[1] ?? {}) as Params); + +/* ── the keys the census reads, each one changes what the widget does ──── */ + +describe('a read field key changes what its widget does (objectui#12130)', () => { + it('`step` (number) sets the input step', async () => { + const without = await mount({ type: 'number' }); + expect(without.row().querySelector('input')?.getAttribute('step')).toBe('any'); + without.unmount(); + const withKey = await mount({ type: 'number', step: 5 }); + expect(withKey.row().querySelector('input')?.getAttribute('step')).toBe('5'); + }); + + it('`language` (code) names the language the editor asks for', async () => { + const without = await mount({ type: 'code' }); + expect(without.row().querySelector('[placeholder]')?.getAttribute('placeholder')).toContain('javascript'); + without.unmount(); + const withKey = await mount({ type: 'code', language: 'python' }); + expect(withKey.row().querySelector('[placeholder]')?.getAttribute('placeholder')).toContain('python'); + }); + + it('`maxSize` (file) refuses a larger pick before any upload', async () => { + const pick = async (m: Awaited>) => { + const input = m.row().querySelector('input[type=file]') as HTMLInputElement; + await act(async () => { fireEvent.change(input, { target: { files: [new File(['0123456789'], 'ten.txt', { type: 'text/plain' })] } }); }); + await settle(); + return m.row().textContent ?? ''; + }; + const without = await mount({ type: 'file' }); + expect(await pick(without)).not.toContain('exceeds max size'); + without.unmount(); + const withKey = await mount({ type: 'file', maxSize: 4 }); + expect(await pick(withKey)).toContain('exceeds max size'); + }); + + it('`capture` (file) adds a camera input in that capture mode', async () => { + const without = await mount({ type: 'file' }); + expect(without.row().querySelector('input[capture]')).toBeNull(); + without.unmount(); + const withKey = await mount({ type: 'file', capture: 'user' }); + expect(withKey.row().querySelector('input[capture="user"]')).toBeTruthy(); + }); + + it('`crop` (image) `false` removes the crop action from a drawn image', async () => { + const value = { f: { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' } }; + const without = await mount({ type: 'image' }, { defaultValues: value }); + expect(without.row().querySelector('[data-testid="image-field-crop-0"]')).toBeTruthy(); + without.unmount(); + const withKey = await mount({ type: 'image', crop: false }, { defaultValues: value }); + expect(withKey.row().querySelector('[data-testid="image-field-crop-0"]')).toBeNull(); + }); + + it('`defaultName` (avatar) is the name the empty avatar draws initials from', async () => { + const without = await mount({ type: 'avatar' }); + expect(without.row().textContent).not.toContain('ZQ'); + without.unmount(); + const withKey = await mount({ type: 'avatar', defaultName: 'Zed Quux' }); + expect(withKey.row().textContent).toContain('ZQ'); + }); + + /** Open a lookup's quick-select popover; the offered options' text. */ + const openLookup = async (m: Awaited>) => { + await click(m.row().querySelector('[data-testid^="lookup-trigger"]')); + return screen.queryAllByRole('option').map((o) => o.textContent ?? ''); + }; + + it('`displayField` (lookup) is the record field each option is labelled by', async () => { + const without = await mount({ type: 'lookup', reference: 'account' }, { dataSource: dataSourceOf() }); + expect(await openLookup(without)).toEqual(['Name One', 'Name Two']); + without.unmount(); + const withKey = await mount({ type: 'lookup', reference: 'account', displayField: 'title' }, { dataSource: dataSourceOf() }); + expect(await openLookup(withKey)).toEqual(['Title One', 'Title Two']); + }); + + it('`descriptionField` (lookup) adds each record\'s field under its option', async () => { + const without = await mount({ type: 'lookup', reference: 'account' }, { dataSource: dataSourceOf() }); + expect((await openLookup(without)).join(' ')).not.toContain('Retail'); + without.unmount(); + const withKey = await mount({ type: 'lookup', reference: 'account', descriptionField: 'industry' }, { dataSource: dataSourceOf() }); + expect((await openLookup(withKey)).join(' ')).toContain('Retail'); + }); + + it('`idField` (lookup) is the record field a pick submits', async () => { + const pick = async (m: Awaited>) => { + await openLookup(m); + await click(screen.queryAllByRole('option')[0]); + return (await m.submit())?.f; + }; + const without = await mount({ type: 'lookup', reference: 'account' }, { dataSource: dataSourceOf() }); + expect(await pick(without)).toBe('r1'); + without.unmount(); + const withKey = await mount({ type: 'lookup', reference: 'account', idField: 'code' }, { dataSource: dataSourceOf() }); + expect(await pick(withKey)).toBe('C-1'); + }); + + it('`lookupFilters` (lookup) scopes the candidate query', async () => { + const hasStatusFilter = (ds: ReturnType) => + queriesOf(ds).some((q) => JSON.stringify(q.$filter ?? null).includes('status')); + const dsWithout = dataSourceOf(); + const without = await mount({ type: 'lookup', reference: 'account' }, { dataSource: dsWithout }); + await openLookup(without); + expect(hasStatusFilter(dsWithout)).toBe(false); + without.unmount(); + const dsWith = dataSourceOf(); + const withKey = await mount( + { type: 'lookup', reference: 'account', lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }] }, + { dataSource: dsWith }, + ); + await openLookup(withKey); + expect(hasStatusFilter(dsWith)).toBe(true); + }); + + it('`allowCreate` (lookup) `false` withdraws the create offer from an empty result', async () => { + const empty = () => { + const ds = dataSourceOf([]); + return ds; + }; + const without = await mount({ type: 'lookup', reference: 'account' }, { dataSource: empty() }); + await openLookup(without); + expect(document.body.textContent).toContain('Create new'); + without.unmount(); + const withKey = await mount({ type: 'lookup', reference: 'account', allowCreate: false }, { dataSource: empty() }); + await openLookup(withKey); + expect(document.body.textContent).not.toContain('Create new'); + }); + + it('`picker` (lookup) `search` replaces the quick-select and its browse-all dialog with the search picker', async () => { + const without = await mount({ type: 'lookup', reference: 'account' }, { dataSource: dataSourceOf() }); + expect(without.row().querySelector('[data-testid="browse-all-records"]')).toBeTruthy(); + without.unmount(); + const withKey = await mount({ type: 'lookup', reference: 'account', picker: 'search' }, { dataSource: dataSourceOf() }); + expect(withKey.row().querySelector('[data-testid="browse-all-records"]')).toBeNull(); + }); + + it('`subtitle` (user) adds the named record fields to each candidate of the search picker', async () => { + const people = [{ id: 'u1', name: 'Ann', email: 'ann@x.co', dept: 'Opsdept' }]; + const open = async (field: Row) => { + const m = await mount({ type: 'user', reference: 'sys_user', picker: 'search', ...field }, { dataSource: dataSourceOf(people) }); + await click(m.row().querySelector('[data-testid^="lookup-trigger"]')); + const text = document.body.textContent ?? ''; + m.unmount(); + return text; + }; + expect(await open({})).not.toContain('Opsdept'); + expect(await open({ subtitle: ['dept'] })).toContain('Opsdept'); + }); + + /** Open a lookup's browse-all dialog; its column headers and the queries it made. */ + const browseAll = async (field: Row) => { + const ds = dataSourceOf(); + const m = await mount({ type: 'lookup', reference: 'account', ...field }, { dataSource: ds }); + await click(m.row().querySelector('[data-testid="browse-all-records"]')); + const headers = [...document.querySelectorAll('[role=dialog] th')].map((h) => h.textContent ?? ''); + const pageSizes = queriesOf(ds).filter((q) => q.$skip === 0).map((q) => q.$top); + m.unmount(); + return { headers, pageSizes }; + }; + + it('`lookupPageSize` (lookup) is the browse-all dialog\'s page size', async () => { + expect((await browseAll({})).pageSizes).toEqual([10]); + expect((await browseAll({ lookupPageSize: 1 })).pageSizes).toEqual([1]); + }); + + it('`lookupColumns` (lookup) are the browse-all dialog\'s columns', async () => { + expect((await browseAll({})).headers).toEqual(['Name']); + expect((await browseAll({ lookupColumns: ['email'] })).headers).toEqual(['Email']); + }); +}); + +/* ── the two keys the card named: neither becomes the field's value ───── */ + +const toggle = (m: Awaited>) => m.row().querySelector('[role=checkbox],[role=switch]'); + +describe('`defaultChecked` on a form field is not honored: the box stays unchecked and submits false (objectui#12130)', () => { + it.each(['checkbox', 'switch', 'boolean'])('%s', async (type) => { + const control = await mount({ type }, { defaultValues: { f: true } }); + expect(toggle(control)?.getAttribute('aria-checked')).toBe('true'); + expect((await control.submit())?.f).toBe(true); + control.unmount(); + + const withKey = await mount({ type, defaultChecked: true }); + expect(toggle(withKey)?.getAttribute('aria-checked')).toBe('false'); + expect((await withKey.submit())?.f).toBe(false); + }); +}); + +describe('`defaultValue` on a form field never becomes the submitted value (objectui#12130)', () => { + const CASES: ReadonlyArray = [ + ['input', 'preset@x.co'], + ['text', 'preset@x.co'], + ['textarea', 'preset@x.co'], + ['email', 'preset@x.co'], + ['number', 42], + ['select', 'b'], + ['radio', 'b'], + ]; + const OPTIONS = [{ label: 'Alpha', value: 'a' }, { label: 'Beta', value: 'b' }]; + + it.each(CASES)('%s', async (type, value) => { + const field = { type, ...(type === 'select' || type === 'radio' ? { options: OPTIONS } : {}) }; + const control = await mount(field, { defaultValues: { f: value } }); + expect((await control.submit())?.f).toEqual(value); + control.unmount(); + + const withKey = await mount({ ...field, defaultValue: value }); + expect((await withKey.submit())?.f).toBeUndefined(); + }); +}); From 43dd3edef7ff8f8e9d1add3b9b58a19901103129 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:34:05 +0000 Subject: [PATCH 2/3] fix(types): declare the fifteen form-field keys the runtime census measures read and effective (objectui#12130) The census measured them read by name off a form `fields[]` entry, and the effects pins show each one changing what its widget does, while the strict authoring face told authors to remove them. Declared on FormFieldSchema and FormField: the spec's FieldSchema members by reference (`step`, `language`, `maxSize`, `allowCreate`, `descriptionField`, `displayField`, `lookupColumns`, `lookupFilters`, `lookupPageSize`), `idField` / `picker` / `subtitle` by reference to the field metadata types, and `capture`, `crop`, `defaultName` typed as their widgets read them. `defaultValue` and `defaultChecked` stay undeclared: no reader asks for either and neither becomes the field's value. The census ledger now holds the remaining read-but-undeclared keys, each with why its refusal stands or that the question is open. Claude-Session: https://claude.ai/code/session_01V8qYZseSLfKH3V8tPu2f73 Co-authored-by: Claude --- .../form-field-channel-census-12130.test.tsx | 102 ++++++--- .../form-field-channel-keys-12130.test.ts | 206 ++++++++++++++++++ .../__tests__/form-field-zod-coverage.test.ts | 20 ++ packages/types/src/form.ts | 97 ++++++++- packages/types/src/zod/form.zod.ts | 29 +++ 5 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 packages/types/src/__tests__/form-field-channel-keys-12130.test.ts diff --git a/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx b/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx index abf7beea36..6cd81e36e9 100644 --- a/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx +++ b/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx @@ -50,7 +50,11 @@ * The keys read and not declared on `FormFieldSchema` must be exactly the keys * of {@link LEDGER}, each with the readers recorded there. A widget that starts * reading a new undeclared key turns this file red, naming the key and its - * readers; so does a ledger row whose read is gone. + * readers; so does a ledger row whose read is gone. Each row says why the + * strict face's refusal of its key stands, or that the question is still open. + * {@link DECLARED_BY_12130} names, for each key this card declared, the field + * types whose widget reads it; on every other type the key is accepted and + * read by nothing. * * ## What it does not see (bounds, said per AGENTS.md #9) * @@ -284,24 +288,14 @@ const CURRENCY = 'entry@i18n/src/currency.ts'; * refusal is right. */ const LEDGER: Readonly> = { - // ── read, and the widget acts on it: the strict face's refusal is false ── - step: { readers: ['entry@fields/src/widgets/NumberField.tsx', 'entry@fields/src/widgets/SliderField.tsx'], why: 'READ AND EFFECTIVE, refused' }, - language: { readers: ['entry@fields/src/widgets/CodeField.tsx'], why: 'READ AND EFFECTIVE, refused' }, - maxSize: { readers: ['entry@fields/src/widgets/FileField.tsx', 'entry@fields/src/widgets/ImageField.tsx'], why: 'READ AND EFFECTIVE, refused' }, - capture: { readers: ['entry@fields/src/widgets/FileField.tsx'], why: 'READ AND EFFECTIVE, refused' }, - crop: { readers: ['entry@fields/src/widgets/ImageField.tsx'], why: 'READ AND EFFECTIVE, refused' }, - defaultName: { readers: ['entry@fields/src/widgets/AvatarField.tsx'], why: 'READ AND EFFECTIVE, refused' }, - allowCreate: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, - descriptionField: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, - displayField: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, - idField: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, - lookupColumns: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, - lookupFilters: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, - lookupPageSize: { readers: [LOOKUP], why: 'READ AND EFFECTIVE, refused' }, - picker: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, - subtitle: { readers: [LOOKUP, USER], why: 'READ AND EFFECTIVE, refused' }, - avatarField: { readers: [LOOKUP, USER], why: 'READ, refused' }, - avatar_field: { readers: [LOOKUP, USER], why: 'READ, refused' }, + // ── read, and still open: the refusal may be false ────────────────────── + avatarField: { + readers: [LOOKUP, USER], + why: 'OPEN — the avatar field of the search picker. The widgets read both spellings, `avatar_field` first; ' + + '`UserFieldMetadata` declares the snake_case one, the field keys of this face are camelCase (objectui#11610), ' + + 'and the spec declares neither. Which spelling to declare is a decision on objectui#12130.', + }, + avatar_field: { readers: [LOOKUP, USER], why: 'OPEN — the other spelling of `avatarField`, above.' }, className: { readers: [ 'props@fields/src/widgets/CheckboxesField.tsx', 'props@fields/src/widgets/CurrencyField.tsx', @@ -311,27 +305,64 @@ const LEDGER: Readonly> = { 'props@fields/src/widgets/RatingField.tsx', 'props@fields/src/widgets/RecipientPickerField.tsx', 'props@fields/src/widgets/TagsField.tsx', ], - why: 'READ, refused', + why: 'OPEN — read off props by these widgets and forwarded to the control by the rest; on a built-in branch it ' + + 'replaces the branch\'s own classes. Whether a field entry declares it, and which element it styles, is a ' + + 'decision on objectui#12130.', + }, + // ── second spellings of a declared key: refused (AGENTS.md #0.1) ──────── + allow_create: { readers: [LOOKUP], why: 'ALIAS of the spec\'s `allowCreate`, which this face declares; the widget reads it first.' }, + lookup_columns: { readers: [LOOKUP], why: 'ALIAS of the spec\'s `lookupColumns`, which this face declares; the widget reads it first.' }, + lookup_page_size: { readers: [LOOKUP], why: 'ALIAS of the spec\'s `lookupPageSize`, which this face declares; the widget reads it first.' }, + reference_field: { readers: [LOOKUP, USER], why: 'ALIAS — the legacy fallback of `displayField`, which this face declares.' }, + defaultCurrency: { + readers: [CURRENCY], + why: 'ALIAS — the pre-spec top-level spelling of `currencyConfig.defaultCurrency`, which `resolveFieldCurrency` still falls back to.', }, - // ── second spellings of a key the spec or this face spells otherwise ──── - allow_create: { readers: [LOOKUP], why: 'READ, refused' }, - lookup_columns: { readers: [LOOKUP], why: 'READ, refused' }, - lookup_page_size: { readers: [LOOKUP], why: 'READ, refused' }, - reference_field: { readers: [LOOKUP, USER], why: 'READ, refused' }, - defaultCurrency: { readers: [CURRENCY], why: 'READ, refused' }, - // ── runtime values no document can carry, and host-stamped keys ───────── - dataSource: { readers: [LOOKUP], why: 'READ, refused' }, - onCreateNew: { readers: [LOOKUP], why: 'READ, refused' }, + // ── runtime values no document can carry ──────────────────────────────── + dataSource: { readers: [LOOKUP], why: 'RUNTIME SLOT — a data-source adapter instance; the form hands the widget its own.' }, + onCreateNew: { readers: [LOOKUP], why: 'RUNTIME SLOT — a host-supplied function; JSON has no function value (the objectui#6124 shape).' }, onUploadingChange: { readers: ['props@fields/src/widgets/AvatarField.tsx', 'props@fields/src/widgets/FileField.tsx', 'props@fields/src/widgets/ImageField.tsx'], - why: 'READ, refused', + why: 'RUNTIME SLOT — a host-supplied function; JSON has no function value (the objectui#6124 shape).', }, + // ── runtime-only, by an existing ruling ───────────────────────────────── field: { readers: ['entry@components/src/renderers/form/form.tsx', 'entry@core/src/validation/server-owned-value.ts', LOOKUP, USER], - why: 'READ, refused', + why: 'RUNTIME-ONLY (objectui#6609) — the resolved object-field metadata stash; in the authored vocabulary the same ' + + 'key names an object field (objectui#3090).', + }, + mobile_fullscreen: { + readers: ['entry@fields/src/widgets/RichTextField.tsx'], + why: 'RUNTIME-ONLY (objectui#3245) — stamped by `ObjectForm` from `ObjectFormSchema.mobile.fullscreenLongText`.', }, - mobile_fullscreen: { readers: ['entry@fields/src/widgets/RichTextField.tsx'], why: 'READ, refused' }, - currency: { readers: [CURRENCY], why: 'READ, refused' }, + currency: { + readers: [CURRENCY], + why: 'RUNTIME-ONLY (objectui#11070 round 13) — the spec\'s `FieldSchema` refuses it; `currencyConfig` is the declared spelling.', + }, +}; + +/** + * The keys objectui#12130 declared on `FormFieldSchema`, each with the field + * types whose widget reads it. The face is flat, so each key is accepted on + * every other type too, where nothing reads it: this map is that list, + * re-derived on every run. + */ +const DECLARED_BY_12130: Readonly> = { + step: ['number', 'slider'], + language: ['code'], + maxSize: ['file', 'image'], + capture: ['file'], + crop: ['image'], + defaultName: ['avatar'], + allowCreate: ['lookup', 'master_detail'], + descriptionField: ['lookup', 'master_detail'], + displayField: ['lookup', 'master_detail', 'user'], + idField: ['lookup', 'master_detail'], + lookupColumns: ['lookup', 'master_detail'], + lookupFilters: ['lookup', 'master_detail', 'user'], + lookupPageSize: ['lookup', 'master_detail'], + picker: ['lookup', 'master_detail', 'user'], + subtitle: ['lookup', 'master_detail', 'user'], }; /* ── the census ──────────────────────────────────────────────────────────── */ @@ -375,5 +406,10 @@ describe('every key the channel reads is declared on FormFieldSchema, or ledgere it.each(Object.entries(LEDGER))('ledger row `%s` names exactly the readers the census records', (key, row) => { expect(readers(key)).toEqual([...row.readers].sort()); }); + + it.each(Object.entries(DECLARED_BY_12130))('`%s` is declared, and read only by the types named for it', (key, types) => { + expect(declared.has(key)).toBe(true); + expect([...(census.get(key)?.types ?? [])].sort()).toEqual([...types].sort()); + }); }); diff --git a/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts b/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts new file mode 100644 index 0000000000..630f77302c --- /dev/null +++ b/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts @@ -0,0 +1,206 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The form-field keys objectui#12130 measured, on the authoring faces. + * + * The runtime half lives in `examples/schema-catalog/test/` (this package is + * "Zero deps. No React", so it cannot render): the census there records every + * key of a `form` `fields[]` entry that a reader asks for by name, and the + * effects file beside it renders each one with and without. Fifteen keys were + * read, changed what their widget does, and were refused by the strict face; + * this card declares them on `FormFieldSchema` and `FormField`. This file pins + * the faces: + * + * 1. each declared key parses on the strict face, and a misspelled sibling + * is still refused by name at the same path; + * 2. each is judged by its declared type on both faces, and the tolerant + * face keeps the value instead of stripping it; + * 3. the keys measured NOT to work stay refused by name on the strict face: + * `defaultValue` and `defaultChecked`, which no reader asks for and + * neither of which becomes the field's value, and the snake_case second + * spellings of four declared keys; + * 4. the TypeScript face carries each spec member and each field metadata + * member by reference (type-level, read by `tsc -p tsconfig.test.json` + * only — vitest does not typecheck). + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import type { FormField } from '../form.js'; +import type { LookupFieldMetadata, UserFieldMetadata } from '../field-types.js'; +import type { Field as SpecField } from '@objectstack/spec/data'; +import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; + +type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; + +const issuesOf = (schema: z.ZodType, doc: unknown): Issue[] | null => { + const result = schema.safeParse(doc); + return result.success ? null : (result.error.issues as unknown as Issue[]); +}; + +/** Every `unrecognized_keys` finding in the issue tree, union arms included, as `path.key`. */ +const undeclared = (issues: Issue[] | null, prefix: PropertyKey[] = []): string[] => { + const out: string[] = []; + for (const issue of issues ?? []) { + const at = [...prefix, ...issue.path]; + if (issue.code === 'unrecognized_keys') for (const k of issue.keys ?? []) out.push([...at, k].map(String).join('.')); + for (const arm of issue.errors ?? []) out.push(...undeclared(arm, at)); + } + return [...new Set(out)]; +}; + +const form = (field: Record) => ({ type: 'form', fields: [{ name: 'f', label: 'F', ...field }] }); + +/** Each declared key on an entry of a type whose widget reads it (the census names those types). */ +const DECLARED: ReadonlyArray]> = [ + ['step', { type: 'number', step: 5 }], + ['language', { type: 'code', language: 'python' }], + ['maxSize', { type: 'file', maxSize: 1048576 }], + ['capture', { type: 'file', capture: 'environment' }], + ['crop', { type: 'image', crop: false }], + ['defaultName', { type: 'avatar', defaultName: 'Ada Lovelace' }], + ['allowCreate', { type: 'lookup', reference: 'account', allowCreate: false }], + ['descriptionField', { type: 'lookup', reference: 'account', descriptionField: 'industry' }], + ['displayField', { type: 'lookup', reference: 'account', displayField: 'title' }], + ['idField', { type: 'lookup', reference: 'position', idField: 'name' }], + ['lookupColumns', { type: 'lookup', reference: 'account', lookupColumns: ['name', { field: 'email', label: 'Email' }] }], + ['lookupFilters', { type: 'lookup', reference: 'account', lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }] }], + ['lookupPageSize', { type: 'lookup', reference: 'account', lookupPageSize: 25 }], + ['picker', { type: 'user', reference: 'sys_user', picker: 'search' }], + ['subtitle', { type: 'user', reference: 'sys_user', picker: 'search', subtitle: ['email'] }], +]; + +/* ── 1. accepted on the strict face; a misspelling beside it is not ─────── */ + +describe('the field keys objectui#12130 measured parse on the strict face', () => { + it.each(DECLARED)('`fields[].%s` parses; a misspelled sibling is refused at the field', (key, field) => { + expect(issuesOf(StrictAnyComponentSchema, form(field))).toBeNull(); + expect(undeclared(issuesOf(StrictAnyComponentSchema, form({ ...field, [`${key}x`]: 1 })))) + .toEqual([`fields.0.${key}x`]); + }); +}); + +/* ── 2. judged by the declared type, kept by the tolerant face ──────────── */ + +describe('each declared key is judged by its declared type (objectui#12130)', () => { + const fieldsOf = (doc: unknown) => { + const parsed = AnyComponentSchema.safeParse(doc); + expect(parsed.success).toBe(true); + return (parsed.data as { fields: Record[] }).fields; + }; + + it.each(DECLARED)('the tolerant face keeps `fields[].%s` instead of stripping it', (key, field) => { + expect(fieldsOf(form(field))[0][key]).toEqual(field[key]); + }); + + const WRONG: ReadonlyArray]> = [ + ['step', { type: 'number', step: 'five' }], + ['language', { type: 'code', language: 3 }], + ['maxSize', { type: 'file', maxSize: 0 }], + ['capture', { type: 'file', capture: 'front' }], + ['crop', { type: 'image', crop: 'no' }], + ['defaultName', { type: 'avatar', defaultName: 7 }], + ['allowCreate', { type: 'lookup', allowCreate: 'yes' }], + ['descriptionField', { type: 'lookup', descriptionField: ['industry'] }], + ['displayField', { type: 'lookup', displayField: { field: 'title' } }], + ['idField', { type: 'lookup', idField: 1 }], + ['lookupColumns', { type: 'lookup', lookupColumns: [{ label: 'no field' }] }], + ['lookupFilters', { type: 'lookup', lookupFilters: [{ field: 'status', operator: 'equals', value: 'active' }] }], + ['lookupPageSize', { type: 'lookup', lookupPageSize: -10 }], + ['picker', { type: 'user', picker: 'table' }], + ['subtitle', { type: 'user', subtitle: 'email' }], + ]; + + it.each(WRONG)('a wrong `fields[].%s` value is refused at that key, on both faces', (key, field) => { + for (const schema of [AnyComponentSchema, StrictAnyComponentSchema]) { + const paths = (issuesOf(schema, form(field)) ?? []).map((i) => i.path.map(String).join('.')); + expect(paths.some((p) => p.startsWith(`fields.0.${key}`)), `${key} on ${schema === AnyComponentSchema ? 'tolerant' : 'strict'}`) + .toBe(true); + } + }); +}); + +/* ── 3. the keys measured not to work stay refused ──────────────────────── */ + +describe('the keys objectui#12130 measured not to work stay refused by name (objectui#12130)', () => { + // No reader asks for either key, and neither becomes the field's value: the + // box stays unchecked, the payload carries no value (the effects pins). + // A form's initial values are `FormSchema.defaultValues`. + const NOT_HONORED: ReadonlyArray]> = [ + ['defaultChecked', { type: 'checkbox', defaultChecked: true }], + ['defaultChecked', { type: 'switch', defaultChecked: true }], + ['defaultValue', { type: 'input', defaultValue: 'preset' }], + ['defaultValue', { type: 'textarea', defaultValue: 'preset' }], + ['defaultValue', { type: 'text', defaultValue: 'preset' }], + ['defaultValue', { type: 'select', options: [{ label: 'B', value: 'b' }], defaultValue: 'b' }], + ]; + + it.each(NOT_HONORED)('`fields[].%s` is refused by name (%j)', (key, field) => { + expect(undeclared(issuesOf(StrictAnyComponentSchema, form(field)))).toEqual([`fields.0.${key}`]); + }); + + it('CONTROL: the same values seeded through `defaultValues` parse', () => { + expect(issuesOf(StrictAnyComponentSchema, { + ...form({ type: 'checkbox' }), + defaultValues: { f: true }, + })).toBeNull(); + }); + + // The snake_case spellings the `lookup` widget still reads before the spec's + // camelCase keys this card declares (the census ledger records the reads). + const SECOND_SPELLINGS: ReadonlyArray]> = [ + ['allow_create', { type: 'lookup', allow_create: false }], + ['lookup_columns', { type: 'lookup', lookup_columns: ['name'] }], + ['lookup_page_size', { type: 'lookup', lookup_page_size: 25 }], + ['reference_field', { type: 'lookup', reference_field: 'title' }], + ]; + + it.each(SECOND_SPELLINGS)('the second spelling `fields[].%s` is refused by name', (key, field) => { + expect(undeclared(issuesOf(StrictAnyComponentSchema, form(field)))).toEqual([`fields.0.${key}`]); + }); +}); + +/* ── 4. type level: the TypeScript face carries the members by reference ── */ + +type Expect = T; +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +/** Each member is a named, typed member — not the index signature's `any`. */ +export type assertionChannelMembersAreTyped = Expect, + false +>>; + +/** The spec's `FieldSchema` members, by reference. */ +export type assertionSpecMembersByReference = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** The field metadata types' members, by reference; the rest as their widgets read them. */ +export type assertionLocalMembers = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index 1d041c943d..a89fa57d9c 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -112,6 +112,26 @@ const DECLARED_KEYS = [ 'totalField', 'addLabel', 'sortField', + // objectui#12130 — the rest of the field metadata the channel reads off the + // entry, measured at runtime (`form-field-channel-census-12130.test.tsx` in + // `examples/schema-catalog/test/`). The spec's `FieldSchema` members by + // reference, plus `idField` / `picker` / `subtitle` from the field metadata + // types and `capture` / `crop` / `defaultName` as their widgets read them. + 'step', + 'language', + 'maxSize', + 'capture', + 'crop', + 'defaultName', + 'allowCreate', + 'descriptionField', + 'displayField', + 'idField', + 'lookupColumns', + 'lookupFilters', + 'lookupPageSize', + 'picker', + 'subtitle', ]; /** diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index fa8109eef3..1d389cbcf7 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -17,7 +17,7 @@ import type { BaseSchema, SchemaNode } from './base.js'; import type { SelectOptionBase } from './select-option.js'; -import type { GridFieldMetadata } from './field-types.js'; +import type { GridFieldMetadata, LookupFieldMetadata, UserFieldMetadata } from './field-types.js'; // objectui#11070 — the field-metadata members `FormField` declares by // reference to the spec's `FieldSchema`. Type-only: no runtime edge. import type { Field as SpecField } from '@objectstack/spec/data'; @@ -2181,6 +2181,101 @@ export interface FormField { * @deprecated Write `sortField`. Nothing reads this spelling. */ sort_field?: never; + + // ── The rest of the entry's field metadata (objectui#12130) ───────────── + // + // objectui#11070 graded the channel above from a source read, and missed + // keys on it. objectui#12130 measured it at runtime through the real + // `SchemaRenderer`: `examples/schema-catalog/test/form-field-channel-census-12130.test.tsx` + // records every key of the entry a reader asks for by name, and + // `form-field-channel-effects-12130.test.tsx` beside it renders each one + // with and without. Every member below is read by the widget its note names + // and changes what that widget does; on any other field type it is accepted + // and read by nothing. The spec's `FieldSchema` members are typed by + // reference, `idField` / `picker` / `subtitle` are the field metadata + // types' members by reference, and `capture`, `crop` and `defaultName`, + // which no type declared, are typed as the widget reads them. + // + // ⛔ Not declared: `defaultValue` and `defaultChecked`. No reader asks for + // either, and neither becomes the field's value (the effects file measures + // both). A form's initial values are {@link FormSchema.defaultValues}. + + /** + * Increment of a numeric field: the `number` widget puts it on its input as + * `step`, and the `slider` widget reads it as its step too. + */ + step?: SpecField['step']; + /** + * Language of a `code` field, e.g. `python`. The `code` widget's editor + * asks for code in it (default `javascript`). + */ + language?: SpecField['language']; + /** + * Largest file a `file` or `image` field accepts, in bytes. The widget + * refuses a larger pick before any upload starts. + */ + maxSize?: SpecField['maxSize']; + /** + * Camera capture mode of a `file` field: `'environment'` or `'user'` adds a + * camera input in that mode, `false` never offers one. Without it, the + * widget offers the rear camera on a touch device when the field accepts + * images. Typed as the `file` widget reads it. + */ + capture?: 'environment' | 'user' | false; + /** + * `false` turns off inline cropping on an `image` field: the crop action + * is not drawn on the field's images. Cropping is on unless this is `false`. + */ + crop?: boolean; + /** + * The name an empty `avatar` field draws its initials from (default: the + * field's `label`). + */ + defaultName?: string; + /** + * Whether a `lookup` field offers to create the referenced record from an + * empty result. On by default for a user-facing reference; `false` turns + * the offer off. + */ + allowCreate?: SpecField['allowCreate']; + /** + * Record field shown under each candidate's label in a `lookup` field's + * quick-select. + */ + descriptionField?: SpecField['descriptionField']; + /** + * Record field each candidate of a `lookup` or `user` field is labelled + * by, in the quick-select and the browse-all dialog. + */ + displayField?: SpecField['displayField']; + /** + * Record field a `lookup` field's pick commits as the value (default `id`): + * {@link LookupFieldMetadata.idField}. + */ + idField?: LookupFieldMetadata['idField']; + /** + * Columns of a `lookup` field's browse-all dialog. + */ + lookupColumns?: SpecField['lookupColumns']; + /** + * Base filters of every candidate query of a `lookup` or `user` field. + */ + lookupFilters?: SpecField['lookupFilters']; + /** + * Page size of a `lookup` field's browse-all dialog (default 10). + */ + lookupPageSize?: SpecField['lookupPageSize']; + /** + * Picker of a `lookup` or `user` field: `'search'` is the search-first + * people picker, which replaces the quick-select and the browse-all dialog: + * {@link UserFieldMetadata.picker}. + */ + picker?: UserFieldMetadata['picker']; + /** + * Record fields shown beside each candidate of the search picker + * (`picker: 'search'`): {@link UserFieldMetadata.subtitle}. + */ + subtitle?: UserFieldMetadata['subtitle']; } /** diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index a1b0b511d4..b898e71e66 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -1159,6 +1159,35 @@ export const FormFieldSchema = z.object({ total_field: retiredGridFieldKey('total_field'), add_label: retiredGridFieldKey('add_label'), sort_field: retiredGridFieldKey('sort_field'), + // objectui#12130 — the rest of the field metadata the channel reads off the + // entry, measured at runtime (the census and effects pins under + // `examples/schema-catalog/test/`). The spec's `FieldSchema` members by + // reference; `idField` / `picker` / `subtitle` stated once from the field + // metadata types' members the TS twin carries by reference; `capture`, + // `crop` and `defaultName` typed as the widget reads them. `defaultValue` + // and `defaultChecked` stay undeclared: no reader asks for either, and + // neither becomes the field's value (`FormSchema.defaultValues` does). + step: stripImportedDefaults(SpecFieldSchema).shape.step, + language: stripImportedDefaults(SpecFieldSchema).shape.language, + maxSize: stripImportedDefaults(SpecFieldSchema).shape.maxSize, + capture: z.union([z.enum(['environment', 'user']), z.literal(false)]).optional() + .describe('Camera capture mode of a `file` field; `false` never offers a camera input; read only by the `file` widget'), + crop: z.boolean().optional() + .describe('`false` turns off inline cropping on an `image` field; read only by the `image` widget'), + defaultName: z.string().optional() + .describe('Name an empty `avatar` field draws its initials from; read only by the `avatar` widget'), + allowCreate: stripImportedDefaults(SpecFieldSchema).shape.allowCreate, + descriptionField: stripImportedDefaults(SpecFieldSchema).shape.descriptionField, + displayField: stripImportedDefaults(SpecFieldSchema).shape.displayField, + idField: z.string().optional() + .describe('Record field a `lookup` pick commits as the value (default `id`); read by the `lookup` widget'), + lookupColumns: stripImportedDefaults(SpecFieldSchema).shape.lookupColumns, + lookupFilters: stripImportedDefaults(SpecFieldSchema).shape.lookupFilters, + lookupPageSize: stripImportedDefaults(SpecFieldSchema).shape.lookupPageSize, + picker: z.enum(['search', 'default']).optional() + .describe('Picker of a `lookup` / `user` field: `search` is the search-first people picker; read by the `lookup` and `user` widgets'), + subtitle: z.array(z.string()).optional() + .describe('Record fields shown beside each candidate of the search picker; read by the `lookup` and `user` widgets'), }).superRefine((field, ctx) => { // objectui#11070 round 13 — the spec's one cross-key rule on `scale`; see // CURRENCY_FIELD_SCALE_REFUSAL. From 9a81311ce12c81a623f4edf4d26d202009d35ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:49:44 +0000 Subject: [PATCH 3/3] fix(types): leave `step` open; the login-form example and the form doc stop teaching keys the census measures inert (objectui#12130) `ObjectForm` writes `step: 'any'` onto the form fields it builds, a value the spec's numeric `FieldSchema.step` refuses, so declaring `step` by reference breaks that producer's build. It stays undeclared and moves to the census ledger as open, with the effect it has recorded beside it. The `login-form` example drops `defaultChecked: false`, and the form doc drops the field-level `defaultValue`: no reader asks for either key, and neither becomes the field's value. A form's initial values are `FormSchema.defaultValues`. One changeset for `@object-ui/types`. Claude-Session: https://claude.ai/code/session_01V8qYZseSLfKH3V8tPu2f73 Co-authored-by: Claude --- .changeset/12130-form-field-channel-keys.md | 19 +++++++++++++++++++ content/docs/components/form/form.mdx | 5 +---- .../form-field-channel-census-12130.test.tsx | 7 ++++++- packages/types/examples/login-form.ts | 3 +-- .../form-field-channel-keys-12130.test.ts | 13 +++++-------- .../__tests__/form-field-zod-coverage.test.ts | 1 - packages/types/src/form.ts | 10 +++++----- packages/types/src/zod/form.zod.ts | 2 +- 8 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 .changeset/12130-form-field-channel-keys.md diff --git a/.changeset/12130-form-field-channel-keys.md b/.changeset/12130-form-field-channel-keys.md new file mode 100644 index 0000000000..79afad93ec --- /dev/null +++ b/.changeset/12130-form-field-channel-keys.md @@ -0,0 +1,19 @@ +--- +'@object-ui/types': minor +--- + +A form field declares the rest of the field metadata its widget reads off the `fields[]` entry, on both faces, so the strict authoring face stops telling authors to remove keys that work (objectui#12130). + +A hand-authored `form` hands each field widget the `fields[]` entry itself as its metadata carrier. objectui#11070 graded that channel from a source read and missed keys on it. This change measures it at runtime through the real `SchemaRenderer`: a census records every key of the entry a reader asks for by name, and each key it found read and undeclared is rendered with and without, to show the widget acting on it. The census is a test (`form-field-channel-census-12130.test.tsx` in `examples/schema-catalog/test/`), so a key a widget starts reading without a declaration turns it red. + +- **Declared, by reference to `@objectstack/spec`'s `FieldSchema`:** `language` (the `code` widget), `maxSize` (`file`, `image`), and the lookup picker's `allowCreate`, `descriptionField`, `displayField`, `lookupColumns`, `lookupFilters` and `lookupPageSize` (`lookup`, `master_detail`, and for `displayField` / `lookupFilters` the `user` widget too). Each is the spec member itself, value checks included. +- **Declared, by reference to the field metadata types:** `idField` (`LookupFieldMetadata`), `picker` and `subtitle` (`UserFieldMetadata`), read by the `lookup` and `user` widgets. +- **Declared as the widget reads them, since no type declared them:** `capture` (`'environment' | 'user' | false`, the `file` widget's camera input), `crop` (`boolean`, `false` turns off the `image` widget's cropping) and `defaultName` (`string`, the name an empty `avatar` draws its initials from). +- **TypeScript.** `FormField` gains each member, typed as above. Each used to fall to the index signature's `any`, so a typed literal with a value of the wrong type is now a compile error. +- **zod (`@object-ui/types/zod`).** WIDENS on the strict authoring face (`StrictAnyComponentSchema`): each key used to be refused with `unrecognized_keys`, and now parses. NARROWS on the tolerant face (`safeValidateSchema`), where each key used to be stripped unjudged: a value of the wrong type (a `maxSize` that is not a positive whole number, a `lookupFilters` operator the spec does not list, a `picker` other than `search` / `default`) is now refused, and the values are now kept. The face is flat: each key is accepted on every field type, and only the widgets named above read it. +- **Not declared: `defaultValue` and `defaultChecked` on a form field.** No reader asks for either, and neither becomes the field's value on any type the effects pins measure, with or without `@object-ui/fields` registered: a checkbox stays unchecked and submits `false`, and the submitted payload carries no value. The strict face keeps refusing both by name. A form's initial values are `FormSchema.defaultValues`, keyed by field name. `content/docs/components/form/form.mdx` no longer lists a field-level `defaultValue`, and the `login-form` example in this package drops its `defaultChecked: false`. +- **Still refused, by name:** the snake_case second spellings the `lookup` widget still reads before the spec's keys above (`allow_create`, `lookup_columns`, `lookup_page_size`, and `reference_field` for `displayField`). Still undeclared, and listed as open in the census: `step`, which the `number` and `slider` widgets read but `ObjectForm` writes as `'any'`, a value the spec's numeric `step` refuses; `avatarField` / `avatar_field`; and `className`. + +BREAKING (`@object-ui/types`), for a document whose form field carries one of the declared keys with a value of the wrong type: it no longer validates on the tolerant face. Fix the value. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) + +**Clause-②: yes (widening)**: each declared key widens the strict face's accept set, and its value check narrows the tolerant face. diff --git a/content/docs/components/form/form.mdx b/content/docs/components/form/form.mdx index da3fc03c16..cc0145d677 100644 --- a/content/docs/components/form/form.mdx +++ b/content/docs/components/form/form.mdx @@ -33,15 +33,12 @@ interface FormField { // Options (for select, radio-group) options?: Array<{ value: string; label: string }>; - - // Default value - defaultValue?: any; } interface FormSchema { type: 'form'; fields: FormField[]; // Form fields - defaultValues?: Record; // Initial values, keyed by field name + defaultValues?: Record; // Initial values, keyed by field name (a field has no default key of its own) submitLabel?: string; // Submit button text (default 'Submit') // Events diff --git a/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx b/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx index 6cd81e36e9..6c1ea24b16 100644 --- a/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx +++ b/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx @@ -289,6 +289,12 @@ const CURRENCY = 'entry@i18n/src/currency.ts'; */ const LEDGER: Readonly> = { // ── read, and still open: the refusal may be false ────────────────────── + step: { + readers: ['entry@fields/src/widgets/NumberField.tsx', 'entry@fields/src/widgets/SliderField.tsx'], + why: 'OPEN — read and effective (the effects pins), but `ObjectForm` writes `step: \'any\'` onto the entries it ' + + 'builds, which the spec\'s numeric `FieldSchema.step` refuses. Declaring it by reference breaks that producer; ' + + 'declaring `number | \'any\'` departs from the spec. A decision on objectui#12130.', + }, avatarField: { readers: [LOOKUP, USER], why: 'OPEN — the avatar field of the search picker. The widgets read both spellings, `avatar_field` first; ' @@ -348,7 +354,6 @@ const LEDGER: Readonly> = { * re-derived on every run. */ const DECLARED_BY_12130: Readonly> = { - step: ['number', 'slider'], language: ['code'], maxSize: ['file', 'image'], capture: ['file'], diff --git a/packages/types/examples/login-form.ts b/packages/types/examples/login-form.ts index a34f1c7736..1cabb747c5 100644 --- a/packages/types/examples/login-form.ts +++ b/packages/types/examples/login-form.ts @@ -53,8 +53,7 @@ export const loginFormSchema: FormSchema = { { name: 'remember', type: 'checkbox', - label: 'Remember me', - defaultChecked: false + label: 'Remember me' } ], diff --git a/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts b/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts index 630f77302c..0168942bdf 100644 --- a/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts +++ b/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts @@ -12,10 +12,10 @@ * The runtime half lives in `examples/schema-catalog/test/` (this package is * "Zero deps. No React", so it cannot render): the census there records every * key of a `form` `fields[]` entry that a reader asks for by name, and the - * effects file beside it renders each one with and without. Fifteen keys were - * read, changed what their widget does, and were refused by the strict face; - * this card declares them on `FormFieldSchema` and `FormField`. This file pins - * the faces: + * effects file beside it renders each one with and without. The keys this + * card declares on `FormFieldSchema` and `FormField` are the ones that were + * read, changed what their widget does, and were refused by the strict face. + * This file pins the faces: * * 1. each declared key parses on the strict face, and a misspelled sibling * is still refused by name at the same path; @@ -60,7 +60,6 @@ const form = (field: Record) => ({ type: 'form', fields: [{ nam /** Each declared key on an entry of a type whose widget reads it (the census names those types). */ const DECLARED: ReadonlyArray]> = [ - ['step', { type: 'number', step: 5 }], ['language', { type: 'code', language: 'python' }], ['maxSize', { type: 'file', maxSize: 1048576 }], ['capture', { type: 'file', capture: 'environment' }], @@ -101,7 +100,6 @@ describe('each declared key is judged by its declared type (objectui#12130)', () }); const WRONG: ReadonlyArray]> = [ - ['step', { type: 'number', step: 'five' }], ['language', { type: 'code', language: 3 }], ['maxSize', { type: 'file', maxSize: 0 }], ['capture', { type: 'file', capture: 'front' }], @@ -175,7 +173,7 @@ type IsAny = 0 extends 1 & T ? true : false; /** Each member is a named, typed member — not the index signature's `any`. */ export type assertionChannelMembersAreTyped = Expect, @@ -184,7 +182,6 @@ export type assertionChannelMembersAreTyped = Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index a89fa57d9c..5a958219cf 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -117,7 +117,6 @@ const DECLARED_KEYS = [ // `examples/schema-catalog/test/`). The spec's `FieldSchema` members by // reference, plus `idField` / `picker` / `subtitle` from the field metadata // types and `capture` / `crop` / `defaultName` as their widgets read them. - 'step', 'language', 'maxSize', 'capture', diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 1d389cbcf7..44f7d94e36 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -2199,12 +2199,12 @@ export interface FormField { // ⛔ Not declared: `defaultValue` and `defaultChecked`. No reader asks for // either, and neither becomes the field's value (the effects file measures // both). A form's initial values are {@link FormSchema.defaultValues}. + // + // ⛔ Not declared either, and open on objectui#12130: `step`. The `number` + // and `slider` widgets read it, but `ObjectForm` (`@object-ui/plugin-form`) + // writes `step: 'any'` onto the entries it builds, a value the spec's + // numeric `FieldSchema.step` refuses. The census ledger carries it. - /** - * Increment of a numeric field: the `number` widget puts it on its input as - * `step`, and the `slider` widget reads it as its step too. - */ - step?: SpecField['step']; /** * Language of a `code` field, e.g. `python`. The `code` widget's editor * asks for code in it (default `javascript`). diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index b898e71e66..753186f657 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -1167,7 +1167,7 @@ export const FormFieldSchema = z.object({ // `crop` and `defaultName` typed as the widget reads them. `defaultValue` // and `defaultChecked` stay undeclared: no reader asks for either, and // neither becomes the field's value (`FormSchema.defaultValues` does). - step: stripImportedDefaults(SpecFieldSchema).shape.step, + // `step` stays undeclared too, open: see the TS twin. language: stripImportedDefaults(SpecFieldSchema).shape.language, maxSize: stripImportedDefaults(SpecFieldSchema).shape.maxSize, capture: z.union([z.enum(['environment', 'user']), z.literal(false)]).optional()