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
26 changes: 26 additions & 0 deletions .changeset/11070-grid-field-keys-round8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@object-ui/types': minor
'@object-ui/fields': minor
'@object-ui/plugin-form': patch
---

The grid field reads each field-level key under the one spelling `GridFieldMetadata` declares (objectui#11070, round 8).

`GridFieldMetadata` declared `allow_reorder` and the docs taught it, while `GridField` read `reorderable`, so `allow_reorder: false` still drew a drag handle on every row. The widget also read its footer total under three spellings, and read four keys that no face declared. Each key now has one spelling, and that spelling is declared and read:

- **Reorder (`@object-ui/fields`).** `GridField` reads `allow_reorder`. `allow_reorder: false` removes the drag handles. The undeclared `reorderable` is no longer read; this round's census found no writer of it in either repository.
- **Total (`@object-ui/types`, `@object-ui/fields`).** `GridFieldMetadata` declares `total_field`, the one spelling the grid reads. It names the CHILD column summed into the footer, which is the spec's `amountField` (`inlineAmountField` on a `master_detail` field, `subforms[].amountField` on a form view). It is not the spec's `totalField`, the parent field a master-detail save writes the sum to. The `amount_field` and `amountField` reads beside it are retired: the same census found nothing writing either into the grid's config.
- **`add_label` (`@object-ui/types`).** Declared. `MasterDetailForm` writes it from a detail's `addLabel`, and it labels the grid's Add button.
- **`allow_duplicate` and `show_line_numbers` (`@object-ui/fields`)** are retired under ADR-0049. No face declared either, and the census found no producer of either. The behaviour their defaults gave stays: each row offers a duplicate action whenever rows can be added, and the line-number column always shows.
- **`sort_field` is unchanged.** `MasterDetailForm` still writes it from a detail's `sortField`, which is derived from the child object when not authored. The spec declares no inline sort-field key, so it stays read and undeclared, named in one place in `GridField`.
- **`record:line_items` total (`@object-ui/plugin-form`).** The panel shows its grid's footer total whenever `amountField` names the column to sum, the way `MasterDetailForm` already did. It used to show it only when `totalField` was also set.

`GridField` now types its config reads as `GridFieldMetadata`, so a read of a key the type does not declare fails to compile.

**Clause-②: yes (narrowing).** The published `GridFieldMetadata` face widens by two optional members, `total_field` and `add_label`. What the grid honours narrows: five keys it used to read are no longer read.

## ⚠️ BREAKING, priced as minor under the fixed group's version policy

- **Rendering.** A grid field written with `reorderable`, `amount_field`, `amountField`, `allow_duplicate` or `show_line_numbers` renders as if that key were absent. Fix: write `allow_reorder: false` to turn off drag reordering, and `total_field` to name the summed column. To turn off the duplicate action, turn off adding with `allow_add: false`; there is no switch for the line-number column.
- **Behaviour.** `allow_reorder: false` now removes the drag handles; before, it was ignored. A `record:line_items` panel with `amountField` and no `totalField` now shows the footer total of that column.
- **TypeScript.** A `GridFieldMetadata` literal carrying any of the five retired keys was already a compile error, and still is.
32 changes: 26 additions & 6 deletions content/docs/fields/grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ The Grid Field component provides an inline table for managing related records o
## Field Schema

A grid field is authored as `GridFieldMetadata` (`@object-ui/types`), which is the
source of truth for the key set: it extends `BaseFieldMetadata` with the column list
and the row-count and row-action limits. Each column is `@objectstack/spec`'s inline
grid column (`InlineGridColumn`, the element of the spec's `inlineColumns` list on a
`master_detail` field), typed by reference, so the columns are checked by the same
compiler that checks the field, and `objectui validate` refuses a column key the spec
does not declare.
source of truth for the key set: it extends `BaseFieldMetadata` with the column list,
the row-count and row-action limits, the footer total and the Add button's label.
Each column is `@objectstack/spec`'s inline grid column (`InlineGridColumn`, the
element of the spec's `inlineColumns` list on a `master_detail` field), typed by
reference, so the columns are checked by the same compiler that checks the field,
and `objectui validate` refuses a column key the spec does not declare.

```ts
import type { GridFieldMetadata } from '@object-ui/types';
Expand All @@ -38,15 +38,35 @@ const lineItems: GridFieldMetadata = {
{ name: 'product', label: 'Product', type: 'lookup', required: true, width: 240 },
{ name: 'quantity', label: 'Qty', type: 'number', width: 80 },
{ name: 'unit_price', label: 'Unit Price', type: 'currency', width: 120 },
{ name: 'amount', label: 'Amount', type: 'currency', computed: true, expr: 'quantity * unit_price' },
],
min_rows: 1,
max_rows: 50,
allow_add: true,
allow_delete: true,
allow_reorder: false,
total_field: 'amount',
add_label: 'Add line',
};
```

The field-level keys, each read under exactly this one spelling:

- `min_rows` / `max_rows` — the row-count limits.
- `allow_add` / `allow_delete` — whether rows can be added or removed. Each row's
duplicate action follows `allow_add`, since a duplicate is an add.
- `allow_reorder` — set `false` to remove the drag handles; rows can be reordered
by dragging otherwise.
- `total_field` — the `name` of the **child** column summed into the footer total.
It is the spec's `amountField`, not its `totalField` (the parent field a
master-detail save writes the sum to). No total shows when it is unset.
- `add_label` — the Add button's label.

A read-only or disabled grid offers no add, delete, duplicate or reorder, whatever
these keys say. The line-number column always shows. There is no `reorderable`,
`amount_field`, `amountField`, `allow_duplicate` or `show_line_numbers` key: none
is read, and `GridFieldMetadata` refuses each.

A column's `width` is a **number** of pixels. There is no per-column `editable`
key (whether cells can be edited follows the field's own read-only state) and no
per-column `defaultValue`: a new row starts with every cell empty.
Expand Down
165 changes: 165 additions & 0 deletions packages/fields/src/widgets/GridField.fieldKeys-11070.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* 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.
*/

/**
* The grid widget's FIELD-level keys: one spelling each, and that spelling is
* the one `GridFieldMetadata` (`@object-ui/types`) declares (objectui#11070,
* round 8).
*
* Before this round the published type and the widget disagreed in both
* directions: `GridFieldMetadata.allow_reorder` was declared and taught by the
* docs while the widget read `reorderable`, so `allow_reorder: false` still
* drew a drag handle on every row; the total was read under three spellings
* (`total_field`, `amount_field`, `amountField`); and four keys were read that
* no face declared (`allow_duplicate`, `show_line_numbers`, `add_label`, and
* `sort_field`, which this file does not cover — see the changeset).
*
* What each block pins:
*
* - reorder — `allow_reorder: false` removes the drag handle from every row,
* against a no-key control that draws one per row; the retired
* `reorderable` changes nothing;
* - total — `total_field` names the CHILD column summed into the footer
* (the spec's `amountField`, never its `totalField`); `amount_field` and
* `amountField` change nothing;
* - `add_label` — declared, and it labels the Add button;
* - `allow_duplicate` and `show_line_numbers` — retired under ADR-0049 (no
* producer in either repository wrote them): the widget keeps the
* behaviour their defaults gave, a duplicate action whenever rows can be
* added and a line-number column always.
*
* Every fixture is typed `GridFieldMetadata`, so `tsc -p tsconfig.test.json`
* (this package's `type-check`) also holds the declared key set: a declared
* key that stopped being a member reddens the fixture that writes it, and a
* retired key that came back as a member reddens its `@ts-expect-error`.
* A retired key reaches the widget only through a cast, which is how a
* document the compiler never saw would reach it.
*/

import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import type { GridFieldMetadata } from '@object-ui/types';
import { GridField } from './GridField';

const columns: NonNullable<GridFieldMetadata['columns']> = [
{ name: 'description', label: 'Description', type: 'text' },
{ name: 'amount', label: 'Amount', type: 'currency' },
];

const rows = [
{ description: 'A', amount: 10 },
{ description: 'B', amount: 20 },
];

/** A grid field as `GridFieldMetadata` declares it, plus the keys under test. */
function grid(keys: Partial<Omit<GridFieldMetadata, 'type' | 'name' | 'columns'>> = {}): GridFieldMetadata {
return { type: 'grid', name: 'lines', columns, ...keys };
}

/** A key the type does not declare, reaching the widget the way an unchecked document would. */
function withUndeclared(extra: Record<string, unknown>): GridFieldMetadata {
return { ...grid(), ...extra } as GridFieldMetadata;
}

function show(field: GridFieldMetadata) {
return render(<GridField value={rows} onChange={() => {}} field={field} />);
}

const dragHandles = () => screen.queryAllByTestId(/^line-items-drag-/).length;

describe('GridField field-level keys: the declared spelling is the read spelling (objectui#11070 round 8)', () => {
describe('reorder: `allow_reorder`', () => {
it('CONTROL: with no key, every row draws a drag handle', () => {
show(grid());
expect(dragHandles()).toBe(rows.length);
});

it('`allow_reorder: false` draws no drag handle on any row', () => {
show(grid({ allow_reorder: false }));
expect(dragHandles()).toBe(0);
});

it('the retired `reorderable` is not read: `reorderable: false` still draws every handle', () => {
show(withUndeclared({ reorderable: false }));
expect(dragHandles()).toBe(rows.length);
});
});

describe('total: `total_field` names the child column summed', () => {
it('`total_field` shows the footer total of that child column', () => {
show(grid({ total_field: 'amount' }));
expect(screen.getByTestId('line-items-total').textContent).toContain('30');
});

it('CONTROL: with no key there is no footer total', () => {
show(grid());
expect(screen.queryByTestId('line-items-total')).toBeNull();
});

it.each(['amount_field', 'amountField'])('the retired `%s` is not read: no footer total', (key) => {
show(withUndeclared({ [key]: 'amount' }));
expect(screen.queryByTestId('line-items-total')).toBeNull();
});
});

describe('`add_label`', () => {
it('labels the Add button', () => {
show(grid({ add_label: 'Add invoice line' }));
expect(screen.getByTestId('line-items-add').textContent).toContain('Add invoice line');
});
});

describe('`allow_duplicate` is retired: duplicate follows whether rows can be added', () => {
it('`allow_duplicate: false` is not read: each row keeps its duplicate action', () => {
show(withUndeclared({ allow_duplicate: false }));
expect(screen.queryAllByTestId(/^line-items-duplicate-/)).toHaveLength(rows.length);
});

it('CONTROL: `allow_add: false` removes the duplicate action with the Add action', () => {
show(grid({ allow_add: false }));
expect(screen.queryAllByTestId(/^line-items-duplicate-/)).toHaveLength(0);
});
});

describe('`show_line_numbers` is retired: the line-number column always shows', () => {
it('`show_line_numbers: false` is not read: the `#` column stays', () => {
show(withUndeclared({ show_line_numbers: false }));
expect(screen.getByRole('columnheader', { name: '#' })).toBeTruthy();
});
});
});

// ── The declared key set, held by the compiler (`tsc -p tsconfig.test.json`) ──

/** Every field-level key the widget reads, each typed as `GridFieldMetadata` declares it. */
const declared: GridFieldMetadata = {
type: 'grid',
name: 'lines',
columns,
min_rows: 1,
max_rows: 50,
allow_add: true,
allow_delete: true,
allow_reorder: false,
total_field: 'amount',
add_label: 'Add line',
};
void declared;

// @ts-expect-error objectui#11070 round 8: `reorderable` is retired; the reorder key is `allow_reorder`.
const retiredReorderable: GridFieldMetadata = { type: 'grid', name: 'lines', reorderable: false };
// @ts-expect-error objectui#11070 round 8: `amount_field` is retired; the summed child column is `total_field`.
const retiredAmountSnake: GridFieldMetadata = { type: 'grid', name: 'lines', amount_field: 'amount' };
// @ts-expect-error objectui#11070 round 8: `amountField` is retired on the widget; the summed child column is `total_field`.
const retiredAmountCamel: GridFieldMetadata = { type: 'grid', name: 'lines', amountField: 'amount' };
// @ts-expect-error objectui#11070 round 8: `allow_duplicate` is retired (ADR-0049); duplicate follows `allow_add`.
const retiredDuplicate: GridFieldMetadata = { type: 'grid', name: 'lines', allow_duplicate: false };
// @ts-expect-error objectui#11070 round 8: `show_line_numbers` is retired (ADR-0049); the line-number column always shows.
const retiredLineNumbers: GridFieldMetadata = { type: 'grid', name: 'lines', show_line_numbers: false };
void [retiredReorderable, retiredAmountSnake, retiredAmountCamel, retiredDuplicate, retiredLineNumbers];
Loading
Loading