diff --git a/.changeset/11610-grid-keys-camelcase.md b/.changeset/11610-grid-keys-camelcase.md new file mode 100644 index 0000000000..b190a6e41b --- /dev/null +++ b/.changeset/11610-grid-keys-camelcase.md @@ -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. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 205e5f9ca4..85a5635b1f 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3034,7 +3034,7 @@ const MEMBER_PINS: Record = { }, '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', diff --git a/apps/console/src/dev/DevLookup.tsx b/apps/console/src/dev/DevLookup.tsx index 9733758250..d62b897bcd 100644 --- a/apps/console/src/dev/DevLookup.tsx +++ b/apps/console/src/dev/DevLookup.tsx @@ -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 (
diff --git a/content/docs/fields/grid.mdx b/content/docs/fields/grid.mdx index 294cb582b9..03ec5c33f2 100644 --- a/content/docs/fields/grid.mdx +++ b/content/docs/fields/grid.mdx @@ -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 @@ -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`, @@ -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`) | diff --git a/content/docs/rfcs/0001-clipboard-paste.md b/content/docs/rfcs/0001-clipboard-paste.md index 1e918ef185..96ce558b76 100644 --- a/content/docs/rfcs/0001-clipboard-paste.md +++ b/content/docs/rfcs/0001-clipboard-paste.md @@ -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; @@ -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) @@ -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)); @@ -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) | diff --git a/examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json b/examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json index 07c4369d61..58338c60f6 100644 --- a/examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json +++ b/examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json @@ -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" } ] } diff --git a/examples/schema-catalog/test/fields-grid-keys-camelcase-11610.test.tsx b/examples/schema-catalog/test/fields-grid-keys-camelcase-11610.test.tsx new file mode 100644 index 0000000000..0a37360ed3 --- /dev/null +++ b/examples/schema-catalog/test/fields-grid-keys-camelcase-11610.test.tsx @@ -0,0 +1,120 @@ +/** + * The grid catalog example's field-level keys are camelCase, and they draw + * through the real form path (objectui#11610). + * + * `fields-grid/line-items-grid` is the stored document the grid doc page shows + * under "Field-Level Keys". objectui#11610 renamed the grid widget's eight + * field-level keys from snake_case to camelCase and refused the old spellings + * by name, so this fixture moved with them. This file drives it through the + * real `SchemaRenderer`, the real `form` renderer (which hands each field + * widget the entry itself as its metadata carrier) and the real + * `@object-ui/fields` registration, and checks what the grid DRAWS: + * + * - the document validates on the strict authoring face; + * - `addLabel` labels the Add button, `totalField` draws the footer total, + * `allowReorder` / `allowDelete` / `allowAdd` leave their controls on + * (each row has a drag handle, a Remove and a Duplicate), and `minRows` + * / `maxRows` leave them enabled at two rows; + * - the CONTROL: the same document with its keys respelled in snake_case is + * refused on the strict face by name, and the form draws the grid's named + * refusal instead of the grid. Without it, a renderer that ignored both + * spellings would pass the first two rows as well. + * + * Module-scope import of `@object-ui/fields`, not `beforeAll` (AGENTS.md + * §测试纪律): the widgets sit behind `React.lazy`, so paying the import here + * keeps the lazy factories out of every test timeout budget. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@object-ui/components'; +import '@object-ui/fields'; +import { SchemaRenderer } from '@object-ui/react'; +import { GRID_FIELD_RETIRED_KEYS } from '@object-ui/types'; +import { StrictAnyComponentSchema } from '@object-ui/types/zod'; +import { allExamples } from '../src/index.js'; + +const ID = 'fields-grid/line-items-grid'; + +type Entry = Record & { name: string; type: string }; +interface FormNode { + type: 'form'; + fields: Entry[]; + [key: string]: unknown; +} + +function example(): FormNode { + const found = allExamples().find((e) => e.id === ID); + expect(found, `${ID} is missing from the catalog`).toBeTruthy(); + // A deep copy: the control case edits its own schema. + return JSON.parse(JSON.stringify(found!.schema)) as FormNode; +} + +/** The same document with every camelCase grid key respelled the way it was before objectui#11610. */ +function asSnakeCase(schema: FormNode): FormNode { + const entry = schema.fields[0]; + for (const [snake, camel] of Object.entries(GRID_FIELD_RETIRED_KEYS)) { + if (camel in entry) { + entry[snake] = entry[camel]; + delete entry[camel]; + } + } + return schema; +} + +describe('fields-grid/line-items-grid writes the camelCase keys, and they draw (objectui#11610)', () => { + let error: ReturnType; + beforeEach(() => { + error = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + error.mockRestore(); + }); + + it('the entry carries all eight camelCase keys and no snake_case one', () => { + const entry = example().fields[0]; + expect(entry.type).toBe('grid'); + for (const [snake, camel] of Object.entries(GRID_FIELD_RETIRED_KEYS)) { + expect(entry, camel).toHaveProperty(camel); + expect(entry, snake).not.toHaveProperty(snake); + } + }); + + it('validates on the strict authoring face', () => { + expect(StrictAnyComponentSchema.safeParse(example()).success).toBe(true); + }); + + it('draws its label, total and row controls through the real form renderer', async () => { + render(); + // `waitFor`: the grid widget is a `React.lazy` boundary. + const add = await waitFor(() => screen.getByTestId('line-items-add') as HTMLButtonElement); + expect(add.textContent).toBe('Add line item'); + // `maxRows: 10` at two rows: Add stays enabled. + expect(add.disabled).toBe(false); + // `totalField: 'amount'`: 59.98 + 49.99. + expect(screen.getByTestId('line-items-total').textContent).toContain('109.97'); + // `allowReorder` / `allowDelete` / `allowAdd` all true: each row keeps its controls. + expect(screen.queryAllByTestId(/^line-items-drag-/)).toHaveLength(2); + expect(screen.queryAllByTestId(/^line-items-duplicate-/)).toHaveLength(2); + const removes = screen.queryAllByTestId(/^line-items-remove-/) as HTMLButtonElement[]; + expect(removes).toHaveLength(2); + // `minRows: 1` at two rows: Remove stays enabled. + expect(removes.every((b) => !b.disabled)).toBe(true); + expect(screen.queryByTestId('grid-field-retired-keys')).toBeNull(); + }); + + it('CONTROL: respelled in snake_case, it is refused by name on the strict face and drawn as the refusal', async () => { + const snake = asSnakeCase(example()); + const parsed = StrictAnyComponentSchema.safeParse(snake); + expect(parsed.success).toBe(false); + const paths = parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')); + expect(paths.sort()).toEqual(Object.keys(GRID_FIELD_RETIRED_KEYS).map((k) => `fields.0.${k}`).sort()); + + render(); + const alert = await waitFor(() => screen.getByTestId('grid-field-retired-keys')); + expect(alert.textContent).toContain('Grid field `line_items`'); + expect(alert.textContent).toContain('`total_field` → `totalField`'); + expect(screen.queryByTestId('line-items-add')).toBeNull(); + expect(screen.queryByTestId('line-items-total')).toBeNull(); + }); +}); diff --git a/packages/fields/src/widgets/GridField.displayLocale-9909.test.tsx b/packages/fields/src/widgets/GridField.displayLocale-9909.test.tsx index 09031b73c7..3801b1fc73 100644 --- a/packages/fields/src/widgets/GridField.displayLocale-9909.test.tsx +++ b/packages/fields/src/widgets/GridField.displayLocale-9909.test.tsx @@ -61,7 +61,7 @@ const rows = [ { description: 'Widget', qty: 1234.5, amount: 98765.25 }, { description: 'Gadget', qty: 1, amount: 1000 }, ]; -const field = { columns, total_field: 'amount' } as never; +const field = { columns, totalField: 'amount' } as never; interface Surface { name: string; diff --git a/packages/fields/src/widgets/GridField.fieldKeys-11070.test.tsx b/packages/fields/src/widgets/GridField.fieldKeys-11070.test.tsx index f4f2ab946d..fb41b307c9 100644 --- a/packages/fields/src/widgets/GridField.fieldKeys-11070.test.tsx +++ b/packages/fields/src/widgets/GridField.fieldKeys-11070.test.tsx @@ -9,7 +9,9 @@ /** * The grid widget's FIELD-level keys: one spelling each, and that spelling is * the one `GridFieldMetadata` (`@object-ui/types`) declares (objectui#11070, - * rounds 8 and 9). + * rounds 8 and 9). objectui#11610 renamed the eight keys to camelCase; this + * file uses the new spellings, and the retired snake_case ones are pinned in + * `GridField.retiredSnakeKeys-11610.test.tsx`. * * Before this round the published type and the widget disagreed in both * directions: `GridFieldMetadata.allow_reorder` was declared and taught by the @@ -22,14 +24,14 @@ * * What each block pins: * - * - reorder — `allow_reorder: false` removes the drag handle from every row, + * - reorder — `allowReorder: 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; - * - `sort_field` — declared (round 9): it names the CHILD field each row is + * - total — `totalField` names the CHILD column summed into the footer + * (the spec's `amountField`, not the PARENT field the spec's own + * `totalField` names); `amount_field` and `amountField` change nothing; + * - `addLabel` — declared, and it labels the Add button; + * - `sortField` — declared (round 9): it names the CHILD field each row is * stamped with its index in, on every change, so a drag-reorder persists; * with no key the rows carry no position; * - `allow_duplicate` and `show_line_numbers` — retired under ADR-0049 (no @@ -78,14 +80,14 @@ function show(field: GridFieldMetadata) { 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`', () => { + describe('reorder: `allowReorder`', () => { 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 })); + it('`allowReorder: false` draws no drag handle on any row', () => { + show(grid({ allowReorder: false })); expect(dragHandles()).toBe(0); }); @@ -95,9 +97,9 @@ describe('GridField field-level keys: the declared spelling is the read spelling }); }); - 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' })); + describe('total: `totalField` names the child column summed', () => { + it('`totalField` shows the footer total of that child column', () => { + show(grid({ totalField: 'amount' })); expect(screen.getByTestId('line-items-total').textContent).toContain('30'); }); @@ -112,14 +114,14 @@ describe('GridField field-level keys: the declared spelling is the read spelling }); }); - describe('`add_label`', () => { + describe('`addLabel`', () => { it('labels the Add button', () => { - show(grid({ add_label: 'Add invoice line' })); + show(grid({ addLabel: 'Add invoice line' })); expect(screen.getByTestId('line-items-add').textContent).toContain('Add invoice line'); }); }); - describe('`sort_field`: the child field stamped with each row\'s index (objectui#11070 round 9)', () => { + describe('`sortField`: the child field stamped with each row\'s index (objectui#11070 round 9)', () => { /** Drag the second row onto the first, and return the rows the change handed back. */ function dragSecondOntoFirst(field: GridFieldMetadata): Array> { const onChange = vi.fn(); @@ -132,8 +134,8 @@ describe('GridField field-level keys: the declared spelling is the read spelling return onChange.mock.calls[0][0]; } - it('`sort_field` stamps each row with its new index after a drag-reorder', () => { - const next = dragSecondOntoFirst(grid({ sort_field: 'position' })); + it('`sortField` stamps each row with its new index after a drag-reorder', () => { + const next = dragSecondOntoFirst(grid({ sortField: 'position' })); expect(next.map((r) => [r.description, r.position])).toEqual([ ['B', 0], ['A', 1], @@ -153,8 +155,8 @@ describe('GridField field-level keys: the declared spelling is the read spelling 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 })); + it('CONTROL: `allowAdd: false` removes the duplicate action with the Add action', () => { + show(grid({ allowAdd: false })); expect(screen.queryAllByTestId(/^line-items-duplicate-/)).toHaveLength(0); }); }); @@ -174,24 +176,24 @@ 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', - sort_field: 'position', + minRows: 1, + maxRows: 50, + allowAdd: true, + allowDelete: true, + allowReorder: false, + totalField: 'amount', + addLabel: 'Add line', + sortField: 'position', }; void declared; -// @ts-expect-error objectui#11070 round 8: `reorderable` is retired; the reorder key is `allow_reorder`. +// @ts-expect-error objectui#11070 round 8: `reorderable` is retired; the reorder key is `allowReorder`. 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`. +// @ts-expect-error objectui#11070 round 8: `amount_field` is retired; the summed child column is `totalField`. 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`. +// @ts-expect-error objectui#11070 round 8: `amountField` is retired on the widget; the summed child column is `totalField`. 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`. +// @ts-expect-error objectui#11070 round 8: `allow_duplicate` is retired (ADR-0049); duplicate follows `allowAdd`. 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 }; diff --git a/packages/fields/src/widgets/GridField.i18nChrome-11131.no-provider.test.tsx b/packages/fields/src/widgets/GridField.i18nChrome-11131.no-provider.test.tsx index 28f906aa91..1583095749 100644 --- a/packages/fields/src/widgets/GridField.i18nChrome-11131.no-provider.test.tsx +++ b/packages/fields/src/widgets/GridField.i18nChrome-11131.no-provider.test.tsx @@ -47,12 +47,12 @@ describe('GridField chrome with no i18n provider (objectui#11131)', () => { expect(cellText(/^No items yet/)).toBe('No items yet — click “Add” to begin.'); }); - it('an authored `add_label` fills the hole on this path too', () => { + it('an authored `addLabel` fills the hole on this path too', () => { render( {}} - field={{ columns, add_label: 'New row' } as never} + field={{ columns, addLabel: 'New row' } as never} displayMode="list" onAdd={() => {}} />, diff --git a/packages/fields/src/widgets/GridField.i18nChrome-11131.test.tsx b/packages/fields/src/widgets/GridField.i18nChrome-11131.test.tsx index 948e9b3c3d..abebae6cf6 100644 --- a/packages/fields/src/widgets/GridField.i18nChrome-11131.test.tsx +++ b/packages/fields/src/widgets/GridField.i18nChrome-11131.test.tsx @@ -14,10 +14,10 @@ * “Add” to begin.` and the read-only grid's `No items`. They now read the * catalogue through the fields package's `useFieldTranslation`: * `fields.grid.addLine`, `fields.grid.noItemsAddHint` (whose `{{label}}` is the - * authored `add_label`, or `detail.add`) and `fields.grid.noItems`. + * authored `addLabel`, or `detail.add`) and `fields.grid.noItems`. * * Only the DEFAULTS move. The `CONTROL` case pins the other half: an authored - * `add_label` is the button's label exactly as written, and it is the label + * `addLabel` is the button's label exactly as written, and it is the label * the empty text names, under the same zh session. * * The provider-less English is the `.no-provider` companion of this file. @@ -63,12 +63,12 @@ describe('GridField chrome resolves through the i18n catalogue (objectui#11131)' expect(screen.queryByText('No items')).toBeNull(); }); - it('CONTROL — an authored `add_label` is the button label and the label the empty text names', async () => { + it('CONTROL — an authored `addLabel` is the button label and the label the empty text names', async () => { inZh( {}} - field={{ columns, add_label: 'Add invoice line' } as never} + field={{ columns, addLabel: 'Add invoice line' } as never} displayMode="list" onAdd={() => {}} />, diff --git a/packages/fields/src/widgets/GridField.i18nChrome-11145.no-provider.test.tsx b/packages/fields/src/widgets/GridField.i18nChrome-11145.no-provider.test.tsx index 6808fd4634..033ab867eb 100644 --- a/packages/fields/src/widgets/GridField.i18nChrome-11145.no-provider.test.tsx +++ b/packages/fields/src/widgets/GridField.i18nChrome-11145.no-provider.test.tsx @@ -44,7 +44,7 @@ describe('GridField chrome with no i18n provider (objectui#11145)', () => { {}} - field={{ columns, total_field: 'amount' } as never} + field={{ columns, totalField: 'amount' } as never} onRowExpand={() => {}} />, ); diff --git a/packages/fields/src/widgets/GridField.i18nChrome-11145.test.tsx b/packages/fields/src/widgets/GridField.i18nChrome-11145.test.tsx index e9cd52c4cc..0b5c0a7ea4 100644 --- a/packages/fields/src/widgets/GridField.i18nChrome-11145.test.tsx +++ b/packages/fields/src/widgets/GridField.i18nChrome-11145.test.tsx @@ -57,7 +57,7 @@ const editableGrid = (language: 'zh' | 'en') => {}} - field={{ columns, total_field: 'amount' } as never} + field={{ columns, totalField: 'amount' } as never} onRowExpand={() => {}} />, ); @@ -117,7 +117,7 @@ describe('GridField chrome resolves through the i18n catalogue (objectui#11145)' it('zh: the read-only grid\'s footer reads 合计 too', async () => { inLanguage( 'zh', - {}} field={{ columns, total_field: 'amount' } as never} readonly />, + {}} field={{ columns, totalField: 'amount' } as never} readonly />, ); const table = await screen.findByTestId('line-items-readonly'); diff --git a/packages/fields/src/widgets/GridField.retiredSnakeKeys-11610.test.tsx b/packages/fields/src/widgets/GridField.retiredSnakeKeys-11610.test.tsx new file mode 100644 index 0000000000..f145462768 --- /dev/null +++ b/packages/fields/src/widgets/GridField.retiredSnakeKeys-11610.test.tsx @@ -0,0 +1,244 @@ +/** + * 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 eight field-level keys draw under their camelCase names, + * and a field still carrying a retired snake_case spelling is REFUSED BY NAME + * instead of drawn (objectui#11610). + * + * objectui#11610 renamed `min_rows`, `max_rows`, `allow_add`, `allow_delete`, + * `allow_reorder`, `total_field`, `add_label` and `sort_field` to `minRows`, + * `maxRows`, `allowAdd`, `allowDelete`, `allowReorder`, `totalField`, + * `addLabel` and `sortField`, with no dual read. The widget is the third face + * of that retirement (the type's tombstones and the zod mirror's alias + * refusals are pinned in `packages/types`): a snake_case key reaching it + * through a document the compiler and the validator never saw must not be + * dropped in silence, because a grid drawn without the author's + * `allow_add: false` looks like it worked. + * + * What each block pins, all through the real `GridField`: + * + * 1. each camelCase key changes what the grid draws, against a lit control + * without the key (the minimum rows lock Remove, the maximum locks Add, + * the three switches remove their controls, the total shows, the label + * shows, the sort field stamps positions); + * 2. each snake_case key, alone, draws the named refusal INSTEAD of the + * grid: an alert naming the retired key and its camelCase replacement, + * and the same text once on `console.error`; the rows are untouched; + * 3. the refusal's edges: several keys in one alert, a key holding + * `undefined` is not refused (no JSON document carries one, and the TS + * and zod faces accept it too), and a refused grid that mounts twice + * logs once. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { GRID_FIELD_RETIRED_KEYS, type GridFieldMetadata, type GridFieldRetiredKey } from '@object-ui/types'; +import { GridField } from './GridField'; + +const columns: NonNullable = [ + { name: 'description', label: 'Description', type: 'text' }, + { name: 'amount', label: 'Amount', type: 'currency' }, +]; + +const rows = [ + { description: 'A', amount: 10 }, + { description: 'B', amount: 20 }, +]; + +function grid(keys: Partial> = {}, name = 'lines'): GridFieldMetadata { + return { type: 'grid', name, columns, ...keys }; +} + +/** A field carrying a retired key, reaching the widget the way a document the compiler never saw would. */ +function withRetired(extra: Record, name = 'lines'): GridFieldMetadata { + return { ...grid({}, name), ...extra } as GridFieldMetadata; +} + +function show(field: GridFieldMetadata, onChange: (next: unknown) => void = () => {}) { + return render(); +} + +const removeButtons = () => screen.queryAllByTestId(/^line-items-remove-/) as HTMLButtonElement[]; +const duplicateButtons = () => screen.queryAllByTestId(/^line-items-duplicate-/) as HTMLButtonElement[]; +const dragHandles = () => screen.queryAllByTestId(/^line-items-drag-/); +const addButton = () => screen.queryByTestId('line-items-add') as HTMLButtonElement | null; + +/** Drag the second row onto the first, and return the rows the change handed back. */ +function dragSecondOntoFirst(field: GridFieldMetadata): Array> { + const onChange = vi.fn(); + show(field, onChange); + const target = screen.getByTestId('line-items-drag-0').closest('tr')!; + fireEvent.dragStart(screen.getByTestId('line-items-drag-1')); + fireEvent.dragOver(target); + fireEvent.drop(target); + expect(onChange).toHaveBeenCalledTimes(1); + return onChange.mock.calls[0][0]; +} + +describe('objectui#11610 — the eight camelCase keys draw', () => { + it('`minRows`: at the minimum, every Remove action is disabled (control: no key, all enabled)', () => { + const { unmount } = show(grid()); + expect(removeButtons()).toHaveLength(rows.length); + expect(removeButtons().every((b) => !b.disabled), 'CONTROL').toBe(true); + unmount(); + show(grid({ minRows: rows.length })); + expect(removeButtons()).toHaveLength(rows.length); + expect(removeButtons().every((b) => b.disabled)).toBe(true); + }); + + it('`maxRows`: at the maximum, Add and every Duplicate are disabled (control: no key, all enabled)', () => { + const { unmount } = show(grid()); + expect(addButton()!.disabled, 'CONTROL').toBe(false); + expect(duplicateButtons().every((b) => !b.disabled), 'CONTROL').toBe(true); + unmount(); + show(grid({ maxRows: rows.length })); + expect(addButton()!.disabled).toBe(true); + expect(duplicateButtons()).toHaveLength(rows.length); + expect(duplicateButtons().every((b) => b.disabled)).toBe(true); + }); + + it('`allowAdd: false` removes Add and every Duplicate (control: no key, both drawn)', () => { + const { unmount } = show(grid()); + expect(addButton(), 'CONTROL').not.toBeNull(); + expect(duplicateButtons(), 'CONTROL').toHaveLength(rows.length); + unmount(); + show(grid({ allowAdd: false })); + expect(addButton()).toBeNull(); + expect(duplicateButtons()).toHaveLength(0); + }); + + it('`allowDelete: false` removes every Remove action (control: no key, one per row)', () => { + const { unmount } = show(grid()); + expect(removeButtons(), 'CONTROL').toHaveLength(rows.length); + unmount(); + show(grid({ allowDelete: false })); + expect(removeButtons()).toHaveLength(0); + }); + + it('`allowReorder: false` removes every drag handle (control: no key, one per row)', () => { + const { unmount } = show(grid()); + expect(dragHandles(), 'CONTROL').toHaveLength(rows.length); + unmount(); + show(grid({ allowReorder: false })); + expect(dragHandles()).toHaveLength(0); + }); + + it('`totalField` shows the footer total of that child column (control: no key, no total)', () => { + const { unmount } = show(grid()); + expect(screen.queryByTestId('line-items-total'), 'CONTROL').toBeNull(); + unmount(); + show(grid({ totalField: 'amount' })); + expect(screen.getByTestId('line-items-total').textContent).toContain('30'); + }); + + it('`addLabel` labels the Add button (control: no key, the locale default)', () => { + const { unmount } = show(grid()); + expect(addButton()!.textContent, 'CONTROL').toBe('Add line'); + unmount(); + show(grid({ addLabel: 'Add invoice line' })); + expect(addButton()!.textContent).toBe('Add invoice line'); + }); + + it('`sortField` stamps each row with its index after a drag-reorder (control: no key, no position)', () => { + const control = dragSecondOntoFirst(grid()); + expect(control.map((r) => r.description), 'CONTROL: the reorder lands').toEqual(['B', 'A']); + expect(control.every((r) => !('position' in r)), 'CONTROL').toBe(true); + document.body.innerHTML = ''; + const next = dragSecondOntoFirst(grid({ sortField: 'position' })); + expect(next.map((r) => [r.description, r.position])).toEqual([ + ['B', 0], + ['A', 1], + ]); + }); +}); + +/** A value each snake_case key used to carry: the camelCase key's declared type. */ +const VALUE: Record = { + min_rows: 1, + max_rows: 20, + allow_add: false, + allow_delete: false, + allow_reorder: false, + total_field: 'amount', + add_label: 'Add line', + sort_field: 'position', +}; +const RETIRED = Object.entries(GRID_FIELD_RETIRED_KEYS) as Array<[GridFieldRetiredKey, string]>; + +describe('objectui#11610 — a retired snake_case key is refused by name, never dropped', () => { + let error: ReturnType; + beforeEach(() => { + error = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + error.mockRestore(); + }); + + it('LIT CONTROL: the same field with no retired key draws the grid and logs nothing', () => { + show(grid({}, 'lit-control')); + expect(screen.getByTestId('line-items')).toBeTruthy(); + expect(screen.queryByTestId('grid-field-retired-keys')).toBeNull(); + expect(error).not.toHaveBeenCalled(); + }); + + it.each(RETIRED)('`%s` draws the refusal naming `%s` instead of the grid', (snake, camel) => { + const onChange = vi.fn(); + // A field name per key: the console line is logged once per message, and + // the name is part of the message. + show(withRetired({ [snake]: VALUE[snake] }, `lines_${snake}`), onChange); + const alert = screen.getByRole('alert'); + expect(alert.getAttribute('data-testid')).toBe('grid-field-retired-keys'); + expect(alert.getAttribute('data-retired-keys')).toBe(snake); + expect(alert.textContent).toContain(`Grid field \`lines_${snake}\``); + expect(alert.textContent).toContain(`\`${snake}\` → \`${camel}\``); + // Refused, not drawn: no grid, no rows, no controls. + expect(screen.queryByTestId('line-items')).toBeNull(); + expect(addButton()).toBeNull(); + // The same text, once, for whoever reads the console instead of the page. + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith(alert.textContent); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('several retired keys are named in ONE alert, in the rename map\'s order', () => { + show(withRetired({ sort_field: 'position', min_rows: 1, allow_add: false }, 'lines_several')); + const alert = screen.getByTestId('grid-field-retired-keys'); + expect(alert.getAttribute('data-retired-keys')).toBe('min_rows allow_add sort_field'); + expect(alert.textContent).toContain('`min_rows` → `minRows`, `allow_add` → `allowAdd`, `sort_field` → `sortField`'); + expect(error).toHaveBeenCalledTimes(1); + }); + + it('a retired key beside its camelCase twin is still refused: there is no precedence to pick a winner', () => { + show(withRetired({ minRows: 1, min_rows: 2 }, 'lines_both')); + expect(screen.getByTestId('grid-field-retired-keys').getAttribute('data-retired-keys')).toBe('min_rows'); + expect(screen.queryByTestId('line-items')).toBeNull(); + }); + + it('a retired key holding `undefined` is not refused, as on the TS and zod faces', () => { + show(withRetired({ min_rows: undefined, allow_add: undefined }, 'lines_undefined')); + expect(screen.queryByTestId('grid-field-retired-keys')).toBeNull(); + expect(screen.getByTestId('line-items')).toBeTruthy(); + expect(error).not.toHaveBeenCalled(); + }); + + it('a field with no name is refused as "This grid field"', () => { + render( {}} field={{ columns, total_field: 'amount' } as never} />); + expect(screen.getByTestId('grid-field-retired-keys').textContent).toContain('This grid field carries'); + }); + + it('a refused grid that mounts twice logs its prescription once', () => { + const field = withRetired({ max_rows: 3 }, 'lines_twice'); + const first = show(field); + first.unmount(); + show(field); + expect(screen.getByTestId('grid-field-retired-keys')).toBeTruthy(); + expect(error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/fields/src/widgets/GridField.test.tsx b/packages/fields/src/widgets/GridField.test.tsx index a3f9d681d2..1af72ae2c2 100644 --- a/packages/fields/src/widgets/GridField.test.tsx +++ b/packages/fields/src/widgets/GridField.test.tsx @@ -10,7 +10,7 @@ const columns = [ { name: 'amount', label: 'Amount', type: 'currency' as const }, ]; -const field = { columns, total_field: 'amount' } as any; +const field = { columns, totalField: 'amount' } as any; describe('GridField / LineItemsField — editable line items', () => { it('is exported under both names', () => { @@ -401,7 +401,7 @@ describe('GridField / LineItemsField — editable line items', () => { // (objectui#10783). The currency's minor unit decides. { name: 'amount', label: 'Amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price' }, ], - total_field: 'amount', + totalField: 'amount', } as any; it('renders a computed column read-only (no input) and recomputes on edit', () => { diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index 0942f8bf6b..b81c41b27b 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -26,7 +26,7 @@ import { useFieldTranslation } from './useFieldTranslation.js'; import { toDomProps } from './toDomProps.js'; import { toHostGroupProps } from './toHostGroupProps.js'; import type { InlineGridColumn } from '@objectstack/spec/data'; -import type { GridFieldMetadata } from '@object-ui/types'; +import { GRID_FIELD_RETIRED_KEYS, type GridFieldMetadata, type GridFieldRetiredKey } from '@object-ui/types'; /** * GridField / LineItemsField — editable child-grid ("line items") widget. @@ -79,8 +79,10 @@ import type { GridFieldMetadata } from '@object-ui/types'; * * Field-level config: the keys `GridFieldMetadata` (`@object-ui/types`) * declares, each read under that one spelling (objectui#11070 round 8), and - * no other key: `sort_field`, the last key read before any face declared it, - * is declared there too (objectui#11070 round 9). + * no other key: `sortField`, the last key read before any face declared it, + * is declared there too (objectui#11070 round 9). objectui#11610 renamed all + * eight from snake_case to camelCase; a field still carrying a snake_case + * spelling is refused by name instead of drawn (`RetiredGridFieldKeys`). */ /** @@ -551,19 +553,7 @@ export function sumColumn(rows: Row[], field: string): number { }, 0); } -export function GridField({ - value, - onChange, - field, - readonly, - disabled, - className, - error, - onRowExpand, - displayMode, - onAdd, - ...props -}: FieldWidgetComponentProps & { +type GridFieldProps = FieldWidgetComponentProps & { /** When provided, each row shows an "expand" button that opens the row in a * full form (the host — e.g. MasterDetailForm — renders the drawer/modal and * writes the edited values back). Lets a "fat" child be edited in a real form @@ -580,7 +570,98 @@ export function GridField({ * `readonlyWhen` / `requiredWhen` CEL predicate — so a line cell can react to * the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */ contextRecord?: Record; -}) { +}; + +/** + * The retired snake_case field-level keys a grid field's metadata carries, in + * {@link GRID_FIELD_RETIRED_KEYS} order (objectui#11610). A key holding + * `undefined` is not counted: no JSON document can carry one, and the TS and + * zod faces accept it too, so all three faces draw the line in one place. + */ +function retiredGridFieldKeysOf(field: unknown): GridFieldRetiredKey[] { + if (field == null || typeof field !== 'object') return []; + const carrier = field as Record; + return (Object.keys(GRID_FIELD_RETIRED_KEYS) as GridFieldRetiredKey[]).filter( + (key) => carrier[key] !== undefined, + ); +} + +/** + * The prescription a grid field carrying retired keys answers with: names the + * field when it has a name, then each retired key beside the camelCase key to + * write. One sentence, read by the alert and by the console line. + */ +function retiredGridFieldKeysMessage(fieldName: unknown, keys: readonly GridFieldRetiredKey[]): string { + const which = typeof fieldName === 'string' && fieldName ? `Grid field \`${fieldName}\`` : 'This grid field'; + const renames = keys.map((key) => `\`${key}\` → \`${GRID_FIELD_RETIRED_KEYS[key]}\``).join(', '); + return ( + `[object-ui] ${which} carries retired snake_case key(s): ${renames}. ` + + "The grid's field-level keys are camelCase since objectui#11610 and it reads no snake_case spelling, " + + 'so the grid is not drawn until each key is renamed; the values stay the same.' + ); +} + +/** + * Messages already logged this session, so a refused grid inside a rendered + * list logs once, not once per mount (the `reportRetiredFieldType` discipline). + */ +const reportedRetiredGridFieldKeys = new Set(); + +/** + * The widget-path face of the objectui#11610 retirement: what a grid field + * carrying a retired snake_case key renders INSTEAD of the grid. The shape is + * `RetiredFieldTombstone`'s (`../index`), this package's settled answer to an + * entry the renderer cannot honour: an inline alert naming the keys and the + * replacements, plus a `console.error` with the same text. Nothing is thrown, + * so the rest of the form still draws, and nothing is silently dropped: a grid + * drawn without the author's `allow_add: false` would look like it worked. + * The rows are untouched, since the alert never calls `onChange`. + */ +function RetiredGridFieldKeys({ message, keys }: { message: string; keys: readonly GridFieldRetiredKey[] }) { + React.useEffect(() => { + if (reportedRetiredGridFieldKeys.has(message)) return; + reportedRetiredGridFieldKeys.add(message); + console.error(message); + }, [message]); + return ( +
+ {message} +
+ ); +} + +/** + * The `grid` field widget. A field whose metadata carries one of the retired + * snake_case keys is refused by name ({@link RetiredGridFieldKeys}); any other + * field draws the grid. + */ +export function GridField(props: GridFieldProps) { + const retired = retiredGridFieldKeysOf(props.field); + if (retired.length > 0) { + const message = retiredGridFieldKeysMessage((props.field as { name?: unknown } | undefined)?.name, retired); + return ; + } + return ; +} + +function GridFieldBody({ + value, + onChange, + field, + readonly, + disabled, + className, + error, + onRowExpand, + displayMode, + onAdd, + ...props +}: GridFieldProps) { // The field-level keys, read as `GridFieldMetadata` declares them and as // nothing else, so a read of a key the type does not declare is a compile // error here rather than a second, unpublished contract (objectui#11070 @@ -665,8 +746,8 @@ export function GridField({ }); }, []); - const allowAdd = cfg.allow_add !== false && !readonly && !disabled; - const allowDelete = cfg.allow_delete !== false && !readonly && !disabled; + const allowAdd = cfg.allowAdd !== false && !readonly && !disabled; + const allowDelete = cfg.allowDelete !== false && !readonly && !disabled; // A duplicate IS an add, so it is offered exactly when adding is. There is // no key of its own: `allow_duplicate` was read here while no face declared // it and round 8's census of both repositories found no producer of it, so @@ -678,27 +759,28 @@ export function GridField({ // Enterprise line grids (NetSuite/SAP/Salesforce) show a line-number column, // always: the `show_line_numbers` switch was retired with `allow_duplicate`, // for the same reason (objectui#11070 round 8). - const minRows: number = cfg.min_rows ?? 0; - const maxRows: number | undefined = cfg.max_rows; + const minRows: number = cfg.minRows ?? 0; + const maxRows: number | undefined = cfg.maxRows; // The CHILD column summed into the footer: the spec's `amountField` (an // `inlineAmountField` / `subforms[].amountField`), which both adapters in - // `@object-ui/plugin-form` write here. ⛔ Not the spec's `totalField`, the - // PARENT field that receives the rollup on save. One spelling: the - // `amount_field` / `amountField` reads beside it are retired, round 8's + // `@object-ui/plugin-form` write here. ⚠️ Same name as the spec's + // `totalField` on those surfaces, which is the PARENT field that receives + // the rollup on save; the grid's key names the child column. One spelling: + // the `amount_field` / `amountField` reads beside it are retired, round 8's // census having found no producer of either (objectui#11070 round 8). - const totalField: string | undefined = cfg.total_field; + const totalField: string | undefined = cfg.totalField; // When set, the row's order is persisted by stamping `row[sortField] = index` // on every change — so drag-reorder survives a reload (the app adds a numeric // position field and lists sort by it). Without it, reorder is order-of-entry. // Declared on `GridFieldMetadata`; its producer is `deriveDetail` // (`@object-ui/plugin-form`, through `MasterDetailForm`), which picks the // child object's `position` / `sort_order` / … field (objectui#11070 round 9). - const sortField: string | undefined = cfg.sort_field; + const sortField: string | undefined = cfg.sortField; // Drag-to-reorder is on for editable grids (off in read-only / list mode), - // and `allow_reorder: false` turns it off: the key `GridFieldMetadata` + // and `allowReorder: false` turns it off: the key `GridFieldMetadata` // declares. The undeclared `reorderable` this used to read is retired // (objectui#11070 round 8). - const allowReorder = cfg.allow_reorder !== false && !readonly && !disabled; + const allowReorder = cfg.allowReorder !== false && !readonly && !disabled; const emit = useCallback( (next: Row[]) => { @@ -1237,7 +1319,7 @@ export function GridField({ className="px-3 py-6 text-center text-muted-foreground" > {t('fields.grid.noItemsAddHint', { - label: cfg.add_label || t('detail.add', { defaultValue: 'Add' }), + label: cfg.addLabel || t('detail.add', { defaultValue: 'Add' }), defaultValue: 'No items yet — click “{{label}}” to begin.', })} @@ -1406,7 +1488,7 @@ export function GridField({ data-testid="line-items-add" > - {cfg.add_label || t('fields.grid.addLine', { defaultValue: 'Add line' })} + {cfg.addLabel || t('fields.grid.addLine', { defaultValue: 'Add line' })} )}
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 52476ac343..0d24ab5d74 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -571,7 +571,7 @@ const en = { }, // objectui#11131 — the line-items grid's default chrome (`GridField`): // its Add button, the read-only grid's empty state, and the list-mode - // grid's empty state. An authored `add_label` still wins over `addLine`, + // grid's empty state. An authored `addLabel` still wins over `addLine`, // and it fills the `{{label}}` hole (`detail.add` when none is authored). // // objectui#11145 — the rest of that chrome: the column chooser's heading, diff --git a/packages/plugin-form/src/LineItemsPanel.gridTotal-11070.test.tsx b/packages/plugin-form/src/LineItemsPanel.gridTotal-11070.test.tsx index 32d92790c1..f94b682e8a 100644 --- a/packages/plugin-form/src/LineItemsPanel.gridTotal-11070.test.tsx +++ b/packages/plugin-form/src/LineItemsPanel.gridTotal-11070.test.tsx @@ -10,9 +10,10 @@ * `record:line_items` shows its grid's footer total whenever `amountField` * names the child column to sum (objectui#11070 round 8). * - * The grid's `total_field` is the CHILD column summed. `MasterDetailForm` maps - * it from the detail's `amountField`; this panel used to map it only when - * `totalField` (the PARENT field the sum is saved to) was set as well, so a + * The grid's `totalField` (`total_field` until objectui#11610) is the CHILD + * column summed. `MasterDetailForm` maps it from the detail's `amountField`; + * this panel used to map it only when the block's own `totalField` (the + * PARENT field the sum is saved to) was set as well, so a * panel that named only the column to sum (the objectstack showcase's * project page authors exactly that) showed no total. Both adapters now map * the same key the same way. diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 37d28b38d6..e6c5a7c696 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -27,6 +27,7 @@ import { cn, } from '@object-ui/components'; import { LineItemsField, type GridColumn } from '@object-ui/fields'; +import type { GridFieldMetadata } from '@object-ui/types'; import { createSafeTranslation } from '@object-ui/i18n'; import { useSchemaContext, useRecordContext, useFilterScope, useResolvedFilter, useDataInvalidation } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; @@ -701,16 +702,20 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // the surrounding form already disables that field (objectui#10163). // Adding and removing lines stay on `schema.readonly` below. columns: applyColumnPermissions(schema.columns, { perms, objectName: schema.childObject }), - // The grid's `total_field` is the CHILD column summed (this block's + // The grid's `totalField` is the CHILD column summed (this block's // `amountField`), shown whenever one is named, exactly as - // `MasterDetailForm` maps it; `totalField` is only the PARENT field - // the sum is written to on save (objectui#11070 round 8). - total_field: schema.amountField || (schema.totalField ? 'amount' : undefined), - min_rows: schema.minRows, - max_rows: schema.maxRows, - allow_add: !schema.readonly, - allow_delete: !schema.readonly, - }) as any, + // `MasterDetailForm` maps it. This block's own `totalField` is only + // the PARENT field the sum is written to on save, so the two + // same-named keys carry different values (objectui#11070 round 8). + totalField: schema.amountField || (schema.totalField ? 'amount' : undefined), + minRows: schema.minRows, + maxRows: schema.maxRows, + allowAdd: !schema.readonly, + allowDelete: !schema.readonly, + // Checked against the grid's published type (objectui#11610), so a key + // the grid does not declare, or one of its retired snake_case + // spellings, fails to compile here. + } satisfies Partial) as GridFieldMetadata, [schema, perms], ); diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index 31c5c14e60..ac13be28ab 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -31,7 +31,7 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { BatchTransactionOperation, DataSource, I18nLabel } from '@object-ui/types'; +import type { BatchTransactionOperation, DataSource, GridFieldMetadata, I18nLabel } from '@object-ui/types'; import { runBatchTransaction } from '@object-ui/core'; import { LineItemsField, type GridColumn } from '@object-ui/fields'; import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components'; @@ -363,7 +363,7 @@ interface DetailEntry { /** * The child field the line grid stamps with each line's position, so a * drag-reorder persists: `deriveDetail`'s pick (the child object's - * `position` / `sort_order` / … field), handed to the grid as `sort_field`. + * `position` / `sort_order` / … field), handed to the grid as `sortField`. * Derived only, never authored (objectui#11070 round 9), so it lives here * beside the other resolved state rather than on the authored config. Absent * when the entry was not derived (a fully configured entry loads no child @@ -862,7 +862,7 @@ const MasterDetailLines: React.FC = ({ displayMode={d.inlineMode === 'form' ? 'list' : 'grid'} {...(d.inlineMode === 'form' ? { onAdd: () => onAddViaForm(entry.id) } : {})} field={ - { + ({ // FLS gate, through the ONE render pass `LineItemsPanel` and // the record-form containers share: a child column the caller // may not read is omitted, and one they may read but not edit @@ -870,12 +870,18 @@ const MasterDetailLines: React.FC = ({ columns: applyColumnPermissions(d.columns, { perms, objectName: d.childObject }), // Show the per-grid running total whenever an amount column is // set — unless the document totals stack below subsumes it. - total_field: showTaxStack ? undefined : (d.amountField || (d.totalField ? 'amount' : undefined)), - sort_field: entry.sortField, - min_rows: d.minRows, - max_rows: d.maxRows, - add_label: d.inlineMode === 'form' ? (d.addLabel || t('detail.add')) : d.addLabel, - } as any + // The grid's `totalField` names the CHILD column summed (this + // detail's `amountField`); the detail's own `totalField` is the + // PARENT field the sum is saved to, and never reaches the grid. + totalField: showTaxStack ? undefined : (d.amountField || (d.totalField ? 'amount' : undefined)), + sortField: entry.sortField, + minRows: d.minRows, + maxRows: d.maxRows, + addLabel: d.inlineMode === 'form' ? (d.addLabel || t('detail.add')) : d.addLabel, + // Checked against the grid's published type (objectui#11610), so + // a key the grid does not declare, or one of its retired + // snake_case spellings, fails to compile here. + } satisfies Partial) as GridFieldMetadata } /> )} diff --git a/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx b/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx index f79dfa05e1..2c69f236b6 100644 --- a/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx +++ b/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx @@ -30,16 +30,22 @@ * * - `title` heads the collection's own section, with `Line Items` as the * fallback. `columns` is handed to the line grid in authored order. - * - 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`, and `amountField` to `total_field`. Beside - * them and `columns`, the object carries `sort_field`, which is no member: - * the block DERIVES it from the child object (`deriveDetail` picks its - * `position` / `sort_order` / … field), and objectui#11070 round 9 retired - * the `sortField` member that used to override it. A member dropped from - * that object, or copied under its authored spelling, reaches a grid that - * reads nothing there, and nothing reports it. That is why row 2 pins the - * object as a SORTED KEY SET and not as a few spot values. + * - Four members reach the grid through ONE hand-written object. Since + * objectui#11610 renamed the grid's keys to camelCase, three keep their + * name (`minRows`, `maxRows`, `addLabel`) and one is RENAMED on the way: + * the detail's `amountField` lands on the grid's `totalField`, the CHILD + * column summed. ⚠️ The detail's own `totalField` is the PARENT field the + * sum is saved to and is NOT forwarded, so the same name carries a + * different value on each side. Beside them and `columns`, the object + * carries `sortField`, which is no member: the block DERIVES it from the + * child object (`deriveDetail` picks its `position` / `sort_order` / … + * field), and objectui#11070 round 9 retired the detail's `sortField` + * member that used to override it. A member dropped from that object, or + * a key the grid does not declare, reaches a grid that reads nothing + * there; since objectui#11610 the object is checked against + * `GridFieldMetadata` at compile time, and a retired snake_case key there + * would draw the grid's named refusal. Row 2 still pins the object as a + * SORTED KEY SET and not as a few spot values. * - `inlineMode` and `formFields` choose the form factor: list-plus-form or * editable cells, and whether a row can be opened in the full form. * - `childObject` and `relationshipField` address the collection's writes @@ -55,14 +61,14 @@ * 1. Each member renders as its own section, in AUTHORED order, headed by * `title` (or `Line Items`), with `columns` in authored order. * 2. The grid object is exactly six keys. Each authored member arrives under - * its renamed key, and off-list members are NOT forwarded, including - * one written in the grid's own snake_case spelling. + * the grid's key, and off-list members are NOT forwarded, including + * one written in the grid's own spelling (`allowAdd`). * 2c. `sortField` is no member (objectui#11070 round 9). On a detail the - * block derives, the grid's `sort_field` is the child's sort-named field + * block derives, the grid's `sortField` is the child's sort-named field * even when a `sortField` naming another field is written beside it. On * a fully configured detail, which derives nothing, a written one leaves - * `sort_field` empty. The compile-time block at the end of this file - * refuses the member by name. + * the grid's `sortField` empty. The compile-time block at the end of this + * file refuses the member by name. * 3. `inlineMode: 'form'` turns the grid into a list with an `Add` action. * In grid mode, `formFields` wider than `columns` offers the row form, and * without that the offer is withheld. Each arm is the control for the @@ -223,7 +229,7 @@ describe('`object-master-detail-form` — the member shape of `details`', () => expect(gridOf('Line Items').field.columns.map((c: { name: string }) => c.name)).toEqual(['note']); }); - it('2. four members reach the grid RENAMED, as exactly six keys, and off-list members are not forwarded', async () => { + it('2. four members reach the grid under its keys, as exactly six keys, and off-list members are not forwarded', async () => { await mount({ details: [ { @@ -238,26 +244,27 @@ describe('`object-master-detail-form` — the member shape of `details`', () => // Off the list. The second is the GRID's own spelling: the map is // explicit, so writing the target key by hand reaches nothing. readonly: true, - allow_add: false, + allowAdd: false, }, ], }); const field = await waitFor(() => gridOf('Lines').field); expect(Object.keys(field).sort(), 'the one object this block hands the grid').toEqual([ - 'add_label', + 'addLabel', 'columns', - 'max_rows', - 'min_rows', - 'sort_field', - 'total_field', + 'maxRows', + 'minRows', + 'sortField', + 'totalField', ]); expect(field).toMatchObject({ - min_rows: 1, - max_rows: 3, - add_label: 'Add a line', - total_field: 'qty', + minRows: 1, + maxRows: 3, + addLabel: 'Add a line', + // The detail's `amountField`, the CHILD column summed. + totalField: 'qty', }); - expect(field.sort_field, 'a fully configured detail derives nothing (row 2c)').toBeUndefined(); + expect(field.sortField, 'a fully configured detail derives nothing (row 2c)').toBeUndefined(); expect(gridOf('Lines').displayMode).toBe('grid'); }); @@ -267,20 +274,20 @@ describe('`object-master-detail-form` — the member shape of `details`', () => // The key set first: `toEqual` treats a key holding `undefined` as absent, // so on its own it could not tell "six keys, five empty" from "one key". expect(Object.keys(field).sort()).toEqual([ - 'add_label', + 'addLabel', 'columns', - 'max_rows', - 'min_rows', - 'sort_field', - 'total_field', + 'maxRows', + 'minRows', + 'sortField', + 'totalField', ]); expect(field).toEqual({ columns: [QTY], - sort_field: undefined, - min_rows: undefined, - max_rows: undefined, - add_label: undefined, - total_field: undefined, + sortField: undefined, + minRows: undefined, + maxRows: undefined, + addLabel: undefined, + totalField: undefined, }); }); @@ -310,14 +317,14 @@ describe('`object-master-detail-form` — the member shape of `details`', () => if (!f.columns?.length) throw new Error('columns not derived yet'); return f; }); - expect(derived.sort_field, 'the first of the child’s sort-named fields').toBe('position'); + expect(derived.sortField, 'the first of the child’s sort-named fields').toBe('position'); const written = await waitFor(() => { const f = gridOf('Steps, written').field; if (!f.columns?.length) throw new Error('columns not derived yet'); return f; }); - expect(written.sort_field, 'the written `sortField` is read by nothing').toBe('position'); - expect(gridOf('Steps, configured').field.sort_field, 'nothing derived, and the written one not read').toBeUndefined(); + expect(written.sortField, 'the detail’s written `sortField` is read by nothing').toBe('position'); + expect(gridOf('Steps, configured').field.sortField, 'nothing derived, and the written one not read').toBeUndefined(); }); it('3. `inlineMode` and `formFields` choose the form factor, each arm the other’s control', async () => { @@ -338,13 +345,13 @@ describe('`object-master-detail-form` — the member shape of `details`', () => expect(list.displayMode).toBe('list'); expect(typeof list.onAdd, 'list mode adds through the full form').toBe('function'); expect(typeof list.onRowExpand, 'list mode edits through the full form').toBe('function'); - expect(list.field.add_label, 'list mode names its Add action even when `addLabel` is unset').toBe('Add'); + expect(list.field.addLabel, 'list mode names its Add action even when the detail’s `addLabel` is unset').toBe('Add'); const wider = gridOf('Wider form'); expect(wider.displayMode).toBe('grid'); expect(typeof wider.onRowExpand, '`formFields` wider than `columns` offers the row form').toBe('function'); expect(wider.onAdd).toBeUndefined(); - expect(wider.field.add_label, 'grid mode leaves the label to the grid').toBeUndefined(); + expect(wider.field.addLabel, 'grid mode leaves the label to the grid').toBeUndefined(); const cells = gridOf('Cells only'); expect(cells.displayMode).toBe('grid'); diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index 6a6dbc84fa..217cbef040 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -98,7 +98,27 @@ const DECLARED_KEYS = [ // `inlineColumns` list (its strict inline grid column), by reference. 'columns', // objectui#11070 round 10 — the `grid` widget's field-level keys, the - // members of `GridFieldMetadata` (the TS twin carries each by reference). + // members of `GridFieldMetadata` (the TS twin carries each by reference), + // camelCase since objectui#11610. + 'minRows', + 'maxRows', + 'allowAdd', + 'allowDelete', + 'allowReorder', + 'totalField', + 'addLabel', + 'sortField', +]; + +/** + * Keys the schema DECLARES only to refuse by name — never authorable, so not in + * {@link DECLARED_KEYS}: the `grid` widget's eight retired snake_case spellings + * (objectui#11610), each an alias refusal naming its camelCase key, paired with + * a `?: never` tombstone on the TS twin. Their refusal is pinned in + * `grid-field-keys-camelcase-11610.test.ts`; this list only keeps the shape + * count honest, so a ninth arm (or a dropped one) must touch it. + */ +const REFUSED_BY_NAME_KEYS = [ 'min_rows', 'max_rows', 'allow_add', @@ -110,8 +130,15 @@ const DECLARED_KEYS = [ ]; describe('FormFieldSchema covers the FormField contract', () => { - it('validates exactly the declared key set', () => { - expect(Object.keys(FormFieldSchema.shape).sort()).toEqual([...DECLARED_KEYS].sort()); + it('validates exactly the declared key set, plus the keys it refuses by name', () => { + expect(Object.keys(FormFieldSchema.shape).sort()).toEqual([...DECLARED_KEYS, ...REFUSED_BY_NAME_KEYS].sort()); + }); + + it('each refused-by-name key refuses every value, so none of them is authorable (objectui#11610)', () => { + for (const key of REFUSED_BY_NAME_KEYS) { + expect(FormFieldSchema.safeParse({ name: 'lines', type: 'grid', [key]: 1 }).success, key).toBe(false); + expect(FormFieldSchema.safeParse({ name: 'lines', type: 'grid', [key]: 'x' }).success, key).toBe(false); + } }); it('requires only `name` — `type` is optional, matching the interface', () => { diff --git a/packages/types/src/__tests__/grid-field-keys-camelcase-11610.test.ts b/packages/types/src/__tests__/grid-field-keys-camelcase-11610.test.ts new file mode 100644 index 0000000000..a26d51bc07 --- /dev/null +++ b/packages/types/src/__tests__/grid-field-keys-camelcase-11610.test.ts @@ -0,0 +1,243 @@ +/** + * 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 eight field-level keys are camelCase, and their retired + * snake_case spellings are REFUSED BY NAME (objectui#11610). + * + * `@objectstack/spec`'s runtime form field can only declare camelCase config + * keys, so `min_rows`, `max_rows`, `allow_add`, `allow_delete`, + * `allow_reorder`, `total_field`, `add_label` and `sort_field` became + * `minRows`, `maxRows`, `allowAdd`, `allowDelete`, `allowReorder`, + * `totalField`, `addLabel` and `sortField` in one move. The old spellings + * retired at once, with no alias window and no dual read: each stays DECLARED + * so that it is refused by name with the camelCase key to write, rather than + * stripped in silence. `GRID_FIELD_RETIRED_KEYS` is the one list. + * + * What this file holds, per face of `@object-ui/types`: + * + * 1. zod — the form-field mirror, the tolerant face (`safeValidateSchema`, + * the `objectui validate` door) and the strict authoring face each answer + * a snake_case key with exactly one `invalid_type` issue at that key, + * whose message names the camelCase replacement; the camelCase key with + * the same value parses on all three (the lit control); + * 2. TypeScript — each tombstone refuses an authored value, and a DELETION + * GUARD per member that does not lean on an index signature: an `Equal` + * row on the member's type (`undefined` for a `?: never` member, a + * compile error once the member is gone from `GridFieldMetadata`, and the + * index signature's `any` once it is gone from `FormField`), plus a + * `keyof` membership row on `FormField`. A `@ts-expect-error` over a + * fresh literal alone cannot do this on `GridFieldMetadata`: with the + * tombstone deleted the key is still refused, as an excess property, and + * the directive swallows that error instead (the objectui#8347 lesson). + * Type-level rows are read by `tsc -p tsconfig.test.json` only. + * + * The widget's face — `GridField` drawing a named refusal instead of the grid + * — is pinned in `packages/fields`, in `GridField.retiredSnakeKeys-11610.test.tsx`. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { GRID_FIELD_RETIRED_KEYS, type GridFieldRetiredKey } from '../index.js'; +import type { BaseFieldMetadata, GridFieldMetadata } from '../field-types.js'; +import type { FormField } from '../form.js'; +import { FormFieldSchema } from '../zod/form.zod.js'; +import { StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +/** A grid value of the camelCase key's declared type, which the snake_case key used to carry too. */ +const VALUE: Record = { + min_rows: 1, + max_rows: 20, + allow_add: false, + allow_delete: false, + allow_reorder: false, + total_field: 'amount', + add_label: 'Add line', + sort_field: 'position', +}; + +const RETIRED = Object.entries(GRID_FIELD_RETIRED_KEYS) as Array<[GridFieldRetiredKey, string]>; + +const gridEntry = (extra: Record) => ({ + name: 'lines', + label: 'Lines', + type: 'grid', + columns: [{ name: 'product', type: 'text' }, { name: 'amount', type: 'currency' }], + ...extra, +}); +const formWith = (extra: Record) => ({ type: 'form', fields: [gridEntry(extra)] }); + +/** The three zod doors, each with the document it parses and the path the entry sits at. */ +const DOORS: ReadonlyArray) => z.ZodSafeParseResult, PropertyKey[]]> = [ + ['the form-field mirror', (extra) => FormFieldSchema.safeParse(gridEntry(extra)), []], + ['the tolerant face (`safeValidateSchema`)', (extra) => safeValidateSchema(formWith(extra)), ['fields', 0]], + ['the strict authoring face', (extra) => StrictAnyComponentSchema.safeParse(formWith(extra)), ['fields', 0]], +]; + +const issuesOf = (result: z.ZodSafeParseResult): Issue[] | null => + result.success ? null : (result.error.issues as unknown as Issue[]); + +describe('objectui#11610 — `GRID_FIELD_RETIRED_KEYS` is the rename map', () => { + it('maps the eight snake_case spellings to the eight camelCase keys, one to one', () => { + expect(Object.keys(GRID_FIELD_RETIRED_KEYS)).toEqual([ + 'min_rows', 'max_rows', 'allow_add', 'allow_delete', 'allow_reorder', 'total_field', 'add_label', 'sort_field', + ]); + expect(Object.values(GRID_FIELD_RETIRED_KEYS)).toEqual([ + 'minRows', 'maxRows', 'allowAdd', 'allowDelete', 'allowReorder', 'totalField', 'addLabel', 'sortField', + ]); + }); +}); + +describe.each(DOORS)('objectui#11610 — %s', (_door, parse, at) => { + it.each(RETIRED)('refuses `%s` BY NAME, naming `%s` as the key to write', (snake, camel) => { + const issues = issuesOf(parse({ [snake]: VALUE[snake] })); + expect(issues, `${snake} must not parse`).not.toBeNull(); + expect(issues!.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path: [...at, snake] }]); + expect(issues![0].message).toContain(`Did you mean \`${snake}\` → \`${camel}\`?`); + }); + + it.each(RETIRED)('LIT CONTROL: `%s`\'s value parses under `%s`, and is kept', (snake, camel) => { + const result = parse({ [camel]: VALUE[snake] }); + expect(issuesOf(result)).toBeNull(); + }); + + it('refuses a snake_case key whatever its value: the refusal is by name, not by type', () => { + for (const [snake] of RETIRED) { + for (const value of [0, 'x', true, null]) { + const issues = issuesOf(parse({ [snake]: value })); + expect(issues?.map((i) => i.path.join('.')), `${snake}: ${String(value)}`).toEqual([[...at, snake].join('.')]); + } + } + }); +}); + +describe('objectui#11610 — the camelCase keys are kept by the parse that `objectui validate` runs', () => { + it('a grid entry carrying all eight camelCase keys parses on the tolerant face with every value kept', () => { + const camel = Object.fromEntries(RETIRED.map(([snake, key]) => [key, VALUE[snake]])); + const parsed = safeValidateSchema(formWith(camel)); + expect(parsed.success).toBe(true); + expect(parsed.success && (parsed.data as { fields: Record[] }).fields[0]).toMatchObject(camel); + }); +}); + +/* ── 2. TypeScript face (read by `tsc -p tsconfig.test.json` only) ──────── */ + +type Expect = T; +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type DeclaredKeysOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; +type GridOwnKeys = Exclude; +/** `GridFieldMetadata`'s own members typed `undefined` alone: its `?: never` tombstones. */ +type GridTombstones = { [K in GridOwnKeys]-?: Equal extends true ? K : never }[GridOwnKeys]; + +/** The map's keys ARE the tombstones and its values ARE the live camelCase keys — no more, no fewer. */ +export type assertionRetiredMapMatchesTheType = [ + Expect>, + Expect>>, + // LIT CONTROL: the tombstone set is not empty, and holds no live key. + Expect, 'min_rows'>>, +]; + +/** Each pair, exactly. */ +export type assertionRenamePairs = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** + * DELETION GUARDS on `GridFieldMetadata`, one row per tombstone. Deleting the + * member turns its row into TS2339 (no such property), whatever the + * `@ts-expect-error` rows below then do. + */ +export type assertionGridTombstonesStayDeclared = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** + * DELETION GUARDS on `FormField`, which carries an index signature: deleting a + * tombstone there makes the member the signature's `any`, so the `Equal` row + * fails, and the `keyof` row fails because the key is no longer DECLARED. + */ +export type assertionFormFieldTombstonesStayDeclared = [ + Expect, GridFieldRetiredKey>, GridFieldRetiredKey>>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** The camelCase keys are live on both faces: a declared value type, not the tombstone's `undefined`. */ +export type assertionCamelKeysAreLive = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/* The refusals an author meets at the authoring site. */ +const base = { type: 'grid', name: 'lines' } as const; +// @ts-expect-error objectui#11610 — `min_rows` is retired; write `minRows`. +export const gridMinRows: GridFieldMetadata = { ...base, min_rows: 1 }; +// @ts-expect-error objectui#11610 — `max_rows` is retired; write `maxRows`. +export const gridMaxRows: GridFieldMetadata = { ...base, max_rows: 20 }; +// @ts-expect-error objectui#11610 — `allow_add` is retired; write `allowAdd`. +export const gridAllowAdd: GridFieldMetadata = { ...base, allow_add: false }; +// @ts-expect-error objectui#11610 — `allow_delete` is retired; write `allowDelete`. +export const gridAllowDelete: GridFieldMetadata = { ...base, allow_delete: false }; +// @ts-expect-error objectui#11610 — `allow_reorder` is retired; write `allowReorder`. +export const gridAllowReorder: GridFieldMetadata = { ...base, allow_reorder: false }; +// @ts-expect-error objectui#11610 — `total_field` is retired; write `totalField`. +export const gridTotalField: GridFieldMetadata = { ...base, total_field: 'amount' }; +// @ts-expect-error objectui#11610 — `add_label` is retired; write `addLabel`. +export const gridAddLabel: GridFieldMetadata = { ...base, add_label: 'Add line' }; +// @ts-expect-error objectui#11610 — `sort_field` is retired; write `sortField`. +export const gridSortField: GridFieldMetadata = { ...base, sort_field: 'position' }; + +// @ts-expect-error objectui#11610 — on the form-field face too: `min_rows` is retired; write `minRows`. +export const entryMinRows: FormField = { name: 'lines', type: 'grid', min_rows: 1 }; +// @ts-expect-error objectui#11610 — `allow_add` is retired; write `allowAdd`. +export const entryAllowAdd: FormField = { name: 'lines', type: 'grid', allow_add: false }; +// @ts-expect-error objectui#11610 — `total_field` is retired; write `totalField`. +export const entryTotalField: FormField = { name: 'lines', type: 'grid', total_field: 'amount' }; +// @ts-expect-error objectui#11610 — `sort_field` is retired; write `sortField`. +export const entrySortField: FormField = { name: 'lines', type: 'grid', sort_field: 'position' }; + +/** LIT CONTROL for the directives above: the camelCase keys compile on both faces. */ +export const gridCamel: GridFieldMetadata = { + ...base, + minRows: 1, + maxRows: 20, + allowAdd: false, + allowDelete: false, + allowReorder: false, + totalField: 'amount', + addLabel: 'Add line', + sortField: 'position', +}; +export const entryCamel: FormField = { name: 'lines', type: 'grid', minRows: 1, allowAdd: false, totalField: 'amount', sortField: 'position' }; diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts index ecf518a3ab..c06e00c3ce 100644 --- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -119,18 +119,19 @@ const GRID_ENTRY = { /** * Round 10: the `grid` widget's field-level keys — every member - * `GridFieldMetadata` declares besides `columns` (round 7) and the base field - * keys — each with a value of its declared type, on a grid entry. + * `GridFieldMetadata` declares besides `columns` (round 7), the base field + * keys and the retired snake_case tombstones — each with a value of its + * declared type, on a grid entry. camelCase since objectui#11610. */ const GRID_FIELD_KEYS = { - min_rows: 1, - max_rows: 20, - allow_add: false, - allow_delete: false, - allow_reorder: false, - total_field: 'amount', - add_label: 'Add line', - sort_field: 'position', + minRows: 1, + maxRows: 20, + allowAdd: false, + allowDelete: false, + allowReorder: false, + totalField: 'amount', + addLabel: 'Add line', + sortField: 'position', } as const; const GRID_FIELD_KEY_CASES: ReadonlyArray]> = Object.entries(GRID_FIELD_KEYS) @@ -162,7 +163,9 @@ describe('objectui#11070 — the declared read keys parse on the strict face', ( ['columns', { type: 'grid', columns: [{ name: 'qty', type: 'number' }, { name: 'sku' }] }], // Round 10: the `grid` widget's field-level keys, `GridFieldMetadata`'s // members, each on a grid entry. Every row was refused by name at the - // round's base (`fields.0.`), so each one is a refusal that flipped. + // round's base (`fields.0.KEY`), so each one is a refusal that flipped; + // objectui#11610 renamed them to camelCase (their snake_case spellings + // are refused by name, `grid-field-keys-camelcase-11610.test.ts`). ...GRID_FIELD_KEY_CASES, ]; @@ -235,15 +238,16 @@ describe('objectui#11070 — a declared key is judged by its declared type on bo ['a bare-object `columns` (the spec types it as an array)', form({ type: 'grid', columns: { name: 'qty' } })], // Round 10: each grid field-level key is judged by `GridFieldMetadata`'s // type. At the round's base the tolerant face STRIPPED every one of these - // and accepted the document, so each row is red there. - ['a string `min_rows`', form({ ...GRID_ENTRY, min_rows: '1' })], - ['a string `max_rows`', form({ ...GRID_ENTRY, max_rows: '20' })], - ['a string `allow_add`', form({ ...GRID_ENTRY, allow_add: 'false' })], - ['a string `allow_delete`', form({ ...GRID_ENTRY, allow_delete: 'false' })], - ['a string `allow_reorder`', form({ ...GRID_ENTRY, allow_reorder: 'false' })], - ['a numeric `total_field`', form({ ...GRID_ENTRY, total_field: 3 })], - ['a numeric `add_label`', form({ ...GRID_ENTRY, add_label: 1 })], - ['a numeric `sort_field`', form({ ...GRID_ENTRY, sort_field: 0 })], + // and accepted the document, so each row is red there. camelCase since + // objectui#11610. + ['a string `minRows`', form({ ...GRID_ENTRY, minRows: '1' })], + ['a string `maxRows`', form({ ...GRID_ENTRY, maxRows: '20' })], + ['a string `allowAdd`', form({ ...GRID_ENTRY, allowAdd: 'false' })], + ['a string `allowDelete`', form({ ...GRID_ENTRY, allowDelete: 'false' })], + ['a string `allowReorder`', form({ ...GRID_ENTRY, allowReorder: 'false' })], + ['a numeric `totalField`', form({ ...GRID_ENTRY, totalField: 3 })], + ['a numeric `addLabel`', form({ ...GRID_ENTRY, addLabel: 1 })], + ['a numeric `sortField`', form({ ...GRID_ENTRY, sortField: 0 })], ['a `null` `object-chart` binding (the adapter placeholder the wrapper no longer writes)', { type: 'object-chart', properties: { objectName: 'task', chartType: 'bar' }, dataSource: null }], ['an adapter-shaped `object-chart` binding', { type: 'object-chart', properties: { objectName: 'task', chartType: 'bar' }, dataSource: 'objectstack' }], ]; @@ -288,6 +292,11 @@ describe('objectui#11070 — the retired spellings stay refused on the strict fa ['summary_field', { type: 'summary', summary_field: 'amount' }], ]; + // ⚠️ The `grid` widget's eight snake_case keys (round 10's spellings, renamed + // to camelCase by objectui#11610) are NOT in this list: they stay DECLARED, + // as alias refusals naming the camelCase key, so the strict face answers + // `invalid_type` with that prescription rather than `unrecognized_keys`. + // Pinned on every face in `grid-field-keys-camelcase-11610.test.ts`. it.each(RETIRED)('the retired `fields[].%s` is refused by name', (key, field) => { expect(undeclared(issuesOf(StrictAnyComponentSchema, form(field)))).toEqual([`fields.0.${key}`]); }); @@ -379,23 +388,25 @@ export type assertionGridColumnsBySpecReference = [ * the grid's type and not mirrored on the form-field face fails to compile * here; the zod side then follows through the `UnmirroredDeclared` ratchet in * `zod-mirror-parity.test.ts` and the key set in `form-field-zod-coverage`. + * Since objectui#11610 that member set includes the eight retired snake_case + * `?: never` tombstones, so the face mirrors the refusals too. */ type DeclaredKeysOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; type GridFieldOwnKeys = Exclude; export type assertionGridFieldKeysOnTheFormFieldFace = [ // LIT CONTROL: the key set the two rows below range over is not empty, and // it holds no base field key (an empty set would pass both in silence). - Expect, 'columns' | 'sort_field'>>, + Expect, 'columns' | 'sortField' | 'sort_field'>>, Expect>, never>>, Expect>, - Expect>, - Expect>, - Expect>, - Expect>, - Expect>, - Expect>, - Expect>, - Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, ]; // @ts-expect-error objectui#11070 round 7 — `GridColumnDefinition` is RETIRED from `../field-types`: a grid column is the spec's `InlineGridColumn` (`GridFieldMetadata['columns']`). diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 220c097798..e6a3a84a7a 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -5147,10 +5147,12 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // Round 3 added `returnType` / `summaryOperations`, and round 7 the `grid` // widget's `columns` (the spec's `inlineColumns` list, so a spec bump that // moves the inline grid column moves ONE side too). Round 10 added the `grid` - // widget's eight field-level keys (`min_rows` … `sort_field`); those are - // LOCAL on both sides (the TS twin is `GridFieldMetadata`'s member by - // reference, and the spec declares none of them), so they add no spec - // crossing and leave this membership resting on the members above. + // widget's eight field-level keys (`minRows` … `sortField` since + // objectui#11610, whose snake_case spellings stay as alias refusals paired + // with `?: never` tombstones); those are LOCAL on both sides (the TS twin is + // `GridFieldMetadata`'s member by reference, and the spec declares none of + // them), so they add no spec crossing and leave this membership resting on + // the members above. 'form.zod.ts#FormFieldSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 64be46b213..5e38ebd29b 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -1025,48 +1025,52 @@ export interface GridFieldMetadata extends BaseFieldMetadata { */ columns?: SpecField['inlineColumns']; /** - * Minimum number of rows + * Minimum number of rows. The grid's Remove action is disabled at this many + * rows. */ - min_rows?: number; + minRows?: number; /** - * Maximum number of rows + * Maximum number of rows. The grid's Add and Duplicate actions are disabled, + * and no blank entry row is drawn, at this many rows. */ - max_rows?: number; + maxRows?: number; /** - * Whether to allow adding rows + * Whether to allow adding rows. On unless set `false`; each row's Duplicate + * action follows it. */ - allow_add?: boolean; + allowAdd?: boolean; /** - * Whether to allow deleting rows + * Whether to allow deleting rows. On unless set `false`. */ - allow_delete?: boolean; + allowDelete?: boolean; /** * Whether rows can be reordered by dragging. On unless set `false`; a * read-only or disabled grid never offers it. The one reorder key the grid * reads (objectui#11070 round 8 retired the undeclared `reorderable` it used * to read instead, which left this member taught and ignored). */ - allow_reorder?: boolean; + allowReorder?: boolean; /** * The CHILD column whose values are summed into the grid's footer total — * the `name` of one of {@link columns}. No total shows when it is unset. * - * It is the spec's `amountField` (`FieldSchema.inlineAmountField` on a + * It carries the spec's `amountField` (`FieldSchema.inlineAmountField` on a * `master_detail` field, `subforms[].amountField` on a form view): the * master-detail and line-items adapters in `@object-ui/plugin-form` write - * that key here. ⛔ It is NOT the spec's `totalField`, the PARENT field that - * receives the rolled-up sum on save; the grid never writes the parent. The - * one spelling the grid reads (objectui#11070 round 8 retired its - * `amount_field` / `amountField` reads, which nothing produced). + * that value here. ⚠️ Same name, different meaning: on those spec surfaces + * `totalField` is the PARENT field that receives the rolled-up sum on save, + * and the grid never writes the parent. Here it names the child column + * summed. The one spelling the grid reads (objectui#11070 round 8 retired + * its `amount_field` / `amountField` reads, which nothing produced). */ - total_field?: string; + totalField?: string; /** * Label of the grid's Add button, and the label its empty state names. The * locale's own wording shows when it is unset. It is the spec's * `subforms[].addLabel`, which the master-detail adapter in * `@object-ui/plugin-form` writes here. */ - add_label?: string; + addLabel?: string; /** * The CHILD field the grid stamps with each row's index (0, 1, 2, …) on * every change, so the order a drag-reorder leaves survives a save and a @@ -1081,9 +1085,91 @@ export interface GridFieldMetadata extends BaseFieldMetadata { * retired the detail's `sortField` override, which nothing wrote), and the * spec declares no inline sort-field key. */ - sort_field?: string; + sortField?: string; + + // ── The retired snake_case spellings (objectui#11610) ─────────────────── + // + // These eight keys were the grid's field-level keys until objectui#11610 + // renamed each to the camelCase member above, so that `@objectstack/spec`'s + // runtime form field can declare them under its camelCase rule for config + // keys. They retired at once, with no alias window: no reader reads them, + // and each is REFUSED BY NAME on every face: here, as a `?: never` + // tombstone; on the form-field zod mirror, as an alias refusal naming the + // camelCase key; and in the `grid` widget, which draws a named refusal + // instead of the grid. {@link GRID_FIELD_RETIRED_KEYS} maps each to its + // replacement. + + /** + * REFUSED BY NAME (objectui#11610): renamed {@link minRows}. + * @deprecated Write `minRows`. Nothing reads this spelling. + */ + min_rows?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link maxRows}. + * @deprecated Write `maxRows`. Nothing reads this spelling. + */ + max_rows?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link allowAdd}. + * @deprecated Write `allowAdd`. Nothing reads this spelling. + */ + allow_add?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link allowDelete}. + * @deprecated Write `allowDelete`. Nothing reads this spelling. + */ + allow_delete?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link allowReorder}. + * @deprecated Write `allowReorder`. Nothing reads this spelling. + */ + allow_reorder?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link totalField}. + * @deprecated Write `totalField`. Nothing reads this spelling. + */ + total_field?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link addLabel}. + * @deprecated Write `addLabel`. Nothing reads this spelling. + */ + add_label?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link sortField}. + * @deprecated Write `sortField`. Nothing reads this spelling. + */ + sort_field?: never; } +/** A member `GridFieldMetadata` declares beyond `BaseFieldMetadata`. */ +type GridFieldOwnKey = Exclude; + +/** + * The `grid` field's retired snake_case field-level keys, each mapped to the + * camelCase {@link GridFieldMetadata} member that replaced it (objectui#11610). + * + * The one list every face of the retirement reads: the form-field zod mirror + * declares an alias refusal per entry, naming the value as the key to write, + * and the `grid` widget refuses a field carrying any entry, naming the same + * replacement. A key or a value that is not one of `GridFieldMetadata`'s own + * members fails to compile here; that the keys are exactly its `?: never` + * tombstones, and the values exactly its camelCase keys, is pinned in + * `__tests__/grid-field-keys-camelcase-11610.test.ts`. + */ +export const GRID_FIELD_RETIRED_KEYS = { + min_rows: 'minRows', + max_rows: 'maxRows', + allow_add: 'allowAdd', + allow_delete: 'allowDelete', + allow_reorder: 'allowReorder', + total_field: 'totalField', + add_label: 'addLabel', + sort_field: 'sortField', +} as const satisfies { readonly [K in GridFieldOwnKey]?: GridFieldOwnKey }; + +/** A retired snake_case spelling of a `grid` field-level key: a key of {@link GRID_FIELD_RETIRED_KEYS}. */ +export type GridFieldRetiredKey = keyof typeof GRID_FIELD_RETIRED_KEYS; + export interface ColorFieldMetadata extends BaseFieldMetadata { type: 'color'; } diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 8230944c3d..529d7efdb7 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -2073,24 +2073,74 @@ export interface FormField { // `GridFieldMetadata`'s own member BY REFERENCE, so the form-field face and // the grid field's published type cannot drift; the meaning of each key is // documented there. Only the `grid` widget reads them: on any other field - // type they are accepted and read by nothing. + // type they are accepted and read by nothing. objectui#11610 renamed all + // eight from snake_case to camelCase. - /** The grid's minimum row count: {@link GridFieldMetadata.min_rows}. */ - min_rows?: GridFieldMetadata['min_rows']; - /** The grid's maximum row count: {@link GridFieldMetadata.max_rows}. */ - max_rows?: GridFieldMetadata['max_rows']; - /** Whether the grid offers Add (on unless `false`): {@link GridFieldMetadata.allow_add}. */ - allow_add?: GridFieldMetadata['allow_add']; - /** Whether the grid offers Delete (on unless `false`): {@link GridFieldMetadata.allow_delete}. */ - allow_delete?: GridFieldMetadata['allow_delete']; - /** Whether rows can be drag-reordered (on unless `false`): {@link GridFieldMetadata.allow_reorder}. */ - allow_reorder?: GridFieldMetadata['allow_reorder']; - /** The CHILD column summed into the footer total: {@link GridFieldMetadata.total_field}. */ - total_field?: GridFieldMetadata['total_field']; - /** The Add button's label: {@link GridFieldMetadata.add_label}. */ - add_label?: GridFieldMetadata['add_label']; - /** The row field stamped with each row's index: {@link GridFieldMetadata.sort_field}. */ - sort_field?: GridFieldMetadata['sort_field']; + /** The grid's minimum row count: {@link GridFieldMetadata.minRows}. */ + minRows?: GridFieldMetadata['minRows']; + /** The grid's maximum row count: {@link GridFieldMetadata.maxRows}. */ + maxRows?: GridFieldMetadata['maxRows']; + /** Whether the grid offers Add (on unless `false`): {@link GridFieldMetadata.allowAdd}. */ + allowAdd?: GridFieldMetadata['allowAdd']; + /** Whether the grid offers Delete (on unless `false`): {@link GridFieldMetadata.allowDelete}. */ + allowDelete?: GridFieldMetadata['allowDelete']; + /** Whether rows can be drag-reordered (on unless `false`): {@link GridFieldMetadata.allowReorder}. */ + allowReorder?: GridFieldMetadata['allowReorder']; + /** The CHILD column summed into the footer total: {@link GridFieldMetadata.totalField}. */ + totalField?: GridFieldMetadata['totalField']; + /** The Add button's label: {@link GridFieldMetadata.addLabel}. */ + addLabel?: GridFieldMetadata['addLabel']; + /** The row field stamped with each row's index: {@link GridFieldMetadata.sortField}. */ + sortField?: GridFieldMetadata['sortField']; + + // ── Their retired snake_case spellings (objectui#11610) ───────────────── + // + // REFUSED BY NAME, as on `GridFieldMetadata` (where the retirement is + // documented): each is a `?: never` tombstone here (a declared member, so + // it outranks the index signature above), and an alias refusal naming the + // camelCase key on the zod mirror. `GRID_FIELD_RETIRED_KEYS` maps each to + // its replacement. + + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.minRows}. + * @deprecated Write `minRows`. Nothing reads this spelling. + */ + min_rows?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.maxRows}. + * @deprecated Write `maxRows`. Nothing reads this spelling. + */ + max_rows?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.allowAdd}. + * @deprecated Write `allowAdd`. Nothing reads this spelling. + */ + allow_add?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.allowDelete}. + * @deprecated Write `allowDelete`. Nothing reads this spelling. + */ + allow_delete?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.allowReorder}. + * @deprecated Write `allowReorder`. Nothing reads this spelling. + */ + allow_reorder?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.totalField}. + * @deprecated Write `totalField`. Nothing reads this spelling. + */ + total_field?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.addLabel}. + * @deprecated Write `addLabel`. Nothing reads this spelling. + */ + add_label?: never; + /** + * REFUSED BY NAME (objectui#11610): renamed {@link FormField.sortField}. + * @deprecated Write `sortField`. Nothing reads this spelling. + */ + sort_field?: never; } /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e37bbd4535..6c9ada3d2a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -588,7 +588,12 @@ export type { ObjectSchemaMetadata, ObjectSchemaClientExtensions, ObjectIndex, + GridFieldRetiredKey, } from './field-types.js'; +// objectui#11610 — the `grid` field's retired snake_case field-level keys, each +// mapped to its camelCase replacement: the one list the form-field zod mirror's +// alias refusals and the `grid` widget's named refusal both read. +export { GRID_FIELD_RETIRED_KEYS } from './field-types.js'; // System / audit / ownership field classification — runtime helper + name set, // used by default list-column derivation to keep framework-injected fields diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index fb770a8054..4fa730a5e8 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -29,6 +29,7 @@ import { ExpressionWireSchema } from './expression.zod.js'; import { EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema } from '@objectstack/spec/shared'; import { stripImportedDefaults } from './imported-defaults.js'; import { closeStrictUnionArms } from './node-derivation.js'; +import { GRID_FIELD_RETIRED_KEYS, type GridFieldRetiredKey } from '../field-types.js'; /** * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). @@ -1004,6 +1005,27 @@ function unresolvableFieldWidgetNamespaceMessage(id: string): string { ); } +/** + * The alias refusal for one of the `grid` widget's retired snake_case + * field-level keys (objectui#11610), on `FormFieldSchema`: the + * {@link aliasKeyRefusal} lead sentence names the camelCase key the entry + * should carry, read from `GRID_FIELD_RETIRED_KEYS`, so the arm, the TS twin's + * `?: never` tombstone and the `grid` widget's refusal name the same + * replacement. The value an author wrote is valid under the new key as it + * stands; only the key changes. + */ +function retiredGridFieldKey(alias: GridFieldRetiredKey) { + const canonical = GRID_FIELD_RETIRED_KEYS[alias]; + return aliasKeyRefusal( + alias, + canonical, + 'this form field', + `The \`grid\` widget's field-level keys are camelCase since objectui#11610, and no reader reads the ` + + `snake_case spelling, so it is refused here by name rather than stripped. Rename the key to \`${canonical}\`; ` + + 'its value stays the same.', + ); +} + export const FormFieldSchema = z.object({ id: z.string().optional().describe('Field ID'), name: z.string().describe('Field name (form data path)'), @@ -1076,22 +1098,35 @@ export const FormFieldSchema = z.object({ // are `GridFieldMetadata`'s members, which the TS twin carries by reference; // `@objectstack/spec` declares none of them (a `grid` field is objectui's own // type), so each value schema here is the TS member's own type, stated once. - min_rows: z.number().optional() + // objectui#11610 renamed all eight from snake_case to camelCase. + minRows: z.number().optional() .describe('Minimum row count of a `grid` field; read only by the `grid` widget'), - max_rows: z.number().optional() + maxRows: z.number().optional() .describe('Maximum row count of a `grid` field; read only by the `grid` widget'), - allow_add: z.boolean().optional() + allowAdd: z.boolean().optional() .describe('Whether a `grid` field offers Add (on unless false); read only by the `grid` widget'), - allow_delete: z.boolean().optional() + allowDelete: z.boolean().optional() .describe('Whether a `grid` field offers Delete (on unless false); read only by the `grid` widget'), - allow_reorder: z.boolean().optional() + allowReorder: z.boolean().optional() .describe('Whether a `grid` field\'s rows can be drag-reordered (on unless false); read only by the `grid` widget'), - total_field: z.string().optional() + totalField: z.string().optional() .describe('Name of the CHILD column a `grid` field sums into its footer total; read only by the `grid` widget'), - add_label: z.string().optional() + addLabel: z.string().optional() .describe('Label of a `grid` field\'s Add button; read only by the `grid` widget'), - sort_field: z.string().optional() + sortField: z.string().optional() .describe('Name of the row field a `grid` field stamps with each row\'s index, so a drag-reorder persists; read only by the `grid` widget'), + // objectui#11610 — the eight retired snake_case spellings, each REFUSED BY + // NAME with the camelCase key to write instead (the TS twin's `?: never` + // tombstones). One list feeds both the arms and the `grid` widget's own + // refusal: `GRID_FIELD_RETIRED_KEYS`. + min_rows: retiredGridFieldKey('min_rows'), + max_rows: retiredGridFieldKey('max_rows'), + allow_add: retiredGridFieldKey('allow_add'), + allow_delete: retiredGridFieldKey('allow_delete'), + allow_reorder: retiredGridFieldKey('allow_reorder'), + total_field: retiredGridFieldKey('total_field'), + add_label: retiredGridFieldKey('add_label'), + sort_field: retiredGridFieldKey('sort_field'), }).superRefine((field, ctx) => { // objectui#5449 — the namespace rule `@object-ui/core` has enforced since // objectui#5375, stated here so `objectui validate` (which reaches this