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 new file mode 100644 index 0000000000..6c1ea24b16 --- /dev/null +++ b/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx @@ -0,0 +1,420 @@ +/** + * 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. 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) + * + * - 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 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; ' + + '`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', + '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: '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.', + }, + // ── 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: '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: '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`.', + }, + 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> = { + 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 ──────────────────────────────────────────────────────────── */ + +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()); + }); + + 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/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(); + }); +}); 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 new file mode 100644 index 0000000000..0168942bdf --- /dev/null +++ b/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts @@ -0,0 +1,203 @@ +/** + * 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. 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; + * 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]> = [ + ['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]> = [ + ['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>, +]; + +/** 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..5a958219cf 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,25 @@ 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. + '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..44f7d94e36 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}. + // + // ⛔ 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. + + /** + * 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..753186f657 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` 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() + .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.