diff --git a/.changeset/12130-avatar-field.md b/.changeset/12130-avatar-field.md new file mode 100644 index 0000000000..0ec7469964 --- /dev/null +++ b/.changeset/12130-avatar-field.md @@ -0,0 +1,16 @@ +--- +'@object-ui/types': minor +--- + +A form field declares `avatarField`, the record field each candidate of the search picker draws its avatar from, on both faces, so the strict authoring face stops telling authors to remove a key that works (objectui#12130). + +The `lookup` and `user` field widgets (and `master_detail`, which renders through the `lookup` widget) read `avatarField` off a `form`'s `fields[]` entry. With `picker: 'search'`, each candidate row draws its avatar from that record field instead of `image`. This is measured through the real `SchemaRenderer`, with and without the key, in `form-field-channel-effects-12130.test.tsx` in `examples/schema-catalog/test/`. The census beside it now lists `avatarField` as declared and read by those three types. + +- **Declared as the widgets read it:** `avatarField` (`string`, default `image`). The spec's `FieldSchema` declares neither spelling, so this is the face's own declaration, typed like `defaultName`. +- **TypeScript.** `FormField` gains the member. It used to fall to the index signature's `any`, so a typed literal with a non-string value is now a compile error. +- **zod (`@object-ui/types/zod`).** WIDENS on the strict authoring face (`StrictAnyComponentSchema`): `avatarField` used to be refused with `unrecognized_keys`, and now parses. NARROWS on the tolerant face (`safeValidateSchema`), where the key used to be stripped unjudged: a non-string value is now refused, and a string is now kept. The face is flat: the key is accepted on every field type, and only the widgets named above read it. +- **Still refused, by name:** `avatar_field`, the snake_case spelling the widgets read first. The face declares one spelling, camelCase like its other field keys, and the census ledger now lists `avatar_field` with the other second spellings (`allow_create`, `lookup_columns`, `lookup_page_size`, `reference_field`). `UserFieldMetadata` still declares `avatar_field`; that spelling's retirement is objectui#12157's. + +BREAKING (`@object-ui/types`), for a document whose form field carries `avatarField` with a non-string value: 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)**: the declared key widens the strict face's accept set, and its value check narrows the tolerant face. diff --git a/.changeset/12130-form-field-channel-keys.md b/.changeset/12130-form-field-channel-keys.md index 79afad93ec..ce65ce235e 100644 --- a/.changeset/12130-form-field-channel-keys.md +++ b/.changeset/12130-form-field-channel-keys.md @@ -12,7 +12,7 @@ A hand-authored `form` hands each field widget the `fields[]` entry itself as it - **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`. +- **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; 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.) 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 6c1ea24b16..b0c9e15d73 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 @@ -295,13 +295,6 @@ const LEDGER: Readonly> = { + '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', @@ -320,6 +313,10 @@ const LEDGER: Readonly> = { 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.' }, + avatar_field: { + readers: [LOOKUP, USER], + why: 'ALIAS of `avatarField`, which this face declares camelCase only (objectui#12130 round 2); the widgets read it first.', + }, defaultCurrency: { readers: [CURRENCY], why: 'ALIAS — the pre-spec top-level spelling of `currencyConfig.defaultCurrency`, which `resolveFieldCurrency` still falls back to.', @@ -368,6 +365,7 @@ const DECLARED_BY_12130: Readonly> = { lookupPageSize: ['lookup', 'master_detail'], picker: ['lookup', 'master_detail', 'user'], subtitle: ['lookup', 'master_detail', 'user'], + avatarField: ['lookup', 'master_detail', 'user'], }; /* ── the census ──────────────────────────────────────────────────────────── */ 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 index 01cf35e738..fd45fb8bc1 100644 --- a/examples/schema-catalog/test/form-field-channel-effects-12130.test.tsx +++ b/examples/schema-catalog/test/form-field-channel-effects-12130.test.tsx @@ -245,6 +245,44 @@ describe('a read field key changes what its widget does (objectui#12130)', () => expect(await open({ subtitle: ['dept'] })).toContain('Opsdept'); }); + /** + * Reports every image as loaded. happy-dom never loads one, so Radix's + * `AvatarImage` draws no `img` whether or not a URL reached it; with this in + * place an `img` is drawn exactly when an avatar URL reached the Avatar (the + * technique of `PeoplePicker.outputFls-10433.test.tsx` in `@object-ui/fields`). + */ + class LoadedImage { + complete = true; + naturalWidth = 1; + src = ''; + crossOrigin: string | null = null; + referrerPolicy = ''; + addEventListener(): void {} + removeEventListener(): void {} + } + + it.each(['user', 'lookup'])('`avatarField` (%s) is the record field each search-picker candidate draws its avatar from', async (type) => { + // Each candidate carries an avatar in the default field (`image`) and in + // another one: both legs read a drawn `img`, so neither passes on a picker + // that draws no avatar at all. + const people = [{ id: 'u1', name: 'Ann', image: 'https://cdn.example/ann-image.png', photo: 'https://cdn.example/ann-photo.png' }]; + const drawn = async (field: Row) => { + const m = await mount({ type, reference: 'sys_user', picker: 'search', ...field }, { dataSource: dataSourceOf(people) }); + await click(m.row().querySelector('[data-testid^="lookup-trigger"]')); + const src = document.querySelector('[data-testid="person-row"] img')?.getAttribute('src') ?? null; + m.unmount(); + return src; + }; + const realImage = window.Image; + (window as unknown as { Image: unknown }).Image = LoadedImage; + try { + expect(await drawn({})).toBe('https://cdn.example/ann-image.png'); + expect(await drawn({ avatarField: 'photo' })).toBe('https://cdn.example/ann-photo.png'); + } finally { + window.Image = realImage; + } + }); + /** Open a lookup's browse-all dialog; its column headers and the queries it made. */ const browseAll = async (field: Row) => { const ds = dataSourceOf(); 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 0168942bdf..05c27a88de 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 @@ -24,7 +24,7 @@ * 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; + * spellings of five 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). @@ -74,6 +74,7 @@ const DECLARED: ReadonlyArray]> = [ ['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'] }], + ['avatarField', { type: 'user', reference: 'sys_user', picker: 'search', avatarField: 'photo' }], ]; /* ── 1. accepted on the strict face; a misspelling beside it is not ─────── */ @@ -114,6 +115,7 @@ describe('each declared key is judged by its declared type (objectui#12130)', () ['lookupPageSize', { type: 'lookup', lookupPageSize: -10 }], ['picker', { type: 'user', picker: 'table' }], ['subtitle', { type: 'user', subtitle: 'email' }], + ['avatarField', { type: 'user', avatarField: ['photo'] }], ]; it.each(WRONG)('a wrong `fields[].%s` value is refused at that key, on both faces', (key, field) => { @@ -153,11 +155,14 @@ describe('the keys objectui#12130 measured not to work stay refused by name (obj // The snake_case spellings the `lookup` widget still reads before the spec's // camelCase keys this card declares (the census ledger records the reads). + // `avatar_field` is the `lookup` and `user` widgets' first read of the + // search picker's `avatarField`, which this face declares camelCase only. 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' }], + ['avatar_field', { type: 'user', picker: 'search', avatar_field: 'photo' }], ]; it.each(SECOND_SPELLINGS)('the second spelling `fields[].%s` is refused by name', (key, field) => { @@ -176,7 +181,7 @@ export type assertionChannelMembersAreTyped = Expect, + | FormField['picker'] | FormField['subtitle'] | FormField['avatarField']>, false >>; @@ -200,4 +205,5 @@ export type assertionLocalMembers = [ 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 5a958219cf..6d513825ea 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -131,6 +131,9 @@ const DECLARED_KEYS = [ 'lookupPageSize', 'picker', 'subtitle', + // objectui#12130 round 2 — the search picker's avatar field, camelCase only; + // the `avatar_field` spelling the widgets read first stays refused. + 'avatarField', ]; /** diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 44f7d94e36..6c094d6866 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -2193,13 +2193,18 @@ export interface FormField { // 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. + // types' members by reference, and `capture`, `crop`, `defaultName` and + // `avatarField`, 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: `avatar_field`, the snake_case second spelling of + // `avatarField` that the `lookup` and `user` widgets still read first. The + // face declares one spelling, camelCase like its other keys (AGENTS.md + // #0.1; objectui#11610), and the census ledger records the read. + // // ⛔ 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 @@ -2276,6 +2281,14 @@ export interface FormField { * (`picker: 'search'`): {@link UserFieldMetadata.subtitle}. */ subtitle?: UserFieldMetadata['subtitle']; + /** + * Record field each candidate of the search picker (`picker: 'search'`) of + * a `lookup` or `user` field draws its avatar image from (default `image`). + * Typed as the widgets read it: the spec's `FieldSchema` declares neither + * spelling, and {@link UserFieldMetadata} declares only the snake_case one, + * which this face refuses. + */ + avatarField?: string; } /** diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 753186f657..f45d49bc3b 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -1164,10 +1164,12 @@ export const FormFieldSchema = z.object({ // `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. + // `crop`, `defaultName` and `avatarField` 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). `avatar_field` stays undeclared: the second spelling of + // `avatarField` (AGENTS.md #0.1). `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() @@ -1188,6 +1190,8 @@ export const FormFieldSchema = z.object({ .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'), + avatarField: z.string().optional() + .describe('Record field each candidate of the search picker draws its avatar image from (default `image`); 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.