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
33 changes: 33 additions & 0 deletions .changeset/11610-grid-keys-camelcase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@object-ui/types': minor
'@object-ui/fields': minor
---

The `grid` field's eight field-level keys are camelCase now, and their snake_case spellings are retired and refused by name on every face (objectui#11610).

BREAKING (`@object-ui/types`, `@object-ui/fields`): a `grid` field's metadata, and a `form` `fields[]` entry of `type: 'grid'`, must spell these keys in camelCase. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.)

- FROM `min_rows` → TO `minRows`
- FROM `max_rows` → TO `maxRows`
- FROM `allow_add` → TO `allowAdd`
- FROM `allow_delete` → TO `allowDelete`
- FROM `allow_reorder` → TO `allowReorder`
- FROM `total_field` → TO `totalField`
- FROM `add_label` → TO `addLabel`
- FROM `sort_field` → TO `sortField`

Why: `@objectstack/spec`'s runtime form field declares config keys in camelCase only, so it could not declare these keys as they were written (objectstack-ai/objectstack#21704, fork 2, ruled B). There is no alias window and no dual read: no reader reads the snake_case spellings any more, and no stored producer outside this repository's own fixtures, which move with this change, was found to write them.

**Migration.** Rename each key; its value stays the same. `totalField` keeps its meaning: the CHILD column summed into the grid's footer, which is the value a spec `amountField` carries. It is not the parent field the spec's own `totalField` names on a master-detail subform.

What each face does with a snake_case key now:

- **TypeScript.** `GridFieldMetadata` and `FormField` declare each as a `never` member, so an authored value no longer compiles. The camelCase members carry the value types the snake_case members had, and `FormField` still takes each one by reference to `GridFieldMetadata`.
- **zod (`@object-ui/types/zod`).** NARROWS on the tolerant face (`safeValidateSchema`, which `objectui validate` runs) and on the strict authoring face: a form field entry carrying a snake_case key used to parse with the value kept, and is now refused with one `invalid_type` issue at that key. The message leads with ``Did you mean `min_rows` → `minRows`?`` (each key names its own replacement). WIDENS on both faces: the camelCase keys parse, judged by the same value types.
- **The `grid` widget (`@object-ui/fields`).** `GridField` reads the camelCase keys only. A field whose metadata still carries a snake_case key is drawn as an inline alert naming each retired key beside its replacement (`role="alert"`, `data-testid="grid-field-retired-keys"`) instead of the grid, and the same text goes to `console.error` once. Nothing is thrown, so the rest of the form still draws, and the rows are not changed.

New export from `@object-ui/types`: `GRID_FIELD_RETIRED_KEYS`, the snake_case to camelCase map that the zod refusals and the widget both read, with its key type `GridFieldRetiredKey`.

`@object-ui/plugin-form`'s master-detail and line-items adapters now hand the grid the camelCase keys, typed against `GridFieldMetadata` instead of cast through `any`. What they draw does not change.

**Clause-②: yes (narrowing)**: the camelCase spellings widen each face, and the snake_case spellings narrow it.
Original file line number Diff line number Diff line change
Expand Up @@ -3034,7 +3034,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'object-master-detail-form.details': {
file: 'packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx',
pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object, each RENAMED on the way (`minRows` to `min_rows`, `maxRows` to `max_rows`, `addLabel` to `add_label`, `amountField` to `total_field`), beside `columns` and the DERIVED `sort_field` (objectui#11070 round 9 retired the `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own snake_case spelling) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. The spec row is `z.array(z.unknown())`, whose description names five of the eleven members the renderer reads, and the registration declares no `of`, so the read site is the whole member contract. New file (objectui#8071 slice 18).',
pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object under the grid\'s camelCase keys (objectui#11610): `minRows`, `maxRows` and `addLabel` by the same name, and `amountField` RENAMED to the grid\'s `totalField` (the CHILD column summed; the detail\'s own `totalField`, the PARENT field, is not forwarded), beside `columns` and the DERIVED `sortField` (objectui#11070 round 9 retired the detail\'s `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own spelling, `allowAdd`) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. The spec row is `z.array(z.unknown())`, whose description names five of the eleven members the renderer reads, and the registration declares no `of`, so the read site is the whole member contract. New file (objectui#8071 slice 18).',
},
'object-master-detail-form.fields': {
file: 'packages/plugin-form/src/__tests__/topLevelFieldsWarnCoverage-8847.test.tsx',
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/dev/DevLookup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const DevLookup: React.FC = () => {
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'amount', label: 'Amount', type: 'currency' },
],
total_field: 'amount',
totalField: 'amount',
} as any;
return (
<div className="mx-auto max-w-3xl space-y-4 p-6">
Expand Down
56 changes: 36 additions & 20 deletions content/docs/fields/grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,30 @@ const lineItems: GridFieldMetadata = {
{ 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',
sort_field: 'position',
minRows: 1,
maxRows: 50,
allowAdd: true,
allowDelete: true,
allowReorder: false,
totalField: 'amount',
addLabel: 'Add line',
sortField: 'position',
};
```

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
- `minRows` / `maxRows` — the row-count limits.
- `allowAdd` / `allowDelete` — whether rows can be added or removed. Each row's
duplicate action follows `allowAdd`, since a duplicate is an add.
- `allowReorder` — 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.
- `sort_field` — the name of a field on each **row** that the grid stamps with the
- `totalField` — the `name` of the **child** column summed into the footer total.
It carries the spec's `amountField`. The spec's own `totalField` on a
master-detail subform is a different thing with the same name: the parent field
the save writes the sum to. No total shows when it is unset.
- `addLabel` — the Add button's label.
- `sortField` — the name of a field on each **row** that the grid stamps with the
row's index (0, 1, 2, …) on every change, so the order a drag-reorder leaves is
saved with the rows. It is not one of the columns: a column of that name would
have its typed value overwritten. Rows carry no position when it is unset. When
Expand Down Expand Up @@ -95,6 +96,21 @@ per-column `defaultValue`: a new row starts with every cell empty.
The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).

### Retired snake_case keys

Until objectui#11610 these eight keys were spelled in snake_case: `min_rows`,
`max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`,
`add_label` and `sort_field`. Each is now refused by name, with the camelCase key
to write instead, and its value carries over unchanged:

- `GridFieldMetadata` and `FormField` declare each as a `never` member, so
TypeScript refuses it at the authoring site.
- `objectui validate` refuses it on a `form` field entry with
``Did you mean `min_rows` → `minRows`?`` (and so on for each key).
- The `grid` widget draws an alert naming the retired keys and their replacements
instead of the grid, and logs the same text with `console.error`. It does not
quietly draw a grid that ignores them.

## Column Types

A column's `type` is one of the spec's nine cell controls: `text`, `number`,
Expand Down Expand Up @@ -329,9 +345,9 @@ it) and acts on them as records. What each one reads:
| Feature | `grid` field (`@object-ui/fields`) | `object-grid` (`@object-ui/plugin-grid`) |
| --- | --- | --- |
| Inline editing | Every cell but a computed one is its column's control, unless the field is read-only or disabled | `editable: true` (off by default), only where the current user may edit the object |
| Add and remove rows | `allow_add` and `allow_delete`, each on unless `false` | An add-record row with `operations.create`, and a row's Delete with `operations.delete` and the host's `onDelete`, each only where the current user may create or delete the object's records |
| Add and remove rows | `allowAdd` and `allowDelete`, each on unless `false` | An add-record row with `operations.create`, and a row's Delete with `operations.delete` and the host's `onDelete`, each only where the current user may create or delete the object's records |
| Sorting and filtering | None | Column-header sorting (a column opts out with `sortable: false`), the query's `filter` and `sort`, and search over `searchableFields` |
| Drag-and-drop reordering | Rows, with `allow_reorder` (on unless `false`); `sort_field` keeps the order on save | Columns only, with `reorderableColumns: true`; rows are not reordered |
| Drag-and-drop reordering | Rows, with `allowReorder` (on unless `false`); `sortField` keeps the order on save | Columns only, with `reorderableColumns: true`; rows are not reordered |
| Export | None | `exportOptions` |
| Computed columns | A column with `computed: true` and an arithmetic `expr` over the row's other cells | None: a formula field's value is shown as the server computed it |
| Totals | One footer total, the sum of the `total_field` column | A footer summary per column (`columns[].summary`), and aggregates on group headers (`aggregations`) |
| Totals | One footer total, the sum of the `totalField` column | A footer summary per column (`columns[].summary`), and aggregates on group headers (`aggregations`) |
10 changes: 5 additions & 5 deletions content/docs/rfcs/0001-clipboard-paste.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export interface UsePasteToGridOptions {
rows: any[];
/** Currently selected range, or null when nothing selected */
selection?: CellRange | null;
/** Cap rows after paste (e.g. max_rows from GridFieldMetadata) */
/** Cap rows after paste (e.g. maxRows from GridFieldMetadata) */
maxRows?: number;
/** Whether append-beyond-selection is allowed */
allowAppend?: boolean;
Expand Down Expand Up @@ -344,7 +344,7 @@ Dialog layout (Shadcn `Dialog` + `Table`):
* **Row** — row is valid iff all mapped cells are `ok`. Invalid rows are
dropped from the apply set but **kept in the preview** so the user can
cancel and fix in the source spreadsheet.
* **Table** — `max_rows` cap enforced: if `currentRows + valid > max_rows`,
* **Table** — `maxRows` cap enforced: if `currentRows + valid > maxRows`,
surface "Only first K rows will be appended" in the status bar.

### 6.4 Quick-paste optimisation (deferred to v1.1)
Expand Down Expand Up @@ -422,8 +422,8 @@ const { onPaste, previewDialog } = usePasteToGrid({
columns: coercersFromGridFieldColumns(field.columns),
rows: value ?? [],
selection,
allowAppend: field.allow_add !== false,
maxRows: field.max_rows,
allowAppend: field.allowAdd !== false,
maxRows: field.maxRows,
preview: true,
onApply: (commands) => {
onChange(applyCommands(value ?? [], commands));
Expand Down Expand Up @@ -468,7 +468,7 @@ RFC is marked Accepted.
| `parseClipboard` | Vitest | 100% branch — covers TSV, CSV, mixed newlines, BOM, quoted with `""`, quoted with embedded newlines, single-cell, all-empty |
| `coerceCell` | Vitest | 100% per supported type — locale-sensitive numbers, currency, percent, ISO date, Excel serial, boolean dictionary |
| `usePasteToGrid` | RTL + Vitest | Selection-aware mode decision, header detection, mapping override, error rollup |
| `PastePreviewDialog` | RTL + Vitest | Empty preview, all-valid, mixed valid/invalid, all-invalid, skipped columns, max_rows clamp |
| `PastePreviewDialog` | RTL + Vitest | Empty preview, all-valid, mixed valid/invalid, all-invalid, skipped columns, maxRows clamp |
| `ObjectGrid` integration | RTL + Playwright e2e | Paste flow end-to-end, staged toolbar, feature flag off path |
| Cross-browser clipboard | Playwright | Chromium + WebKit (Safari has known clipboard quirks) |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@
"type": "currency"
}
],
"min_rows": 1,
"max_rows": 10,
"allow_add": true,
"allow_delete": true,
"allow_reorder": true,
"total_field": "amount",
"add_label": "Add line item",
"sort_field": "position"
"minRows": 1,
"maxRows": 10,
"allowAdd": true,
"allowDelete": true,
"allowReorder": true,
"totalField": "amount",
"addLabel": "Add line item",
"sortField": "position"
}
]
}
Loading
Loading