Skip to content

Commit 6fb7115

Browse files
feat(spec): the object-form runtime form field declares the grid widget's eight camelCase keys (#21768) (#21825)
Fixes #21768 Clause-②: yes (widening) ## What this does The spec half of objectstack-ai/objectui#11610, which renamed the `grid` widget's eight field-level keys to camelCase (merge `2abec3a96c`). The `.objectui-sha` pin on `main` is `9dfaca654311`, and it carries that merge: `merge-base --is-ancestor 2abec3a96c 9dfaca654311` exits 0, which proves ancestry on its own. This executes ruling B on #21704 fork 2 (record `5978663135`). That ruling deferred the eight keys "until objectui camelCases them". This PR also answers objectui's two asks in `5981368783` and the at-tier review's ③ in `5986881331`. It works under the claim `5987845581`. | | before | after | |:--|:--|:--| | runtime form field (`buildObjectFormRuntimeField`: `object-form` `customFields[]`, and the inline arm of both forms' `sections[].fields[]`) | `minRows` … `sortField` refused as unrecognized keys | declared with the widget's value types | | the eight snake_case spellings | refused, with one shared prescription: "these come in once the widget reads a camelCase spelling" | still refused by name; each refusal names its own camelCase key | | `totalField` | not declared | described as the grid's CHILD column summed into the footer, not the PARENT field a master-detail or `record:line_items` sum is saved to | ## Census at the pin (objectui `9dfaca654311`, read with `git show`) | key | value type: `GridFieldMetadata` (`types/src/field-types.ts:1031-1088`) and the zod mirror (`types/src/zod/form.zod.ts:1102-1117`) | grid read (`fields/src/widgets/GridField.tsx`, `cfg = field` at `:669`) | snake_case spelling | |:--|:--|:--|:--| | `minRows` | `number` / `z.number()` | `:762`; Remove stops at it (`:882`, `:1447`) | `min_rows`: refused | | `maxRows` | `number` / `z.number()` | `:763`; Add, Duplicate and the blank row stop at it (`:808`, `:850`, `:892`, `:1077`, `:1487`) | `max_rows`: refused | | `allowAdd` | `boolean` / `z.boolean()` | `:749`, `!== false`; off when read-only or disabled | `allow_add`: refused | | `allowDelete` | `boolean` / `z.boolean()` | `:750`, the same | `allow_delete`: refused | | `allowReorder` | `boolean` / `z.boolean()` | `:783`, the same | `allow_reorder`: refused | | `totalField` | `string` / `z.string()` | `:771`; summed into the footer at `:919-923` | `total_field`: refused | | `addLabel` | `string` / `z.string()` | `:1322` (empty state) and `:1491` (Add button) | `add_label`: refused | | `sortField` | `string` / `z.string()` | `:778`; stamped on every row by `emit` (`:785-790`) | `sort_field`: refused | - **No snake_case read remains.** `git grep` of the eight snake_case spellings over objectui's `packages/*/src` (tests and stories excluded) returns only its refusal faces: - the TS tombstones (`field-types.ts:1106-1141`, `form.ts:2108-2143`); - `GRID_FIELD_RETIRED_KEYS` (`field-types.ts:1159`); - the zod alias refusals (`zod/form.zod.ts:1122-1129`); - one comment in `GridField.tsx:617`. The widget refuses a field that carries any of them, and draws a refusal instead of the grid (`GridField.tsx:643-650`). The control grep for the camelCase spellings over the same scope has 38 hits in `GridField.tsx` alone. - **`FormField` takes each key by reference** to `GridFieldMetadata` (`types/src/form.ts:2079-2094`). - **`totalField` is a homonym.** The line-items panel and the master-detail form both hand the grid their `amountField` *as* its `totalField` (`plugin-form/src/LineItemsPanel.tsx:710`, `MasterDetailForm.tsx:876`). ## Writers of either spelling in this repo (base `75ddcd1b41`) - **snake_case:** none authored. - The only authored value is the spec pin's own refusal probe (`component-form-custom-fields-sections-typed.pin.test.ts`, `min_rows`). - Prose mentions: the S-forms changeset's FROM → TO row, the console pin changeset's interim note, and dated comments in `conversions/registry.ts`, `migrations/registry.ts` and two migration entries. - **camelCase on an inline form field:** none in `examples/`, `skills/`, `content/docs/` or `apps/`. - The `addLabel` hits there (`examples/app-showcase/.../project-workspace.page.ts:59`, `skills/objectstack-ui/SKILL.md:83`) are master-detail detail entries, a different surface that is unchanged. - The objectui writer this PR pins is the schema catalog's `fields-grid/line-items-grid` field, which carries all eight keys. ## Changes, `packages/spec/src/ui/component.zod.ts` - **The eight members.** A new builder, `objectFormRuntimeFieldGridMembers()`, declares them, and the runtime field spreads it into its shape. The builder's docblock carries the read points above. - **The refusal map.** `OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS` is now a map from each snake_case key to its camelCase key (`satisfies` the declared key union, so a value naming an undeclared key fails `tsc`). - **One guidance set per entry, under the map's name.** A set answers once per message, so this way each written spelling gets its own bullet naming its own replacement. The `total_field` bullet also restates the CHILD-column meaning. - **The three comments moved:** - the line-items guidance block, re-measured at `9dfaca654311`: `LineItemsPanel.tsx:696-720`, `:816-827`, spelled `addLabel` / `sortField`; - the `customFields` fork note, which now records the rename; - the master-detail `sortField` retirement note, which keeps its dated read and adds a hop sentence: the derived field is handed to the grid as `sortField` at `MasterDetailForm.tsx:877`, and the entry key is still a tombstone. - **One runtime string had to change, because this PR makes it false.** `record:line_items`' `sortField` refusal said "No block takes an authored `sortField`". An inline `grid` field now takes one, for the rows of its own value, so the sentence now reads "No block takes an authored `sortField` for child records". The accept set is unchanged. `content/docs/references/ui/component.mdx` was regenerated by `check:generated --fix`, which named only `gen:docs` as stale. The changeset is `.changeset/21768-object-form-runtime-field-grid-camelcase-keys.md`: `@objectstack/spec` minor, with `Clause-②: yes (widening)` at line start. ## D3 reading No accept set narrows. Every value that parsed at the base still parses, so no ADR-0087 D3 entry and no conversion is owed. Before this PR, the eight camelCase keys were refused (`unrecognized_keys`) and the eight snake_case keys were refused. After it, the camelCase keys parse and the snake_case keys are still refused. A camelCase key with the wrong value type is still refused, now as `invalid_type`. No step-18 file is touched. ## Tests, at head `bb32836dc9` **`component-form-custom-fields-sections-typed.pin.test.ts`** - **§1:** the catalog writer parses byte-identical as a `customFields` member. Lit controls: the keys on an `object-form` section's inline entry, with the three switches `false`, and on an `object-master-detail-form` section's inline entry. - **§2:** the existing `min_rows` row keeps its code and path. Its prescription check now asserts that the bullet names `min_rows` and `minRows`. - **§3:** the key-set pin gains the eight keys. - **§5** (new): - each snake_case key gives `unrecognized_keys` at `customFields.0`, and exactly one bullet names that key and its camelCase key. The camelCase key then parses with the same value. - two retired keys on one field give two bullets, one each. - a section's inline entry refuses `total_field` the same way. - each camelCase key refuses a wrong value type as `invalid_type` at its own path. - each describe names its one reader. - the `totalField` homonym: the inline grid field's describe starts with the CHILD column and the footer, and names `amountField`. The `record:line_items` and master-detail detail-entry describes start with "Parent field to receive the rolled-up sum" and never say CHILD or footer. The assertions check named subjects, not copy. **`master-detail-detail-sort-field-retirement.test.ts`** - Its tree-scoped absence walk flagged the new pin's `sortField: 'position'`. The walk's own header covers this case: "A future schema that declares a `sortField` of its own would trip this walk: narrow the matcher to detail entries then, never exclude the new file." - So the matcher is narrowed, and no file is excluded. A match is skipped only when the object literal around it names `type` or `widget` `grid` / `field:grid` at its own level, which marks the inline grid field. - Three anti-vacuity probes stay offenders: a nested column's `grid` type, an inline grid field placed earlier than a detail entry in the same text, and `type: 'grids'`. - The six pre-existing probes are unchanged. **Ablation** - **Prediction, written before the run:** dropping `sortField` from the builder turns six tests red. They are §3's key set, §1's two grid writer rows, §5's `sort_field` refusal row, §5's `sortField` value-type row, and §5's describes test. The retirement pin stays green. - **Method:** `node scripts/ablation-replace.mjs --delete`. The anchor hit once and went 1 → 0, and the blob went `298601db7467` → `48deefce4054`. - **Result:** exactly those six went red, `Tests 6 failed | 108 passed (114)`. - **Restore:** the blob matches HEAD `298601db7467`, `git diff HEAD` is empty, and `git status --porcelain` is empty. - **Two runs:** at `edbdef7bf8`, and again at `bb32836dc9`. The subject is imported from `./component.zod` (source, not `dist`), so no rebuild is involved. **Suites** - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec typecheck` at `bb32836dc9`: exit 0. - `check:test-typecheck` says OK, and the debt ledger is unchanged. - `tsc -p tsconfig.test.json --listFilesOnly` lists both edited test files, and neither has a ledger entry. - The full spec suite (`vitest run --project local`): `Test Files 615 passed (615)`, `Tests 18383 passed | 1 todo`. It ran at `edbdef7bf8`. The only later change is the pin file's assertion wording, re-run at `bb32836dc9` with `alias-integrity.test.ts` and `strict-object.test.ts`: `Tests 166 passed (166)`. ## Gates, at head `bb32836dc9` - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 110 commands for this change set, and every one exits 0. - `--ran` reconciliation: `110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN`. - Exit codes were captured before any pipe. - `check:type-check-debt` ran under the verify lock, because its first unlocked run hit the 280s per-command cap. - At the first head (`edbdef7bf8`), six gates (`check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:lean-entry-closure` and `check:dual-build-cjs-loads`) first answered exit 3, PREREQUISITE NOT MET, because their packages had no `dist`. After a turbo build of `lint`, `formula`, `client-react` and `objectql` (all cache hits), each answered 0. All six also answered 0 at `bb32836dc9`. - **Lint, a measured narrowing.** Repo-wide `pnpm lint` is CI's. Locally, ESLint ran over the three edited TS files at `bb32836dc9`, with three pieces of evidence: - **Population:** each file resolves a config through `--print-config` (`packages/**/*.{ts,tsx,mts,cts}`), so none is ignored. - **Count:** `--format json` reports 3 files, 0 errors and 0 warnings. - **Invariance:** the resolved `parserOptions` holds only `ecmaVersion` and `sourceType`, with no `project`, and no typed rule is on. Linting is not type-aware, so this diff cannot change the verdict on any file it did not touch. - **Not measured locally:** the path-scheduled CI jobs and type-check lanes that `dispatch-gates` lists outside its derived set (Test Core shards, Dogfood, Build Core, Build Docs, Temporal Conformance, and the workspace and consumer type checks). CI runs them. ## Declared beyond the claim's listed surface - `master-detail-detail-sort-field-retirement.test.ts`: the claim lists no file for this edit. The new `sortField` member is what tripped the walk, and the walk's header says what to do, so it was narrowed, not excluded (see Tests). - The `record:line_items` `sortField` refusal sentence: the sentence became false with this PR, so it was corrected in the same file (see Changes). ## Acceptance notes - `main` is 2 commits past this branch's base (`ba57588665`). They touch `service-settings`, the QA checklist JSON and a comment-only hunk in `spec/src/contracts/crypto-provider.ts`. None of that overlaps this diff, so `main` was not merged in. CI's merge ref covers the combination. - Three dated prose records still describe the pre-rename spelling or the old reach. All are accurate as dated reads, and nothing parses them. Carrier: none. - The `replacement` text of the D3 entry `ui-record-line-items-props-closed` says "`sortField`, which no block takes". - `conversions/registry.ts` (about `:12488`) and the retired-key entry `18.ui__ObjectMasterDetailFormProps__details.sortField` say the derived field is "handed to the grid as `sort_field` (`:874`)", read at pin `89cad75d5570`. - The S-forms D3 `reason` says the snake_case keys stay out "until the widget reads a camelCase" spelling. - The pending S-forms changeset (`.changeset/21464-component-props-form-custom-fields-sections-typed.md`) still says the eight keys "come in once the widget reads a camelCase spelling". If it ships in the same release as this changeset, the CHANGELOG reads as a sequence: that entry, then this one stating that they came in. It is not edited here, because it is outside this PR's surface. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 53021e3 commit 6fb7115

5 files changed

Lines changed: 370 additions & 33 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an inline `object-form` field declares the `grid` widget's eight camelCase field-level keys, and each snake_case spelling is refused naming its camelCase key (#21768)
6+
7+
Clause-②: yes (widening)
8+
9+
A widening of a published authoring surface: every value that parsed before still parses, and eight keys that were refused now parse. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`.
10+
11+
**`@objectstack/spec`**
12+
13+
- **The runtime form field takes the `grid` widget's field-level keys.** An `object-form` `customFields` member, and the inline entry of an `object-form` or `object-master-detail-form` section's `fields`, now declare `minRows` and `maxRows` (numbers), `allowAdd`, `allowDelete` and `allowReorder` (booleans, on unless `false`), and `totalField`, `addLabel` and `sortField` (strings). These are the value types objectui's `GridFieldMetadata` declares. The `grid` widget reads each one off a `type: 'grid'` field: `minRows` stops Remove, `maxRows` stops Add, Duplicate and the blank entry row, `addLabel` labels the Add button, and `sortField` names the row field the grid stamps with each row's index, so a drag-reorder is saved. objectui renamed the eight from snake_case to camelCase, with no dual read, and the `.objectui-sha` pin `9dfaca654311` carries that rename.
14+
- **`totalField` here is the CHILD column the grid sums into its footer.** On `record:line_items` and on an `object-master-detail-form` detail entry, the same spelling names the PARENT field the sum is saved to, and their child column is `amountField`. The describe states the difference. The other blocks' keys are unchanged.
15+
- **The snake_case spellings are still refused, and each refusal now names its own replacement.** `min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label` and `sort_field` are each answered with "Rename the key to `minRows`" (and so on); the value stays the same. The old answer said the keys would come in once the widget read a camelCase spelling, and the widget now does. Two retired spellings on one field get one line each.
16+
- **`record:line_items`' `sortField` refusal** now says no block takes an authored `sortField` *for child records*. An inline `grid` field takes one for the rows of its own value, so the unqualified sentence was no longer true.
17+
18+
## FROM → TO
19+
20+
| you wrote | write instead |
21+
|:--|:--|
22+
| `customFields: [{ name: 'items', type: 'grid', min_rows: 1, allow_add: false }]` | `customFields: [{ name: 'items', type: 'grid', minRows: 1, allowAdd: false }]` |
23+
| `total_field: 'amount'` on an inline grid field | `totalField: 'amount'`, naming the child column summed |
24+
| `add_label: 'Add line'`, `sort_field: 'position'` | `addLabel: 'Add line'`, `sortField: 'position'` |
25+
26+
The one-line fix: rename each key to its camelCase spelling, keeping its value. Nothing that parsed before is refused, so no ADR-0087 conversion or D3 entry is owed.
27+
28+
## Who is affected, measured
29+
30+
- **objectstack** at `75ddcd1b41` (this branch's base): no writer of either spelling on an inline form field in `examples/`, `skills/`, `content/docs/` or `apps/`. The spec's own pin is the only one: its `min_rows` refusal probe.
31+
- **objectui** at the pin `9dfaca654311`: the camelCase writer is the schema catalog's `fields-grid/line-items-grid` example, a `grid` field carrying all eight keys, and it parses. No production source reads a snake_case spelling. The only snake_case occurrences left are objectui's own refusal faces: the TS tombstones, the zod alias refusals, and the widget's refusal.
32+
- **hotcrm**, **cloud** and deployed metadata were not measured.

‎content/docs/references/ui/component.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,14 @@ Sort field and direction pair
657657
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | The value type a formula field displays (number / text / boolean / date) |
658658
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` |
659659
| **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes |
660+
| **minRows** | `number` | optional | A `grid` field's minimum row count: its Remove action is disabled at this many rows (no minimum when unset). Read only by the `grid` widget |
661+
| **maxRows** | `number` | optional | A `grid` field's maximum row count: its Add and Duplicate actions are disabled, and no blank entry row is drawn, at this many rows (no maximum when unset). Read only by the `grid` widget |
662+
| **allowAdd** | `boolean` | optional | Whether a `grid` field offers Add, and each row's Duplicate (a duplicate is an add): on unless `false`. A read-only or disabled grid offers neither. Read only by the `grid` widget |
663+
| **allowDelete** | `boolean` | optional | Whether a `grid` field offers each row's Delete: on unless `false`. A read-only or disabled grid never offers it. Read only by the `grid` widget |
664+
| **allowReorder** | `boolean` | optional | Whether a `grid` field's rows can be reordered by dragging: on unless `false`. A read-only or disabled grid never offers it. Read only by the `grid` widget |
665+
| **totalField** | `string` | optional | The CHILD column a `grid` field sums into its footer total — the `name` of one of its `columns`; no total shows when unset. Not the PARENT field a master-detail or `record:line_items` sum is saved to: those blocks spell that `totalField` and the child column `amountField`, and the grid writes no parent field. Read only by the `grid` widget |
666+
| **addLabel** | `string` | optional | Label of a `grid` field's Add button, also named in its empty state (the locale's own wording when unset). A plain string. Read only by the `grid` widget |
667+
| **sortField** | `string` | optional | A field on each row that a `grid` field stamps with the row's index (0, 1, 2, …) on every change, so the order a drag-reorder leaves is saved with the rows (rows carry no position when unset). A row field, not one of `columns`: a column of that name has its typed value overwritten. Read only by the `grid` widget |
660668

661669
### Nested Shape: `ObjectFormProps.sections[number]`
662670

‎packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts‎

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@
3333
* both rows share one section instance, and the section's inline arm is the
3434
* one runtime field `customFields` takes.
3535
* - §4 THE REGISTRATION: the ADR-0087 D3 entries step 18 carries.
36+
* - §5 THE GRID WIDGET'S CAMELCASE KEYS (#21768): objectstack-ai/objectui#11610
37+
* renamed the `grid` widget's eight field-level keys to camelCase, and the
38+
* `.objectui-sha` pin `9dfaca654311` carries it, so the runtime field declares
39+
* `minRows`, `maxRows`, `allowAdd`, `allowDelete`, `allowReorder`,
40+
* `totalField`, `addLabel` and `sortField` with the widget's value types. The
41+
* writer shape parses (§1); each snake_case spelling is refused by name with
42+
* its own camelCase key; and `totalField`'s describe says it is the grid's
43+
* CHILD column — the opposite of the same spelling on `record:line_items` and
44+
* a master-detail detail entry, which names the PARENT field the sum is saved
45+
* to. A widening: nothing that parsed before is refused now.
3646
*
3747
* The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the
3848
* other half: the `customFields` and both `sections[]` fork lines left its
@@ -46,6 +56,7 @@ import {
4656
ComponentPropsMap,
4757
ObjectFormPropsSchema,
4858
ObjectMasterDetailFormPropsSchema,
59+
RecordLineItemsProps,
4960
} from './component.zod';
5061
import { FormFieldSchema, FormSectionSchema, FormSelectOptionSchema } from './view.zod';
5162
import { SelectOptionSchema } from '../data/field.zod';
@@ -201,6 +212,29 @@ describe('§1 each member accepts every shape a measured writer authors', () =>
201212
{ name: 'rest', label: 'Rest', columns: 1, pane: 'secondary', fields: ['note'] },
202213
],
203214
}],
215+
// §5 — objectui `examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json` at the
216+
// `.objectui-sha` pin `9dfaca654311`: the one `fields[]` entry, a `grid` field carrying all eight
217+
// camelCase keys, the shape objectui's grid-field guide (`content/docs/fields/grid.mdx`) teaches.
218+
['the grid widget\'s camelCase keys on an inline grid field', 'object-form', {
219+
customFields: [{
220+
name: 'line_items', label: 'Line Items', type: 'grid',
221+
columns: [
222+
{ name: 'product', label: 'Product', type: 'text' },
223+
{ name: 'quantity', label: 'Qty', type: 'number' },
224+
{ name: 'amount', label: 'Amount', type: 'currency' },
225+
],
226+
minRows: 1, maxRows: 10, allowAdd: true, allowDelete: true, allowReorder: true,
227+
totalField: 'amount', addLabel: 'Add line item', sortField: 'position',
228+
}],
229+
}],
230+
// Lit control: the same keys on a section's inline entry (the one runtime field), the three
231+
// switches off, on both rows that take the section.
232+
['the grid widget\'s camelCase keys on a section\'s inline entry', 'object-form', {
233+
sections: [{ label: 'Lines', fields: [{ name: 'lines', type: 'grid', columns: [{ name: 'qty', type: 'number' }], minRows: 0, maxRows: 3, allowAdd: false, allowDelete: false, allowReorder: false, addLabel: 'Add', sortField: 'line_no' }] }],
234+
}],
235+
['the grid widget\'s camelCase keys on a master-detail section\'s inline entry', 'object-master-detail-form', {
236+
sections: [{ label: 'Lines', fields: [{ name: 'lines', type: 'grid', columns: [{ name: 'amount', type: 'currency' }], totalField: 'amount' }] }],
237+
}],
204238
['no sections', 'object-form', { sections: [] }],
205239
// objectui `plugin-form/src/masterDetailFormTypeVocabulary.test.tsx:109` — the parent half's sections.
206240
['the parent half\'s sections', 'object-master-detail-form', {
@@ -342,7 +376,7 @@ describe('§2 off-shape values are refused with the code and the path', () => {
342376
expect(say({ defaultValue: 'X' })).toMatch(/block's\s+`initialValues`/);
343377
expect(say({ id: 'a1' })).toMatch(/identified by its `name`/);
344378
expect(say({ fields: ['b'] })).toMatch(/Group fields with the block's `sections`/);
345-
expect(say({ min_rows: 1 })).toMatch(/snake_case field-level keys/);
379+
expect(say({ min_rows: 1 })).toMatch(/^ {2}• .*`min_rows`.*`minRows`/m);
346380
expect(say({ helpText: 'x' })).toMatch(/`helpText` → `description`/);
347381
expect(say({ validation: { required: true } })).toMatch(/the MESSAGE a required field shows/);
348382
expect(say({ validation: { pattern: { value: '^a', message: 'x' } } })).toMatch(/field's own `pattern` string/);
@@ -381,10 +415,11 @@ describe('§2 off-shape values are refused with the code and the path', () => {
381415
describe('§3 the declared members, and one shape for both rows', () => {
382416
it('the runtime form field declares exactly the members the form draws', () => {
383417
expect(Object.keys(runtimeField().shape).sort()).toEqual([
384-
'accept', 'colSpan', 'columns', 'dependsOn', 'description', 'dimensions', 'disabled', 'group', 'hidden',
385-
'inputType', 'label', 'max', 'maxLength', 'min', 'minLength', 'multiple', 'name', 'options', 'pattern',
386-
'placeholder', 'readonly', 'readonlyWhen', 'reference', 'required', 'requiredWhen', 'returnType', 'rows',
387-
'span', 'summaryOperations', 'type', 'validation', 'visibleWhen', 'widget',
418+
'accept', 'addLabel', 'allowAdd', 'allowDelete', 'allowReorder', 'colSpan', 'columns', 'dependsOn',
419+
'description', 'dimensions', 'disabled', 'group', 'hidden', 'inputType', 'label', 'max', 'maxLength',
420+
'maxRows', 'min', 'minLength', 'minRows', 'multiple', 'name', 'options', 'pattern', 'placeholder',
421+
'readonly', 'readonlyWhen', 'reference', 'required', 'requiredWhen', 'returnType', 'rows', 'sortField',
422+
'span', 'summaryOperations', 'totalField', 'type', 'validation', 'visibleWhen', 'widget',
388423
]);
389424
});
390425

@@ -453,3 +488,90 @@ describe('§4 each narrowing is registered as the ADR-0087 D3 entry step 18 carr
453488
expect(ids).toContain(id);
454489
});
455490
});
491+
492+
// ───────────────────────────────────────────────────────────────────────────
493+
// §5 the grid widget's camelCase keys (#21768)
494+
// ───────────────────────────────────────────────────────────────────────────
495+
496+
describe('§5 the grid widget\'s eight field-level keys, camelCase since objectstack-ai/objectui#11610', () => {
497+
// The rename, as objectui's `GRID_FIELD_RETIRED_KEYS` pairs it (`types/src/field-types.ts:1159` at the
498+
// `.objectui-sha` pin `9dfaca654311`), with each key's value type as `GridFieldMetadata` declares it.
499+
const GRID_KEYS = [
500+
['min_rows', 'minRows', 1, 'one'],
501+
['max_rows', 'maxRows', 10, '10'],
502+
['allow_add', 'allowAdd', false, 'no'],
503+
['allow_delete', 'allowDelete', false, 0],
504+
['allow_reorder', 'allowReorder', true, 'true'],
505+
['total_field', 'totalField', 'amount', 5],
506+
['add_label', 'addLabel', 'Add line', { en: 'Add line' }],
507+
['sort_field', 'sortField', 'position', ['position']],
508+
] as const;
509+
const field = (member: Record<string, unknown>) =>
510+
parse('object-form', { customFields: [{ name: 'items', type: 'grid', ...member }] });
511+
512+
/** The prescription bullets of a refusal (`strictUnknownKeyError` renders each as one ` • ` line). */
513+
const bullets = (message: string): string[] => message.split('\n').filter((line) => line.startsWith(' • '));
514+
515+
it.each(GRID_KEYS)('`%s` is refused by name, its prescription naming `%s`', (snake, camel, value) => {
516+
const r = field({ [snake]: value });
517+
expect(issues(r)).toEqual([{ code: 'unrecognized_keys', path: 'customFields.0' }]);
518+
const message = firstMessage(r);
519+
// Named subjects, not copy: the first line names the written key, and the one bullet names it and its
520+
// camelCase replacement.
521+
expect(message.split('\n')[0]).toContain(`\`${snake}\``);
522+
const [bullet, ...rest] = bullets(message);
523+
expect(rest).toEqual([]);
524+
expect(bullet).toContain(`\`${snake}\``);
525+
expect(bullet).toContain(`\`${camel}\``);
526+
// The key the prescription names is one the field accepts, with the very value written.
527+
const renamed = field({ [camel]: value });
528+
expect(issues(renamed)).toEqual([]);
529+
expect(renamed.success && (renamed.data as { customFields: Record<string, unknown>[] }).customFields[0]![camel]).toBe(value);
530+
});
531+
532+
it('two retired spellings on one field get one bullet each, each naming its own camelCase key', () => {
533+
const lines = bullets(firstMessage(field({ allow_add: false, sort_field: 'position' })));
534+
expect(lines).toHaveLength(2);
535+
expect(lines.filter((l) => l.includes('`allow_add`') && l.includes('`allowAdd`'))).toHaveLength(1);
536+
expect(lines.filter((l) => l.includes('`sort_field`') && l.includes('`sortField`'))).toHaveLength(1);
537+
});
538+
539+
it('a section\'s inline entry refuses a retired spelling with the same prescription', () => {
540+
const r = parse('object-form', { sections: [{ fields: [{ name: 'items', type: 'grid', total_field: 'amount' }] }] });
541+
expect(issues(r)).toEqual([{ code: 'invalid_union', path: 'sections.0.fields.0' }]);
542+
expect(messages(r)).toMatch(/^ {2}• .*`total_field`.*`totalField`/m);
543+
});
544+
545+
it.each(GRID_KEYS)('`%s` → `%s` takes the widget\'s value type and refuses another', (_snake, camel, _value, wrong) => {
546+
expect(issues(field({ [camel]: wrong }))).toEqual([{ code: 'invalid_type', path: `customFields.0.${camel}` }]);
547+
});
548+
549+
it('each of the eight describes names the one reader', () => {
550+
for (const [, camel] of GRID_KEYS) {
551+
const member = runtimeField().shape[camel] as { description?: string };
552+
expect(member.description, camel).toMatch(/Read only by the `grid` widget$/);
553+
}
554+
});
555+
556+
// Two keys spelled alike across siblings with opposite meanings is how a writer goes wrong: the grid's
557+
// `totalField` names the CHILD column it sums, the child-collection blocks' names the PARENT field the
558+
// sum is saved to (their child column is `amountField`, which their renderers hand the grid AS its
559+
// `totalField` — `LineItemsPanel.tsx:710`, `MasterDetailForm.tsx:876` at the pin).
560+
it('`totalField` on the inline grid field is the CHILD column summed, the opposite of the same spelling on the child-collection blocks', () => {
561+
const description = (schema: unknown) => (schema as { description?: string }).description ?? '';
562+
const grid = description(runtimeField().shape.totalField);
563+
// The first sentence is the contract: the CHILD column, summed into the footer.
564+
expect(grid).toMatch(/^The CHILD column [^.]*\bfooter\b/);
565+
// And it names the homonym it is not, and the sibling key that IS this value.
566+
expect(grid).toMatch(/\bNot the PARENT field\b/);
567+
expect(grid).toContain('`amountField`');
568+
569+
const lineItems = description((RecordLineItemsProps as unknown as { shape: Record<string, unknown> }).shape.totalField);
570+
const detailEntry = description(objectOf(ObjectMasterDetailFormPropsSchema.shape.details).shape.totalField);
571+
for (const [label, sibling] of [['record:line_items', lineItems], ['master-detail detail entry', detailEntry]] as const) {
572+
expect(sibling, label).toMatch(/^Parent field to receive the rolled-up sum/);
573+
expect(sibling, label).not.toMatch(/CHILD|footer/);
574+
}
575+
expect(grid).not.toMatch(/^Parent field/);
576+
});
577+
});

0 commit comments

Comments
 (0)