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
7 changes: 7 additions & 0 deletions .changeset/line-grid-item-autofill.md
Original file line number Diff line number Diff line change
@@ -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)`.
34 changes: 21 additions & 13 deletions e2e/live/form-view-subforms.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand All @@ -26,7 +27,6 @@ test('New <object> 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();
Expand All @@ -37,14 +37,21 @@ test('New <object> 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),
Expand All @@ -59,9 +66,10 @@ test('New <object> 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);
});
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/GridField.test.tsx
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down Expand Up @@ -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);
});
Expand Down
58 changes: 51 additions & 7 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -569,6 +612,7 @@ export function GridField({
<LookupField
value={val}
onChange={(v: any) => 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}
/>
Expand Down
13 changes: 10 additions & 3 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading