Skip to content
22 changes: 22 additions & 0 deletions .changeset/28245-lookup-autofill-fill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/fields': patch
'@object-ui/components': patch
'@object-ui/types': patch
'@object-ui/plugin-form': patch
'@objectstack/spec': patch
---

feat(fields): a lookup pick fills the fields its `lookupAutofill` declares, in the record form and in the line-item grid

Clause-②: yes (widening)
Metadata-protocol: none

`FieldSchema.lookupAutofill` (`[{ target, source, overwrite? }]` on a single-value `lookup` / `master_detail`) now has its reader. Picking a record writes each declared `target` of the record being edited from the picked record's `source` — a quote line's `unit_price` from the picked product's `list_price` — before save, where a server hook used to be the only filler.

- **The overwrite rule is the spec's.** Without `overwrite: true` a pick fills a target only while it is empty or still holds the value an earlier pick in the same form (or grid row) filled in; a value the user typed, or one the record already stored, stays. A pair declaring `overwrite: true` replaces the target on every pick. A `source` the picked record does not carry (a field the reader may not read is not served) writes nothing.
- **Record form.** `LookupField` applies the pairs on every single-value pick path: the dropdown, the keyboard, quick-create and the browse-all dialog. The form renderer hands it a new widget prop, `onAutofill(patch)`, which writes the targets as edits of the form (`setValue`, dirty), so the filled values are saved with the record.
- **Line-item grid.** `GridField` takes the child object's pairs through a new host prop, `lookupAutofill` (lookup field name → pairs, built with the new `lookupAutofillByField`), which `MasterDetailForm` and `LineItemsPanel` pass from the child schema they load. The column key `autofill` is unchanged and still governs only the same-named copy; a declared pair applies whatever it says and takes its target out of that copy, so one target has one writer per pick. A pick from the grid's browse-all dialog now reaches the grid once, with its record, so both copies run on that path too.
- **Types.** `LookupFieldMetadata.lookupAutofill` is declared by reference to the spec. New exports from `@object-ui/fields`: `declaredLookupAutofillPatch`, `lookupAutofillByField`, `LookupAutofillPair`, `LookupAutofillByField`; `lookupAutofillPatch` takes an optional fourth argument carrying the declared pairs.
- **Liveness.** The `lookupAutofill` row of `@objectstack/spec`'s field ledger moves from `planned` to `live`, citing these readers.

A master-detail collection whose relationship and every column are authored loads no child schema, so its grid fills only the same-named copy.
26 changes: 13 additions & 13 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -382,26 +382,26 @@
"note": "objectui LookupField — opt-in inline quick-create (dataSource.create from typed text). Read camelCase-only at objectui @daeb77fc84: packages/fields/src/widgets/LookupField.tsx#explicitAllowCreate (objectui#12157 retired the `allow_create` read)."
},
"lookupAutofill": {
"status": "planned",
"verifiedAt": "2026-10-30",
"status": "live",
"verifiedAt": "2026-10-31",
"evidenceScope": "in-repo",
"evidence": "packages/spec/src/data/field.zod.ts#lookupAutofill (the parse-time door, field half: the FieldSchema superRefine refuses the key off a single-value `lookup` / `master_detail`); packages/spec/src/data/object.zod.ts#refuseDishonestLookupAutofill (the parse-time door, target half: each target a declared, author-written field of the same object, not the lookup itself, named once); packages/spec/src/stack.zod.ts#collectLookupAutofillErrors (the `os validate` / `os build` door, source half: a declared, non-`internal` field of the referenced object whose value the target holds); packages/spec/src/data/field-lookup-autofill.test.ts (the accept and refuse pins)",
"note": "PLANNED — declared ahead of its one reader by the card's own sequencing (#27945 is the spec half; the ui half, the record form's lookup widget and the line-item grid writing the declared targets on pick, is a separate domain:ui card). PRODUCER: HotCRM's quote line item (product lookup, `unit_price` from the product's `list_price`), today filled only after save by a server hook. CONSUMER: ui/packages/fields LookupField (the form) and ui/packages/fields/src/widgets/GridField.tsx#lookupAutofillPatch (the grid), which today copies only same-named fields under the column key `autofill`. Measured in this repo at 3a2f4c00: no reader of `lookupAutofill` anywhere outside packages/spec, ui/ included (the key is new). No `authorWarn`, on the `referenceDeletedWhen` precedent: the authoring half (parse, target and source checks) is enforced now, an author who sets it today re-authors nothing when the reader lands, and a warned field row would reopen the lint's field walk for one key. Flip to `live` when the ui card lands, citing both read points.",
"evidence": "ui/packages/fields/src/widgets/lookupAutofill.ts#declaredLookupAutofillPatch (the one fill rule: each target from the picked record's source, filled only while empty or still holding the earlier pick's value unless `overwrite: true`); ui/packages/fields/src/widgets/LookupField.tsx#fillDeclaredTargets (the record form: every single-value pick path, popover, keyboard, quick-create and the browse-all dialog, reads the field's pairs and writes through the host's `onAutofill`); ui/packages/components/src/renderers/form/form.tsx#LOOKUP_AUTOFILL_WIDGET_TYPES (the form renderer hands a lookup its `onAutofill` writer, `form.setValue` as an edit); ui/packages/fields/src/widgets/GridField.tsx#lookupAutofillPatch (the line-item grid: the declared pair fills under the same rule and takes its target out of the same-named `autofill` copy), its pairs handed over by ui/packages/plugin-form/src/MasterDetailForm.tsx#lookupAutofill and ui/packages/plugin-form/src/LineItemsPanel.tsx#lineLookupAutofill through ui/packages/fields/src/widgets/lookupAutofill.ts#lookupAutofillByField; authoring doors unchanged: packages/spec/src/data/field.zod.ts#lookupAutofill, packages/spec/src/data/object.zod.ts#refuseDishonestLookupAutofill, packages/spec/src/stack.zod.ts#collectLookupAutofillErrors",
"note": "LIVE — read by the record form and the line-item grid on a pick. Pinned end to end by ui/packages/plugin-form/src/ObjectForm.lookupAutofill-28245.test.tsx (an empty target fills and is saved, a typed value is kept, `overwrite: true` replaces, an earlier pick's value is replaced, the browse-all dialog fills too, a lookup without the key fills nothing) and ui/packages/fields/src/widgets/GridField.lookupAutofill-28245.test.tsx (the same rule in a grid row, the declared target out of the same-named copy, a stored line's own value kept). Bound: a master-detail collection whose relationship and every column are authored loads no child schema, so its grid fills only the same-named copy. PRODUCER: HotCRM's quote line item (product lookup, `unit_price` from the product's `list_price`).",
"children": {
"target": {
"status": "planned",
"verifiedAt": "2026-10-30",
"note": "The field of the declaring object a pick fills. Judged at parse by the container's evidence (snake_case; declared on the same object, not the lookup itself, not readonly, not computed, named once); written by nothing until the ui card's form and grid read the pairs. Shares the container's verdict and its flip."
"status": "live",
"verifiedAt": "2026-10-31",
"note": "The field of the declaring object a pick fills: the key `declaredLookupAutofillPatch` writes, in the form through `onAutofill` and in a grid row through the row patch. Shares the container's evidence."
},
"source": {
"status": "planned",
"verifiedAt": "2026-10-30",
"note": "The field of the picked record whose value is copied. Judged at `os validate` / `os build` by the container's evidence (declared on the referenced object, not internal, a value the target holds); read by nothing until the ui card lands. Shares the container's verdict and its flip."
"status": "live",
"verifiedAt": "2026-10-31",
"note": "The field of the picked record whose value is copied: read off the picked row by `declaredLookupAutofillPatch`; a source the row does not carry (not served to this reader) writes nothing. Shares the container's evidence."
},
"overwrite": {
"status": "planned",
"verifiedAt": "2026-10-30",
"note": "Opt-in to replace a value the target already holds on every pick; absent, a pick fills only an empty target or one an earlier pick filled. Read by nothing until the ui card lands. Shares the container's verdict and its flip."
"status": "live",
"verifiedAt": "2026-10-31",
"note": "TRUE makes the pick replace the target whatever it holds; absent, `declaredLookupAutofillPatch` fills only an empty target or one still holding the earlier pick's value. Shares the container's evidence."
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/field.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `field` | 101 | 0 | 0 | 1 | 8 | 110 |
| `field` | 104 | 0 | 0 | 1 | 5 | 110 |
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,11 @@ const LEDGER: Readonly<Record<string, LedgerRow>> = {
why: 'RUNTIME-ONLY (objectstack#27188) — object-field metadata (the spec\'s `FieldSchema.valueDomain`, ADR-0144 D2) read '
+ 'off the resolved `field` stash: a record-currency code field takes its domain from the object, not from the form.',
},
lookupAutofill: {
readers: [LOOKUP],
why: 'RUNTIME-ONLY (objectstack#28245) — object-field metadata (the spec\'s `FieldSchema.lookupAutofill`) read off the '
+ 'resolved `field` stash: which fields a pick fills is a fact about the object, not about one form.',
},
currency: {
readers: [CURRENCY],
why: 'RUNTIME-ONLY (objectui#11070 round 13) — the spec\'s `FieldSchema` refuses it; `currencyConfig` is the declared spelling.',
Expand Down
19 changes: 19 additions & 0 deletions ui/packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
dependsOnLabels,
emptyHint,
onInvalidInputChange,
onAutofill,
// Renderer-only, and deliberately NOT a new key in the widget contract
// (objectui#3393). The call site started forwarding `label` for the
// built-in fullscreen dialog; registered widgets must keep receiving
Expand Down Expand Up @@ -1286,9 +1287,15 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
// Only the widgets that report unparseable text read it; every other one
// would spread an unknown handler onto its DOM (objectstack#27483).
...(INVALID_INPUT_REPORTING_WIDGET_TYPES.has(normalizedType) ? { onInvalidInputChange } : {}),
// Only a relationship pick fills sibling fields (`lookupAutofill`); every
// other widget would spread an unknown handler onto its DOM.
...(LOOKUP_AUTOFILL_WIDGET_TYPES.has(normalizedType) ? { onAutofill } : {}),
};
}

/** The registered widgets whose pick writes `lookupAutofill` targets through `onAutofill`. */
const LOOKUP_AUTOFILL_WIDGET_TYPES: ReadonlySet<string> = new Set(['lookup', 'master_detail']);

/** The registered widgets that fire `onInvalidInputChange`: the date picker's two faces. */
const INVALID_INPUT_REPORTING_WIDGET_TYPES: ReadonlySet<string> = new Set(['date', 'datetime']);

Expand Down Expand Up @@ -3904,6 +3911,18 @@ ComponentRegistry.register('form',
invalidInputMessages.set(name, message);
void form.trigger(name);
},
// A lookup pick's declared `lookupAutofill` targets, written
// as edits of this form's record; the widget decides what the
// overwrite rule lets it fill. A target already showing an
// error is re-validated, so a filled required field clears.
onAutofill: (patch: Record<string, unknown>) => {
for (const [target, filled] of Object.entries(patch)) {
form.setValue(target, filled, {
shouldDirty: true,
shouldValidate: !!form.getFieldState(target).error,
});
}
},
// Required is a STATE, so it travels the state channel
// (objectui#3290). Until now the resolved `required` (static
// `required` + `requiredWhen` CEL) drove exactly one thing —
Expand Down
1 change: 1 addition & 0 deletions ui/packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5316,6 +5316,7 @@ export * from './widgets/UserField.js';
export * from './widgets/ObjectField.js';
export * from './widgets/VectorField.js';
export * from './widgets/GridField.js';
export * from './widgets/lookupAutofill.js';
// New widgets according to @objectstack/spec
export * from './widgets/ColorField.js';
export * from './widgets/SliderField.js';
Expand Down
Loading
Loading