Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/12130-avatar-field.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .changeset/12130-form-field-channel-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,6 @@ const LEDGER: Readonly<Record<string, LedgerRow>> = {
+ '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',
Expand All @@ -320,6 +313,10 @@ const LEDGER: Readonly<Record<string, LedgerRow>> = {
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.',
Expand Down Expand Up @@ -368,6 +365,7 @@ const DECLARED_BY_12130: Readonly<Record<string, readonly string[]>> = {
lookupPageSize: ['lookup', 'master_detail'],
picker: ['lookup', 'master_detail', 'user'],
subtitle: ['lookup', 'master_detail', 'user'],
avatarField: ['lookup', 'master_detail', 'user'],
};

/* ── the census ──────────────────────────────────────────────────────────── */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -74,6 +74,7 @@ const DECLARED: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
['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 ─────── */
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<readonly [string, Record<string, unknown>]> = [
['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) => {
Expand All @@ -176,7 +181,7 @@ export type assertionChannelMembersAreTyped = Expect<Equal<
IsAny<FormField['language'] | FormField['maxSize'] | FormField['capture'] | FormField['crop']
| FormField['defaultName'] | FormField['allowCreate'] | FormField['descriptionField'] | FormField['displayField']
| FormField['idField'] | FormField['lookupColumns'] | FormField['lookupFilters'] | FormField['lookupPageSize']
| FormField['picker'] | FormField['subtitle']>,
| FormField['picker'] | FormField['subtitle'] | FormField['avatarField']>,
false
>>;

Expand All @@ -200,4 +205,5 @@ export type assertionLocalMembers = [
Expect<Equal<FormField['capture'], 'environment' | 'user' | false | undefined>>,
Expect<Equal<FormField['crop'], boolean | undefined>>,
Expect<Equal<FormField['defaultName'], string | undefined>>,
Expect<Equal<FormField['avatarField'], string | undefined>>,
];
3 changes: 3 additions & 0 deletions packages/types/src/__tests__/form-field-zod-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

/**
Expand Down
17 changes: 15 additions & 2 deletions packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down
12 changes: 8 additions & 4 deletions packages/types/src/zod/form.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
Expand Down
Loading