From 2cb39bf4529dd22c180173686cf39c6845c6d4f0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:59:42 +0800 Subject: [PATCH] feat(grid): item-typeahead auto-fill from a lookup column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a lookup cell's record is selected, GridField copies the record's fields into any sibling column of the same name (e.g. a product's unit_price + description), then recomputes computed columns — the catalog-typeahead every invoicing tool has (QuickBooks / Stripe / NetSuite). Opt out per column with `autofill: false`. - LookupField: optional `onSelectRecord(record)` surfaces the full selected record (not just its id); drives the update in single-select. - GridField: pure exported `lookupAutofillPatch(columns, col, record)` builds the FK-id + matching-siblings patch; wired into the lookup cell. - Live e2e: New Invoice picks a catalog product → description + unit_price auto-fill, amount computes, and all of it persists in the atomic batch. Co-Authored-By: Claude Opus 4.8 --- .changeset/line-grid-item-autofill.md | 7 +++ e2e/live/form-view-subforms.spec.ts | 34 ++++++----- .../fields/src/widgets/GridField.test.tsx | 26 ++++++++- packages/fields/src/widgets/GridField.tsx | 58 ++++++++++++++++--- packages/fields/src/widgets/LookupField.tsx | 13 ++++- 5 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 .changeset/line-grid-item-autofill.md diff --git a/.changeset/line-grid-item-autofill.md b/.changeset/line-grid-item-autofill.md new file mode 100644 index 000000000..867514ea5 --- /dev/null +++ b/.changeset/line-grid-item-autofill.md @@ -0,0 +1,7 @@ +--- +"@object-ui/fields": minor +--- + +Line-item grid: item-typeahead auto-fill from a lookup column. + +When a lookup cell's record is picked, `GridField` now copies any of the chosen record's fields whose names match a sibling column (e.g. a product's `unit_price` / `description` drop into the row), then recomputes computed columns — the catalog-typeahead behaviour of QuickBooks / Stripe / NetSuite. Opt out per column with `autofill: false`. `LookupField` gains an optional `onSelectRecord(record)` callback that surfaces the full selected record (not just its id). New pure export `lookupAutofillPatch(columns, col, record)`. diff --git a/e2e/live/form-view-subforms.spec.ts b/e2e/live/form-view-subforms.spec.ts index f65eb964c..398786951 100644 --- a/e2e/live/form-view-subforms.spec.ts +++ b/e2e/live/form-view-subforms.spec.ts @@ -9,9 +9,10 @@ import { selectOption, fillLookup } from './helpers'; * "New Invoice" opens a master-detail modal that submits the header + its * lines in one atomic /api/v1/batch. * - * This also exercises the modern grid mechanics: - * • the always-present trailing "ghost" row — you type straight into it, no - * "Add line" click, and it auto-appends the next blank line; + * This exercises the full modern grid: + * • the trailing "ghost" row — type straight into it, no "Add line" click; + * • a Product catalog typeahead whose selection auto-fills the line's + * description + unit_price (matching field names); * • the computed read-only `amount = quantity × unit_price`, recomputed live * and persisted in the batch (so the parent total rolls it up server-side). */ @@ -26,7 +27,6 @@ test('New modal renders relationship-derived subforms and submits an at await page.goto('/apps/showcase_app/showcase_invoice'); await page.getByRole('button', { name: /^New$/i }).first().click(); - // The standard create modal is now a master-detail form (no custom page). const dialog = page.getByRole('dialog'); await expect(dialog.getByTestId('md-form-submit')).toBeVisible(); await expect(dialog.getByText('Line Items', { exact: false })).toBeVisible(); @@ -37,14 +37,21 @@ test('New modal renders relationship-derived subforms and submits an at await selectOption(page, 'status', 'draft'); await expect(dialog.getByText('Northwind', { exact: false }).first()).toBeVisible(); - // Type straight into the ghost row — no "Add line" click needed. + // Pick a catalog product in the ghost row — its description + unit_price + // auto-fill, and the row materialises (no "Add line" click). const li = dialog.getByTestId('line-items'); - await li.locator('input[aria-label="Product"]').first().fill('Widget A'); - await li.locator('input[aria-label="Qty"]').first().fill('2'); - await li.locator('input[aria-label="Unit Price"]').first().fill('50'); + await li.getByTestId('lookup-trigger').first().click(); + const option = page.getByRole('option', { name: /Widget A/i }).first(); + await option.waitFor({ state: 'visible' }); + await option.click(); + + // Auto-filled from the product record. + await expect(li.locator('input[aria-label="Unit Price"]').first()).toHaveValue('29.99'); + await expect(li.locator('input[aria-label="Description"]').first()).toHaveValue('Standard widget'); - // The computed Amount column shows the live line total (read-only). - await expect(li.locator('[data-computed="amount"]').first()).toContainText('100'); + await li.locator('input[aria-label="Qty"]').first().fill('2'); + // Computed Amount = 2 × 29.99 (read-only). + await expect(li.locator('[data-computed="amount"]').first()).toContainText('59.98'); await Promise.all([ page.waitForRequest((r) => r.url().includes('/api/v1/batch'), { timeout: 15_000 }).catch(() => null), @@ -59,9 +66,10 @@ test('New modal renders relationship-derived subforms and submits an at expect(ops[0].data.status).toBe('draft'); const child = ops.find((o: any) => o.object === 'showcase_invoice_line'); expect(child?.data?.invoice).toEqual({ $ref: 0 }); - expect(child?.data?.product).toBe('Widget A'); - // The client-computed amount (2 × 50) is persisted, so the server total rolls it up. - expect(Number(child?.data?.amount)).toBe(100); + expect(child?.data?.product).toBeTruthy(); // the chosen product id + expect(child?.data?.description).toBe('Standard widget'); // auto-filled + expect(Number(child?.data?.unit_price)).toBe(29.99); // auto-filled + expect(Number(child?.data?.amount)).toBe(59.98); // computed (2 × 29.99) // The empty ghost line must NOT have been persisted as a blank child. expect(ops.filter((o: any) => o.object === 'showcase_invoice_line')).toHaveLength(1); }); diff --git a/packages/fields/src/widgets/GridField.test.tsx b/packages/fields/src/widgets/GridField.test.tsx index 1e50d0a59..57c4ae4ec 100644 --- a/packages/fields/src/widgets/GridField.test.tsx +++ b/packages/fields/src/widgets/GridField.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; -import { GridField, LineItemsField, sumColumn } from './GridField'; +import { GridField, LineItemsField, sumColumn, lookupAutofillPatch } from './GridField'; const columns = [ { field: 'description', label: 'Description', type: 'text' as const }, @@ -199,6 +199,30 @@ describe('GridField / LineItemsField — editable line items', () => { expect(screen.getByText('30')).toBeTruthy(); }); + describe('lookupAutofillPatch (item typeahead auto-fill)', () => { + const cols = [ + { field: 'product', type: 'lookup' as const, reference: 'product' }, + { field: 'description', type: 'text' as const }, + { field: 'quantity', type: 'number' as const }, + { field: 'unit_price', type: 'currency' as const }, + { field: 'amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price' }, + ]; + const product = { value: 'p1', label: 'Widget A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, sku: 'WIDGET-A' }; + + it('sets the FK id and copies same-named sibling fields from the record', () => { + const patch = lookupAutofillPatch(cols, cols[0], product); + expect(patch).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 29.99 }); + // quantity (not on the record) and computed amount are left to the row/compute. + expect(patch).not.toHaveProperty('quantity'); + expect(patch).not.toHaveProperty('amount'); + }); + + it('copies only the FK id when autofill is disabled', () => { + const patch = lookupAutofillPatch(cols, { ...cols[0], autofill: false }, product); + expect(patch).toEqual({ product: 'p1' }); + }); + }); + it('sumColumn ignores blanks and NaN', () => { expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3); }); diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index 8d4a2a141..89edf222f 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -71,6 +71,10 @@ export interface GridColumn { expr?: string; /** Decimal places to round a computed numeric/currency result to. */ scale?: number; + /** For `type: 'lookup'` — when a record is picked, copy its fields into any + * sibling columns of the same name (e.g. a product's unit_price/description). + * On by default for lookup columns; set `false` to disable the auto-fill. */ + autofill?: boolean; } type Row = Record; @@ -196,6 +200,26 @@ export function evalArith(expr: string, row: Row): number | null { * inputs, returning a new row. Called after each edit so computed columns and * the running total stay live, and so the computed value persists in the batch. */ +/** + * Build the row patch for a lookup-cell selection: set the FK column to the + * chosen record's id, and (unless the column opts out with `autofill: false`) + * copy any of the record's fields whose names match a sibling column — the + * 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. + */ +export function lookupAutofillPatch(columns: GridColumn[], col: GridColumn, record: any): Row { + const patch: Row = { [col.field]: record?.value ?? record?.id ?? record?._id }; + if (col.autofill !== false && record && typeof record === 'object') { + for (const other of columns) { + if (other.field === col.field || other.computed || other.type === 'lookup') continue; + const v = record[other.field]; + if (v !== undefined && v !== null && v !== '') patch[other.field] = v; + } + } + return patch; +} + export function computeRow(columns: GridColumn[], row: Row): Row { const computedCols = columns.filter((c) => c.computed && c.expr); if (computedCols.length === 0) return row; @@ -315,24 +339,43 @@ export function GridField({ }, [columns]); /** - * Apply a single cell change. `rowIdx === rows.length` targets the trailing - * "ghost" row (always-present empty line) — editing it materialises a new - * real row, so the user never has to click "Add line" to keep entering. + * Merge a field patch into a row. `rowIdx === rows.length` targets the + * trailing "ghost" row (always-present empty line) — editing it materialises + * a new real row, so the user never has to click "Add line" to keep entering. * Computed columns are recomputed for the touched row on every change. */ - const applyCell = useCallback( - (rowIdx: number, field: string, value: any) => { + const applyPatch = useCallback( + (rowIdx: number, patch: Row) => { const isGhost = rowIdx >= rows.length; if (isGhost) { if (maxRows != null && rows.length >= maxRows) return; - emit([...rows, computeRow(columns, { ...blankRow(), [field]: value })]); + emit([...rows, computeRow(columns, { ...blankRow(), ...patch })]); return; } - emit(rows.map((r, i) => (i === rowIdx ? computeRow(columns, { ...r, [field]: value }) : r))); + emit(rows.map((r, i) => (i === rowIdx ? computeRow(columns, { ...r, ...patch }) : r))); }, [rows, columns, maxRows, blankRow, emit], ); + const applyCell = useCallback( + (rowIdx: number, field: string, value: any) => applyPatch(rowIdx, { [field]: value }), + [applyPatch], + ); + + /** + * A lookup cell selection — set the FK id and auto-fill any sibling cells + * whose column name matches a field on the chosen record (and isn't itself a + * 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`. + */ + const applyLookupSelection = useCallback( + (rowIdx: number, col: GridColumn, record: any) => { + applyPatch(rowIdx, lookupAutofillPatch(columns, col, record)); + }, + [columns, applyPatch], + ); + /** Set a cell to an already-typed value (lookup ids, etc.) without coercion. */ const setCellValue = useCallback( (rowIdx: number, field: string, value: any) => applyCell(rowIdx, field, value), @@ -569,6 +612,7 @@ export function GridField({ setCellValue(rowIdx, c.field, v)} + onSelectRecord={(rec: any) => applyLookupSelection(rowIdx, c, rec)} field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField, multiple: c.multiple, options: c.options, placeholder: '—' } as any} disabled={disabled} /> diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index e02fda6de..d6613121b 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -467,23 +467,30 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel ? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean) : value ? [findOption(value)].filter(Boolean) : []; + // Optional: receive the FULL selected record (not just its id) so a host can + // auto-fill sibling fields from it — e.g. a line-item grid copying a product's + // unit_price/description when the item is chosen. When provided (single + // select), it drives the update and the host owns the resulting value change. + const onSelectRecord = (props as any).onSelectRecord as ((record: LookupOption) => void) | undefined; + const handleSelect = useCallback( (option: LookupOption) => { if (multiple) { const currentValues = Array.isArray(value) ? value : []; const isSelected = currentValues.includes(option.value); - + if (isSelected) { onChange(currentValues.filter((v: any) => v !== option.value)); } else { onChange([...currentValues, option.value]); } } else { - onChange(option.value); + if (onSelectRecord) onSelectRecord(option); + else onChange(option.value); setIsOpen(false); } }, - [multiple, value, onChange], + [multiple, value, onChange, onSelectRecord], ); const handleRemove = (optionValue: any) => {