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
15 changes: 15 additions & 0 deletions .changeset/line-grid-spreadsheet-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@object-ui/fields": minor
"@object-ui/plugin-form": minor
---

Spreadsheet-style line-item grid editor.

`GridField`'s editable grid mode is reworked into an enterprise line-item editor (the QuickBooks / Stripe / NetSuite pattern), generalised across every inline grid:

- **Computed read-only columns** — a child field with an arithmetic `expression` (e.g. `amount = quantity * unit_price`) renders read-only, recomputes live as its inputs change, and writes the result back into the row so it persists and the running total reflects it. A small safe arithmetic evaluator (`+ - * / %`, parens, `record.<field>` refs; no `eval`) powers it.
- **Trailing "ghost" row** — start-with-one + auto-append: typing in the ghost materialises a real row (index-stable, so focus/caret survive), so you keep entering lines without clicking "Add".
- **Borderless click-to-focus cells** + role-based column widths (description flexes; qty/price/amount stay narrow).
- **Keyboard navigation** — Enter / ArrowUp / ArrowDown move between rows in the same column.
- Per-row "expand to full form" is gated to grids that omit fields (no redundant expand on thin lines).
- `deriveColumns` surfaces a field `expression` as a computed column; the running-total column prefers the computed/last-currency column. Blank/ghost rows are filtered from the persisted batch (`isBlankRow`).
29 changes: 23 additions & 6 deletions e2e/live/form-view-subforms.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { test, expect } from '@playwright/test';
import { selectOption, fillLookup, addLineItem } from './helpers';
import { selectOption, fillLookup } from './helpers';

/**
* Tier 0 live e2e: an object's standard New/Edit modal renders inline child
* collections derived from the DATA MODEL (no view config, no bespoke page).
* `showcase_invoice_line.invoice` declares `inlineEdit: 'grid'`, so every
* standard Invoice form auto-renders a "Line Items" grid; "New Invoice" opens
* a master-detail modal that submits the header + its lines in one atomic
* /api/v1/batch. (An explicit `form.subforms` would override the derived one.)
* standard Invoice form auto-renders a spreadsheet-style "Line Items" grid;
* "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;
* • the computed read-only `amount = quantity × unit_price`, recomputed live
* and persisted in the batch (so the parent total rolls it up server-side).
*/
test('New <object> modal renders relationship-derived subforms and submits an atomic batch', async ({ page }) => {
const batches: any[] = [];
Expand All @@ -31,8 +37,14 @@ 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();

const row = await addLineItem(page);
await row.getByRole('textbox').first().fill('Widget A');
// Type straight into the ghost row — no "Add line" click needed.
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');

// The computed Amount column shows the live line total (read-only).
await expect(li.locator('[data-computed="amount"]').first()).toContainText('100');

await Promise.all([
page.waitForRequest((r) => r.url().includes('/api/v1/batch'), { timeout: 15_000 }).catch(() => null),
Expand All @@ -47,4 +59,9 @@ 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);
// 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);
});
43 changes: 0 additions & 43 deletions e2e/live/row-expand.spec.ts

This file was deleted.

64 changes: 62 additions & 2 deletions packages/fields/src/widgets/GridField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,77 @@ describe('GridField / LineItemsField — editable line items', () => {
it('editing a text cell emits the raw string', () => {
const onChange = vi.fn();
render(<GridField value={[{ description: '', amount: null }]} onChange={onChange} field={field} />);
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Taxi' } });
// [0] = the data row ([1] would be the always-present trailing ghost row).
fireEvent.change(screen.getAllByLabelText('Description')[0], { target: { value: 'Taxi' } });
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: null }]);
});

it('editing a currency cell coerces to a number', () => {
const onChange = vi.fn();
render(<GridField value={[{ description: 'Taxi', amount: null }]} onChange={onChange} field={field} />);
fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '42.5' } });
fireEvent.change(screen.getAllByLabelText('Amount')[0], { target: { value: '42.5' } });
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: 42.5 }]);
});

describe('trailing ghost row (start-with-one + auto-append)', () => {
it('renders a trailing empty row so an empty grid still has one input line', () => {
render(<GridField value={[]} onChange={() => {}} field={field} />);
// No "No items" empty-state in grid mode — the ghost row IS the first line.
expect(screen.getByText('Description')).toBeTruthy();
expect(screen.getAllByLabelText('Description')).toHaveLength(1); // just the ghost
});

it('typing in the ghost row materialises a new row (no Add click needed)', () => {
const onChange = vi.fn();
render(<GridField value={[{ description: 'A', amount: 1 }]} onChange={onChange} field={field} />);
const inputs = screen.getAllByLabelText('Description');
expect(inputs).toHaveLength(2); // data row + ghost
fireEvent.change(inputs[1], { target: { value: 'B' } }); // type in the ghost
expect(onChange).toHaveBeenCalledWith([
{ description: 'A', amount: 1 },
{ description: 'B', amount: null },
]);
});
});

describe('computed columns (amount = qty × unit_price)', () => {
const computedField = {
columns: [
{ field: 'product', label: 'Product', type: 'text' as const },
{ field: 'quantity', label: 'Qty', type: 'number' as const },
{ field: 'unit_price', label: 'Unit Price', type: 'currency' as const },
{ field: 'amount', label: 'Amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price', scale: 2 },
],
total_field: 'amount',
} as any;

it('renders a computed column read-only (no input) and recomputes on edit', () => {
const onChange = vi.fn();
render(<GridField value={[{ product: 'Widget', quantity: 3, unit_price: 10, amount: 30 }]} onChange={onChange} field={computedField} />);
// Amount is display-only — there is no editable Amount cell.
expect(screen.queryByLabelText('Amount')).toBeNull();
// Editing quantity recomputes amount in the emitted row.
fireEvent.change(screen.getAllByLabelText('Qty')[0], { target: { value: '4' } });
expect(onChange).toHaveBeenCalledWith([{ product: 'Widget', quantity: 4, unit_price: 10, amount: 40 }]);
});

it('shows a dash for a computed cell whose inputs are blank', () => {
render(<GridField value={[{ product: 'Widget', quantity: null, unit_price: null, amount: null }]} onChange={() => {}} field={computedField} />);
// The computed amount cell reads "—" until its inputs exist.
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
});
});

describe('keyboard navigation', () => {
it('Enter moves focus to the same column in the next row', () => {
render(<GridField value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]} onChange={() => {}} field={field} />);
const row0 = screen.getAllByLabelText('Description')[0];
row0.focus();
fireEvent.keyDown(row0, { key: 'Enter' });
expect(document.activeElement).toBe(screen.getAllByLabelText('Description')[1]);
});
});

it('removing a row emits the array without it', () => {
const onChange = vi.fn();
render(
Expand Down
Loading
Loading