diff --git a/.changeset/28245-lookup-autofill-fill.md b/.changeset/28245-lookup-autofill-fill.md new file mode 100644 index 00000000000..8e47ce52694 --- /dev/null +++ b/.changeset/28245-lookup-autofill-fill.md @@ -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. diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 0a6c2485c7f..ee95fc61c10 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -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." } } }, diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 458d15e5870..0a83dd7e24d 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -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 | diff --git a/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx b/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx index 9b65b29d214..d2888291e5c 100644 --- a/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx +++ b/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx @@ -326,6 +326,11 @@ const LEDGER: Readonly> = { 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.', diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 32d30099346..b66926d6e88 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -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 @@ -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 = new Set(['lookup', 'master_detail']); + /** The registered widgets that fire `onInvalidInputChange`: the date picker's two faces. */ const INVALID_INPUT_REPORTING_WIDGET_TYPES: ReadonlySet = new Set(['date', 'datetime']); @@ -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) => { + 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 — diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 42c99908393..ae92e7fa3eb 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -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'; diff --git a/ui/packages/fields/src/widgets/GridField.lookupAutofill-28245.test.tsx b/ui/packages/fields/src/widgets/GridField.lookupAutofill-28245.test.tsx new file mode 100644 index 00000000000..8e77a5d3abe --- /dev/null +++ b/ui/packages/fields/src/widgets/GridField.lookupAutofill-28245.test.tsx @@ -0,0 +1,182 @@ +/** + * 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. + */ + +/** + * A pick in a line-item grid's lookup cell fills the targets the child field's + * `lookupAutofill` declares (objectstack#28245, the ui half of + * `FieldSchema.lookupAutofill`). + * + * Pinned on the pure patch: a declared pair fills an empty target, keeps a + * value somebody wrote, `overwrite: true` replaces it, a value an earlier pick + * filled is replaced, the pair applies whatever the column's `autofill` says, + * and a declared target is taken out of the same-named copy. Control: with no + * pair the patch is the same-named copy it always was. Then through the grid + * itself: the host hands the pairs over, a cell pick fills the row, and the + * next pick replaces only what the first one filled. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, act, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SchemaRendererContext } from '@object-ui/react'; +import { GridField, lookupAutofillPatch } from './GridField'; +import { declaredLookupAutofillPatch, lookupAutofillByField } from './lookupAutofill'; + +afterEach(cleanup); + +const cols = [ + { name: 'product', type: 'lookup' as const, reference: 'product' }, + { name: 'description', type: 'text' as const }, + { name: 'unit_price', type: 'currency' as const }, +]; +const widget = { value: 'p1', label: 'Widget', name: 'Widget', description: 'Standard widget', unit_price: 29.99, list_price: 25 }; +const gadget = { value: 'p2', label: 'Gadget', name: 'Gadget', description: 'Gadget', unit_price: 59.99, list_price: 50 }; +const pairs = [{ target: 'unit_price', source: 'list_price' }]; + +describe('lookupAutofillPatch with declared lookupAutofill pairs (objectstack#28245)', () => { + it('fills an empty target from the declared source, not from the same-named field', () => { + const patch = lookupAutofillPatch(cols, cols[0], widget, { pairs, row: { unit_price: null } }); + expect(patch).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 25 }); + }); + + it("keeps a value somebody wrote: the same-named copy no longer writes a declared target", () => { + const patch = lookupAutofillPatch(cols, cols[0], widget, { pairs, row: { unit_price: 10 } }); + expect(patch).toEqual({ product: 'p1', description: 'Standard widget' }); + }); + + it('`overwrite: true` replaces a value somebody wrote', () => { + const patch = lookupAutofillPatch(cols, cols[0], widget, { + pairs: [{ target: 'unit_price', source: 'list_price', overwrite: true }], + row: { unit_price: 10 }, + }); + expect(patch.unit_price).toBe(25); + }); + + it('replaces a value the earlier pick filled', () => { + const patch = lookupAutofillPatch(cols, cols[0], gadget, { pairs, row: { product: 'p1', unit_price: 25 }, previous: widget }); + expect(patch.unit_price).toBe(50); + }); + + it('applies the declared pair on a column with `autofill: false`', () => { + const patch = lookupAutofillPatch(cols, { ...cols[0], autofill: false }, widget, { pairs, row: {} }); + expect(patch).toEqual({ product: 'p1', unit_price: 25 }); + }); + + it('control: with no declared pair the patch is the same-named copy', () => { + expect(lookupAutofillPatch(cols, cols[0], widget, { row: { unit_price: 10 } })).toEqual(lookupAutofillPatch(cols, cols[0], widget)); + expect(lookupAutofillPatch(cols, cols[0], widget)).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 29.99 }); + }); +}); + +describe('declaredLookupAutofillPatch (objectstack#28245)', () => { + it('writes nothing for a source the picked record does not carry', () => { + expect(declaredLookupAutofillPatch(pairs, { id: 'p3', name: 'Hidden price' }, {})).toEqual({}); + }); + + it('control: no pairs, no writes', () => { + expect(declaredLookupAutofillPatch(undefined, widget, {})).toEqual({}); + expect(declaredLookupAutofillPatch([], widget, {})).toEqual({}); + }); + + it('reads the pairs of single-value relationship fields only', () => { + expect( + lookupAutofillByField({ + product: { type: 'lookup', reference: 'product', lookupAutofill: pairs }, + tags: { type: 'lookup', reference: 'tag', multiple: true, lookupAutofill: pairs }, + name: { type: 'text', lookupAutofill: pairs }, + account: { type: 'lookup', reference: 'account' }, + }), + ).toEqual({ product: pairs }); + }); +}); + +describe('GridField — a lookup cell pick fills the declared targets (objectstack#28245)', () => { + const ROWS = [ + { id: 'p1', name: 'Widget', list_price: 25 }, + { id: 'p2', name: 'Gadget', list_price: 50 }, + ]; + const ds = { + find: vi.fn(async (_o: string, params?: { $filter?: { id?: { $in?: unknown[] } } }) => { + const wanted = params?.$filter?.id?.$in; + const rows = ROWS.filter((r) => !wanted || wanted.includes(r.id)); + return { data: rows.map((r) => ({ ...r })), total: rows.length }; + }), + findOne: vi.fn(async (_o: string, id: string) => { + const row = ROWS.find((r) => r.id === id); + return row ? { ...row } : null; + }), + }; + const gridColumns = [ + { name: 'product', type: 'lookup' as const, reference: 'product' }, + { name: 'unit_price', type: 'number' as const }, + ]; + + function Host({ initial }: { initial: Record[] }) { + const [rows, setRows] = React.useState(initial); + // The grid's lookup cells read their records through the renderer + // context's data source, as in the console. + return ( + + []) => setRows(next)} + field={{ columns: gridColumns } as never} + lookupAutofill={{ product: pairs }} + /> + {JSON.stringify(rows)} + + ); + } + + const rowsNow = () => JSON.parse(screen.getByTestId('rows').textContent || '[]'); + + async function pickInFirstRow(name: string): Promise { + await act(async () => { + fireEvent.click(screen.getAllByTestId('lookup-trigger')[0]); + }); + const option = await waitFor(() => { + const el = screen.getAllByRole('option').find((o) => (o.getAttribute('title') ?? o.textContent ?? '').includes(name)); + if (!el) throw new Error(`${name} not offered`); + return el; + }, { timeout: 3000 }); + await act(async () => { + fireEvent.click(option); + }); + } + + it('fills the target on a pick, replaces it on the next pick, and keeps an edited value', async () => { + render(); + + await pickInFirstRow('Widget'); + await waitFor(() => expect(rowsNow()[0]).toMatchObject({ product: 'p1', unit_price: 25 })); + + await pickInFirstRow('Gadget'); + await waitFor(() => expect(rowsNow()[0]).toMatchObject({ product: 'p2', unit_price: 50 })); + }); + + it('a pick from the browse-all dialog reaches the grid once, with its record, and fills the target', async () => { + render(); + await act(async () => { + fireEvent.click(screen.getAllByTestId('browse-all-records')[0]); + }); + const row = await screen.findByTestId('record-row-p2', {}, { timeout: 3000 }); + await act(async () => { + fireEvent.click(row); + }); + await waitFor(() => expect(rowsNow()).toEqual([{ product: 'p2', unit_price: 50 }])); + }); + + it("keeps a stored line's own price: a value no pick in this grid filled", async () => { + render(); + + await pickInFirstRow('Gadget'); + await waitFor(() => expect(rowsNow()[0]).toMatchObject({ product: 'p2' })); + expect(rowsNow()[0].unit_price).toBe(19); + }); +}); diff --git a/ui/packages/fields/src/widgets/GridField.tsx b/ui/packages/fields/src/widgets/GridField.tsx index bffae48d5b8..aca12409936 100644 --- a/ui/packages/fields/src/widgets/GridField.tsx +++ b/ui/packages/fields/src/widgets/GridField.tsx @@ -20,6 +20,7 @@ import { formatDate, formatDateTime, resolveFieldRuleState, toDisplayDate } from import { useDisplayLocale, useLocalization, formatDisplayNumber } from '@object-ui/i18n'; import { resolveFieldCurrency, currencyFractionDigits, currencySymbol } from '../currency.js'; import { LookupField } from './LookupField.js'; +import { declaredLookupAutofillPatch, type LookupAutofillByField, type LookupAutofillPair } from './lookupAutofill.js'; import { FileCell } from './FileField.js'; import { toDateInputValue, toDateTimeInputValue, fromDateTimeInputValue, isImpossibleStoredDay } from './nativeDateValue.js'; import { useFieldTranslation } from './useFieldTranslation.js'; @@ -271,17 +272,31 @@ export function evalArith(expr: string, row: Row): number | null { * catalog-typeahead behaviour (pick a product → its unit_price/description fill * in). Skips the lookup column itself and computed columns. Pure + exported so * it is unit-testable independent of the picker UI. + * + * `declared` carries the child field's `lookupAutofill` pairs, the row being + * edited and the record the cell held from an earlier pick in this grid. Each + * pair writes its target under the spec's overwrite rule + * (`declaredLookupAutofillPatch`), whatever the column's `autofill` says, and + * a declared target is taken out of the same-named copy, so one target never + * has two writers on one pick. */ -export function lookupAutofillPatch(columns: GridColumn[], col: GridColumn, record: any): Row { +export function lookupAutofillPatch( + columns: GridColumn[], + col: GridColumn, + record: any, + declared?: { pairs?: readonly LookupAutofillPair[]; row?: Row; previous?: Row | null }, +): Row { const patch: Row = { [col.name]: record?.value ?? record?.id ?? record?._id }; + const declaredTargets = new Set((declared?.pairs ?? []).map((p) => p.target)); if (col.autofill !== false && record && typeof record === 'object') { for (const other of columns) { if (other.name === col.name || other.computed || other.type === 'lookup') continue; + if (declaredTargets.has(other.name)) continue; const v = record[other.name]; if (v !== undefined && v !== null && v !== '') patch[other.name] = v; } } - return patch; + return { ...patch, ...declaredLookupAutofillPatch(declared?.pairs, record, declared?.row, declared?.previous) }; } /** @@ -585,6 +600,11 @@ type GridFieldProps = FieldWidgetComponentProps & { * `readonlyWhen` / `requiredWhen` CEL predicate — so a line cell can react to * the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */ contextRecord?: Record; + /** The child object's declared `lookupAutofill` pairs, by lookup column + * name (`lookupAutofillByField` over the child's fields). A grid column + * carries no field definition, so the host that knows the child object + * hands them over. Supplied by MasterDetailForm and LineItemsPanel. */ + lookupAutofill?: LookupAutofillByField; }; /** @@ -675,6 +695,7 @@ function GridFieldBody({ onRowExpand, displayMode, onAdd, + lookupAutofill, ...props }: GridFieldProps) { // The field-level keys, read as `GridFieldMetadata` declares them and as @@ -856,12 +877,28 @@ function GridFieldBody({ * lookup/computed column). The catalog-typeahead behaviour every invoicing * tool has: pick a product → its unit_price / description drop into the row. * Opt out per column with `autofill: false`. + * + * The column's declared `lookupAutofill` pairs fill their targets too, under + * the spec's overwrite rule. "An earlier pick filled it" is read from the + * records picked in this grid, keyed by column and id: the record the cell + * held before this pick, if this grid picked it, is the one whose `source` + * values count as pick-filled. Keyed by the row's own lookup value rather + * than its index, so adding, removing or reordering rows cannot misattribute it. */ + const pickedLookupRecords = useRef(new Map()); const applyLookupSelection = useCallback( (rowIdx: number, col: GridColumn, record: any) => { - applyPatch(rowIdx, lookupAutofillPatch(columns, col, record)); + const row = rows[rowIdx] ?? blankRow(); + const pickKey = (id: unknown) => `${col.name}\u0000${String(id)}`; + const heldId = row[col.name]; + const previous = heldId == null || heldId === '' ? null : pickedLookupRecords.current.get(pickKey(heldId)) ?? null; + const patch = lookupAutofillPatch(columns, col, record, { pairs: lookupAutofill?.[col.name], row, previous }); + if (record && typeof record === 'object' && patch[col.name] != null) { + pickedLookupRecords.current.set(pickKey(patch[col.name]), record); + } + applyPatch(rowIdx, patch); }, - [columns, applyPatch], + [columns, rows, blankRow, lookupAutofill, applyPatch], ); /** Set a cell to an already-typed value (lookup ids, etc.) without coercion. */ diff --git a/ui/packages/fields/src/widgets/LookupField.tsx b/ui/packages/fields/src/widgets/LookupField.tsx index 04c7cbb215e..3c30898c4c7 100644 --- a/ui/packages/fields/src/widgets/LookupField.tsx +++ b/ui/packages/fields/src/widgets/LookupField.tsx @@ -22,6 +22,7 @@ import { deriveLookupColumns } from './deriveLookupColumns.js'; import { buildExpandFields, declaredNameField, getRecordDisplayName, mergeFilterNodes, resolveAffordance, resolveNameField, toPredicateRecord, withoutDeniedFields } from '@object-ui/core'; import { getRecentLookupIds, pushRecentLookupId } from './recentLookups.js'; import { getPersonInitials } from './personDisplay.js'; +import { declaredLookupAutofillPatch, type LookupAutofillPair } from './lookupAutofill.js'; import { getCellRendererResolver } from './_cell-renderer-bridge.js'; // The one place a lookup column's display value is decided — shared with the // "browse all records" picker (RecordPickerDialog) so a single `lookupColumns` @@ -1050,6 +1051,39 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro // select), it drives the update and the host owns the resulting value change. const onSelectRecord = props.onSelectRecord; + /** + * The field's declared `lookupAutofill` pairs (`@objectstack/spec`), read + * where a record form draws this widget: a single-value pick whose host owns + * no `onSelectRecord` and supplies `onAutofill`, the writer for the sibling + * fields of the record being edited. A host that owns the pick (the line-item + * grid) applies the pairs itself. + */ + const autofillPairs: readonly LookupAutofillPair[] | undefined = + !multiple && !onSelectRecord && props.onAutofill ? fieldMeta?.lookupAutofill : undefined; + const onAutofill = props.onAutofill; + + /** + * Fill the declared targets from the picked `row`. The current values are + * the host's live record (`dependentValues`). "An earlier pick filled it" is + * read from `pickedRecords`, the rows this widget's own picks committed: the + * row the field held before this pick, if a pick in this form chose it, is + * the one whose `source` values count as pick-filled. A value the field held + * from the stored record was never picked here, so its targets stay. + */ + const fillDeclaredTargets = useCallback( + (row: Record | undefined) => { + if (!autofillPairs?.length || !onAutofill || !row) return; + const heldId = normalizeId(value); + const previous = + heldId == null || heldId === '' + ? null + : pickedRecords.find((r) => String(recordValue(r, idField)) === String(heldId)) ?? null; + const patch = declaredLookupAutofillPatch(autofillPairs, row, resolvedDependentValues, previous); + if (Object.keys(patch).length > 0) onAutofill(patch); + }, + [autofillPairs, onAutofill, normalizeId, value, pickedRecords, idField, resolvedDependentValues], + ); + /** * Commit a pick. `row` is the record `option` was built from. * @@ -1086,11 +1120,14 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro } else { if (referenceTo) pushRecentLookupId(referenceTo, option.value); if (onSelectRecord) onSelectRecord(option); - else onChange(option.value); + else { + onChange(option.value); + fillDeclaredTargets(row); + } setIsOpen(false); } }, - [multiple, value, onChange, onSelectRecord, referenceTo, normalizeId, idField], + [multiple, value, onChange, onSelectRecord, referenceTo, normalizeId, idField, fillDeclaredTargets], ); const handleRemove = (optionValue: any) => { @@ -1111,9 +1148,41 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro if (referenceTo) { records.forEach((r) => pushRecentLookupId(referenceTo, recordValue(r, idField) as string | number | undefined)); } + // A single pick from a Level-2 picker commits its id through `onSelect` + // (`onChange`) just before this, so the declared targets fill here, from + // the picked row, with `pickedRecords` still holding the earlier picks. + if (!multiple && records.length === 1) fillDeclaredTargets(records[0]); setPickedRecords(records); }, - [idField, referenceTo], + [idField, referenceTo, multiple, fillDeclaredTargets], + ); + + /** + * The browse-all dialog's single pick, for a host that owns the pick + * (`onSelectRecord`, the line-item grid): the dialog's bare-id `onSelect` + * is held back and the pick reaches the host once, with its record, exactly + * as a popover pick does, so the host's autofill runs on either path. A + * cleared value and a multi-value pick commit through `onChange` as before. + */ + const handleDialogSelect = useCallback( + (next: any) => { + if (!multiple && onSelectRecord && next != null && next !== '') return; + onChange(next); + }, + [multiple, onSelectRecord, onChange], + ); + const handleDialogSelectRecords = useCallback( + (records: Record[]) => { + if (multiple || !onSelectRecord || records.length !== 1) { + handlePickerSelectRecords(records); + return; + } + const row = records[0]; + if (referenceTo) pushRecentLookupId(referenceTo, recordValue(row, idField) as string | number | undefined); + setPickedRecords(records); + onSelectRecord(recordToOption(row, declaredDisplayField, idField, effectiveDescriptionField, refObjectSchema, perms, referenceTo)); + }, + [multiple, onSelectRecord, handlePickerSelectRecords, referenceTo, idField, declaredDisplayField, effectiveDescriptionField, refObjectSchema, perms], ); // ── Recently-used, quick-create, combined option list ──────────────────── @@ -1937,8 +2006,8 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro idField={idField} pageSize={lookupPageSize} value={pickerValue} - onSelect={onChange} - onSelectRecords={handlePickerSelectRecords} + onSelect={handleDialogSelect} + onSelectRecords={handleDialogSelectRecords} lookupFilters={lookupFilters} baseFilter={dependentFilter} cellRenderer={getCellRendererResolver()} diff --git a/ui/packages/fields/src/widgets/lookupAutofill.ts b/ui/packages/fields/src/widgets/lookupAutofill.ts new file mode 100644 index 00000000000..71d2e0fa16d --- /dev/null +++ b/ui/packages/fields/src/widgets/lookupAutofill.ts @@ -0,0 +1,77 @@ +import type { Field as SpecField } from '@objectstack/spec/data'; + +/** + * One declared `{ target, source, overwrite? }` pair of `@objectstack/spec`'s + * `FieldSchema.lookupAutofill`, typed by reference so the two cannot drift. + */ +export type LookupAutofillPair = NonNullable[number]; + +/** + * The declared pairs of the lookup fields of one object, keyed by the lookup + * field's name: what a line-item grid is handed for its lookup columns, since + * a grid column (`InlineGridColumnSchema`) carries no field definition. + */ +export type LookupAutofillByField = Readonly>; + +type Values = Record; + +/** A target holding nothing a person could have written. */ +function isEmptyValue(v: unknown): boolean { + return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0); +} + +function sameValue(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; + return JSON.stringify(a) === JSON.stringify(b); +} + +/** + * The writes one pick in a lookup makes under its declared `lookupAutofill` + * pairs: each `target` of the record being edited takes the picked record's + * `source`. + * + * The overwrite rule is the spec's (the key's describe): a pair without + * `overwrite: true` fills its target only while the target is empty or still + * holds the value an earlier pick filled in — `previous`, the record the lookup + * held from an earlier pick in this same form or row, read at the same + * `source`. A value the user typed or 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: a missing key is not a value. + */ +export function declaredLookupAutofillPatch( + pairs: readonly LookupAutofillPair[] | undefined, + picked: Values | null | undefined, + current: Values | null | undefined, + previous?: Values | null, +): Values { + const patch: Values = {}; + if (!pairs?.length || !picked || typeof picked !== 'object') return patch; + for (const { target, source, overwrite } of pairs) { + if (!(source in picked) || picked[source] === undefined) continue; + const held = current?.[target]; + const filledByEarlierPick = !!previous && source in previous && sameValue(held, previous[source]); + if (overwrite === true || isEmptyValue(held) || filledByEarlierPick) patch[target] = picked[source]; + } + return patch; +} + +/** + * The declared `lookupAutofill` pairs of an object's fields, by lookup field + * name. Only single-value `lookup` / `master_detail` fields carry the key (the + * spec refuses it elsewhere), so only those are read. + */ +export function lookupAutofillByField(fields: Record | undefined | null): LookupAutofillByField { + const out: Record = {}; + if (!fields || typeof fields !== 'object') return out; + for (const [name, def] of Object.entries(fields)) { + const d = def as { type?: unknown; multiple?: unknown; lookupAutofill?: unknown } | null | undefined; + if (!d || (d.type !== 'lookup' && d.type !== 'master_detail') || d.multiple === true) continue; + if (Array.isArray(d.lookupAutofill) && d.lookupAutofill.length > 0) { + out[name] = d.lookupAutofill as LookupAutofillPair[]; + } + } + return out; +} diff --git a/ui/packages/fields/src/widgets/toHostProps.ts b/ui/packages/fields/src/widgets/toHostProps.ts index 3d54a23414a..6944b47c92f 100644 --- a/ui/packages/fields/src/widgets/toHostProps.ts +++ b/ui/packages/fields/src/widgets/toHostProps.ts @@ -80,7 +80,8 @@ import type { FieldWidgetComponentProps } from './types.js'; * `field?.dependsOn ?? dependsOnProp` in all four option widgets. * - `emptyHint`: the host's value wins when supplied (objectui#3231). * - `onCreateNew`: the prop wins over `field.onCreateNew`; `onSelectRecord` - * has no metadata carrier at all, so the prop is its ONLY one. + * and `onAutofill` have no metadata carrier at all, so the prop is their + * ONLY one. * * Resolving any of that HERE would give each key a second author — the * `field || schema` shape objectui#3233 spent a release removing. One key, one @@ -120,6 +121,7 @@ const HOST_PLUMBING_KEYS = [ 'dependsOnLabels', 'emptyHint', 'onSelectRecord', + 'onAutofill', 'onCreateNew', ] as const; diff --git a/ui/packages/fields/src/widgets/types.ts b/ui/packages/fields/src/widgets/types.ts index f0cfc7524e8..92ed993299b 100644 --- a/ui/packages/fields/src/widgets/types.ts +++ b/ui/packages/fields/src/widgets/types.ts @@ -247,6 +247,17 @@ export type FieldWidgetComponentProps = { * price). When provided, the host owns the resulting value change. */ onSelectRecord?: (record: Record) => void; + /** + * Write sibling fields of the record being edited: the targets a pick in a + * lookup fills under its declared `lookupAutofill` pairs, as one patch of + * field name → value. + * + * **Producer**: the form renderer, which owns the record's values. + * **Consumer**: `LookupField`, on a single-value pick whose host owns no + * `onSelectRecord` (a host that owns the pick, the line-item grid, applies + * the pairs itself). A lookup reached without it fills nothing. + */ + onAutofill?: (patch: Record) => void; /** * Offer a "create new" affordance in a record picker, carrying whatever the * user had typed. Also declarable on the field metadata. diff --git a/ui/packages/plugin-form/src/LineItemsPanel.tsx b/ui/packages/plugin-form/src/LineItemsPanel.tsx index 2fbc47a6a14..eef51b77d61 100644 --- a/ui/packages/plugin-form/src/LineItemsPanel.tsx +++ b/ui/packages/plugin-form/src/LineItemsPanel.tsx @@ -26,7 +26,7 @@ import { CardTitle, cn, } from '@object-ui/components'; -import { LineItemsField, useBooleanValueLabel, type GridColumn } from '@object-ui/fields'; +import { LineItemsField, lookupAutofillByField, useBooleanValueLabel, type GridColumn } from '@object-ui/fields'; import type { GridFieldMetadata } from '@object-ui/types'; import { createSafeTranslation } from '@object-ui/i18n'; import { useSchemaContext, useRecordContext, useFilterScope, useResolvedFilter, useDataInvalidation, usePrintPage, NonGridRowCeilingNote } from '@object-ui/react'; @@ -745,6 +745,11 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch [childSchema, schema.childObject, perms], ); + const lineLookupAutofill = useMemo( + () => lookupAutofillByField(childSchema?.fields as Record | undefined), + [childSchema], + ); + const gridField = useMemo( () => ({ @@ -886,6 +891,10 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch value={rows} onChange={onChange} field={gridField} + // The child fields' declared `lookupAutofill` pairs, from the + // child schema loaded above: a lookup cell's pick fills their + // targets in the row. Empty until that schema arrives. + lookupAutofill={lineLookupAutofill} readonly={schema.readonly} // No input while the save is in flight (objectui#10631): `save` // reloads the rows once the batch lands, so a line edited in the diff --git a/ui/packages/plugin-form/src/MasterDetailForm.tsx b/ui/packages/plugin-form/src/MasterDetailForm.tsx index a26b5fb86dc..5e1ac4c0048 100644 --- a/ui/packages/plugin-form/src/MasterDetailForm.tsx +++ b/ui/packages/plugin-form/src/MasterDetailForm.tsx @@ -33,7 +33,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { BatchTransactionOperation, DataSource, GridFieldMetadata, I18nLabel } from '@object-ui/types'; import { resolveAffordance, runBatchTransaction } from '@object-ui/core'; -import { LineItemsField, useBooleanValueLabel, type BooleanValueLabel, type GridColumn } from '@object-ui/fields'; +import { LineItemsField, lookupAutofillByField, useBooleanValueLabel, type BooleanValueLabel, type GridColumn, type LookupAutofillByField } from '@object-ui/fields'; import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components'; import { formatDisplayNumber, @@ -425,6 +425,14 @@ interface DetailEntry { * authored config. */ columnsWith?: (booleanLabel: BooleanValueLabel) => GridColumn[]; + /** + * The child fields' declared `lookupAutofill` pairs, by lookup field name, + * read from the schema the resolve effect loaded and handed to the line grid + * (`GridField`'s `lookupAutofill`): a grid column carries no field + * definition. Absent when no schema was loaded for this entry (a fully + * configured entry skips the fetch). Internal state, never authored. + */ + lookupAutofill?: LookupAutofillByField; } /** The field shape `resolveFieldCurrency` reads — the one currency resolver. */ @@ -1076,6 +1084,9 @@ const MasterDetailLines: React.FC = ({ // CEL rule evaluates against it as `parent` (e.g. lock when // parent.status == 'paid'). contextRecord={parentRecord} + // The child fields' declared `lookupAutofill` pairs: a lookup + // cell's pick fills their targets in the row. + lookupAutofill={entry.lookupAutofill} // Per-row "expand to full form" is offered when it adds something: // always in form mode (it IS the editor), and in grid mode only // when the full form has fields the grid omits. A thin grid whose @@ -1344,6 +1355,7 @@ export const MasterDetailForm: React.FC = ({ sortField: derived.sortField, amountFieldDef: childFieldDef(childSchema, amountField), columnsWith, + lookupAutofill: lookupAutofillByField(childSchema?.fields as Record | undefined), }; } return { @@ -1360,6 +1372,7 @@ export const MasterDetailForm: React.FC = ({ sortField: derived.sortField, amountFieldDef: childFieldDef(childSchema, amountField), columnsWith, + lookupAutofill: lookupAutofillByField(childSchema?.fields as Record | undefined), }; } catch (err) { // THE DERIVE FAILED, on a schema that loaded fine — almost always diff --git a/ui/packages/plugin-form/src/ObjectForm.lookupAutofill-28245.test.tsx b/ui/packages/plugin-form/src/ObjectForm.lookupAutofill-28245.test.tsx new file mode 100644 index 00000000000..ac1c408318c --- /dev/null +++ b/ui/packages/plugin-form/src/ObjectForm.lookupAutofill-28245.test.tsx @@ -0,0 +1,201 @@ +/** + * 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. + */ + +/** + * A pick in a lookup fills the fields its `lookupAutofill` declares, in the + * record form (objectstack#28245, the ui half of `FieldSchema.lookupAutofill`). + * + * End to end through ObjectForm: the object schema carries the pairs, the form + * renderer hands the lookup its `onAutofill` writer, and the lookup applies the + * spec's overwrite rule. Pinned: a pick fills an empty target; a value the + * user typed is kept; `overwrite: true` replaces it; a value an earlier pick + * filled is replaced by the next pick; the filled value is saved. Control: a + * lookup without the key fills nothing, not even a same-named field. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor, fireEvent, screen, act } from '@testing-library/react'; +import React from 'react'; + +import { ObjectForm } from './ObjectForm'; +import { registerAllFields } from '@object-ui/fields'; +import { SchemaRendererContext } from '@object-ui/react'; + +registerAllFields(); + +const SCHEMAS: Record = { + quote_line: { + name: 'quote_line', + fields: { + product: { + type: 'lookup', + label: 'Product', + reference: 'product', + lookupAutofill: [ + { target: 'unit_price', source: 'list_price' }, + { target: 'description', source: 'summary', overwrite: true }, + ], + }, + unit_price: { type: 'number', label: 'Sales Price' }, + description: { type: 'text', label: 'Description' }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, + note: { type: 'text', label: 'Note' }, + }, + }, + product: { + name: 'product', + fields: { + name: { type: 'text', label: 'Name' }, + list_price: { type: 'number', label: 'List Price' }, + summary: { type: 'text', label: 'Summary' }, + }, + }, + account: { + name: 'account', + fields: { + name: { type: 'text', label: 'Name' }, + note: { type: 'text', label: 'Note' }, + }, + }, +}; + +const ROWS: Record[]> = { + product: [ + { id: 'p1', name: 'Widget', list_price: 100, summary: 'A widget' }, + { id: 'p2', name: 'Gadget', list_price: 250, summary: 'A gadget' }, + ], + account: [{ id: 'a1', name: 'Acme', note: 'from the account' }], +}; + +const makeDS = () => ({ + getObjectSchema: vi.fn(async (name: string) => SCHEMAS[name]), + find: vi.fn(async (name: string, params?: { $filter?: { id?: { $in?: unknown[] } } }) => { + const wanted = params?.$filter?.id?.$in; + const rows = (ROWS[name] ?? []).filter((r) => !wanted || wanted.includes(r.id)); + return { data: rows.map((r) => ({ ...r })), total: rows.length }; + }), + findOne: vi.fn(async (name: string, id: string) => { + const row = (ROWS[name] ?? []).find((r) => r.id === id); + return row ? { ...row } : null; + }), + create: vi.fn(async (_o: string, d: Record) => ({ id: 'l1', ...d })), + update: vi.fn(), +}); + +const input = (c: HTMLElement, name: string) => + waitFor(() => { + const el = c.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; + if (!el) throw new Error(`${name} not ready`); + return el; + }); + +async function pick(field: string, title: string): Promise { + const trigger = await screen.findByTestId(`lookup-trigger-${field}`); + await act(async () => { + fireEvent.click(trigger); + }); + const option = await waitFor(() => { + const el = screen.getAllByRole('option').find((o) => (o.getAttribute('title') ?? o.textContent ?? '').includes(title)); + if (!el) throw new Error(`${title} not offered`); + return el; + }, { timeout: 3000 }); + await act(async () => { + fireEvent.click(option); + }); + for (let i = 0; i < 6; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +function renderForm() { + const ds = makeDS(); + // Mounted the way the console mounts a form: the lookup reads its records + // through the renderer context's data source. + const view = render( + + + , + ); + return { ds, container: view.container }; +} + +describe('ObjectForm — a lookup pick fills its declared lookupAutofill targets (objectstack#28245)', () => { + it('fills an empty target from the picked record, and saves the filled value', async () => { + const { ds, container } = renderForm(); + await input(container, 'unit_price'); + await pick('product', 'Widget'); + + await waitFor(() => expect((container.querySelector('input[name="unit_price"]') as HTMLInputElement).value).toBe('100')); + expect((container.querySelector('input[name="description"]') as HTMLInputElement).value).toBe('A widget'); + + fireEvent.submit(container.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + expect(ds.create.mock.calls[0][1]).toMatchObject({ product: 'p1', unit_price: 100, description: 'A widget' }); + }); + + it("keeps a value the user typed, and `overwrite: true` replaces one", async () => { + const { container } = renderForm(); + fireEvent.change(await input(container, 'unit_price'), { target: { value: '77' } }); + fireEvent.change(await input(container, 'description'), { target: { value: 'my words' } }); + await pick('product', 'Widget'); + + await waitFor(() => expect((container.querySelector('input[name="description"]') as HTMLInputElement).value).toBe('A widget')); + expect((container.querySelector('input[name="unit_price"]') as HTMLInputElement).value).toBe('77'); + }); + + it('replaces a value an earlier pick filled, and keeps it once the user edits it', async () => { + const { container } = renderForm(); + await input(container, 'unit_price'); + await pick('product', 'Widget'); + await waitFor(() => expect((container.querySelector('input[name="unit_price"]') as HTMLInputElement).value).toBe('100')); + + await pick('product', 'Gadget'); + await waitFor(() => expect((container.querySelector('input[name="unit_price"]') as HTMLInputElement).value).toBe('250')); + + fireEvent.change(await input(container, 'unit_price'), { target: { value: '90' } }); + await pick('product', 'Widget'); + await waitFor(() => expect((container.querySelector('input[name="description"]') as HTMLInputElement).value).toBe('A widget')); + expect((container.querySelector('input[name="unit_price"]') as HTMLInputElement).value).toBe('90'); + }); + + it('a pick from the browse-all dialog fills the target too', async () => { + const { container } = renderForm(); + await input(container, 'unit_price'); + const browse = await waitFor(() => { + const el = container.querySelector('[data-field="product"] [data-testid="browse-all-records"]') as HTMLElement | null; + if (!el) throw new Error('browse not ready'); + return el; + }); + await act(async () => { + fireEvent.click(browse); + }); + const row = await screen.findByTestId('record-row-p2', {}, { timeout: 3000 }); + await act(async () => { + fireEvent.click(row); + }); + await waitFor(() => expect((container.querySelector('input[name="unit_price"]') as HTMLInputElement).value).toBe('250')); + }); + + it('control: a lookup without the key fills nothing, not even a same-named field', async () => { + const { ds, container } = renderForm(); + await input(container, 'note'); + await pick('account', 'Acme'); + + fireEvent.submit(container.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const saved = ds.create.mock.calls[0][1]; + expect(saved).toMatchObject({ account: 'a1' }); + expect(saved.note ?? '').toBe(''); + expect(saved.unit_price ?? null).toBe(null); + expect((container.querySelector('input[name="note"]') as HTMLInputElement).value).toBe(''); + }); +}); diff --git a/ui/packages/plugin-grid/src/relationalMetaKeys.ts b/ui/packages/plugin-grid/src/relationalMetaKeys.ts index 9a1e606dfdb..e035a7818c5 100644 --- a/ui/packages/plugin-grid/src/relationalMetaKeys.ts +++ b/ui/packages/plugin-grid/src/relationalMetaKeys.ts @@ -383,6 +383,7 @@ export const RELATIONAL_META_READ_SET: Readonly