You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2abec3a
Browse filesBrowse the repository at this point in the historyBrowse files
authored
feat(types,fields)!: camelCase the grid widget's eight field-level keys; the snake_case spellings are refused by name (objectui#11610) (#11614)
Fixes#11610
Clause-②: yes (narrowing)
The `grid` widget's eight field-level keys become camelCase in one move
across the published type, its zod mirror, the form-field face and every
reader. The snake_case spellings retire at once and are refused by name
on all three faces, each refusal naming the camelCase key. Ground:
objectstack-ai/objectstack#21704 fork 2, ruled B (record `5978663135`);
claim `5979906990` (seat ruling: refuse by name, no load-time
conversion, premised on no stored producer). Dispatch session
`session_01CPvhwGcirXqBGEdPSb72TZ`, `mode:subagent`.
## Premise (measured before any edit): no stored producer outside
objectui's own fixtures
- **objectstack `origin/main` `316be321`** (read through `git grep` on a
private ref, nothing edited): the eight snake_case words occur only in
comments, test strings and one changeset line. `packages/spec`
`component.zod.ts` names them in three comments (the line-items guidance
block, the `customFields` fork note, the master-detail `sortField`
retirement note); the retirement test for `details.sortField` carries
one as a must-not-match judge string; `conversions/registry.ts`,
`migrations/registry.ts` and the retired-key entry for
`details.sortField` carry it in comments. No schema declares or emits
any of the eight as a field's metadata, and the spec's `FieldType` enum
has no `grid` member at all, so the spec cannot store a `grid` field.
The seat's reading is confirmed.
- **objectui at base `b508ac50`** (every tracked file, `git grep` on
that commit), each hit classified:
- grid field metadata, READERS: `GridField.tsx` (the eight reads).
- grid field metadata, PRODUCERS (in-repo, render-time, not stored):
`MasterDetailForm` and `LineItemsPanel` (both hand the grid an object
cast through `any`), and `apps/console` `DevLookup` (a dev harness).
- grid field metadata, DECLARATIONS: `GridFieldMetadata`, the form-field
face (`FormField`), its zod mirror (`FormFieldSchema`).
- grid field metadata, STORED fixture: `examples/schema-catalog`
`fields-grid/line-items-grid.json` (the only stored document).
- docs: `content/docs/fields/grid.mdx`; the clipboard-paste RFC (states
`max_rows` / `allow_add` as GridFieldMetadata's API in its proposed
wiring); ADR 0001 (reported below, not edited).
- tests: seven `GridField` test files in `packages/fields` (the triage
counted five), the master-detail members test and the line-items total
test in `packages/plugin-form`, three `packages/types` tests (strict
face, form-field coverage, mirror parity comment), the console registry
pin's prose.
- SAME NAME, DIFFERENT READER (not renamed): the `## MAX_ROWS vs
MIN_ROWS` heading-anchor string in `plugin-markdown`'s toc test; the en
locale comment (a comment about the grid's label, updated); every
`CHANGELOG.md` (history, untouched). The master-detail
`details.sortField` the spec retired is a different key on a different
object and stays untouched.
- Existing facts not re-measured: the objectstack#21464 census (0
`customFields` entries) and the triage's hotcrm / cloud reading (0
hits).
## Rename map
| FROM | TO |
| --- | --- |
| `min_rows` | `minRows` |
| `max_rows` | `maxRows` |
| `allow_add` | `allowAdd` |
| `allow_delete` | `allowDelete` |
| `allow_reorder` | `allowReorder` |
| `total_field` | `totalField` |
| `add_label` | `addLabel` |
| `sort_field` | `sortField` |
One list holds it: `GRID_FIELD_RETIRED_KEYS` (new export of
`@object-ui/types`, with its key type `GridFieldRetiredKey`). The zod
arms and the widget both read it; a type-level pin holds its keys equal
to the `never` tombstones and its values equal to the live camelCase
members.
## The retirement form, per face (refused by name, never stripped)
- **TypeScript.** `GridFieldMetadata` and `FormField` each declare the
eight snake_case keys as `never` tombstones with a `@deprecated` note
naming the replacement. `{ type: 'grid', name: 'lines', min_rows: 1 }`
typed `GridFieldMetadata` does not compile; on the docs face the gate's
compile of `grid.mdx` reports `TS2322: Type 'number' is not assignable
to type 'undefined'` for the same key.
- **zod (`@object-ui/types/zod`).** `FormFieldSchema` declares each
snake_case key as an `aliasKeyRefusal` arm. On the mirror, the tolerant
face (`safeValidateSchema`, the `objectui validate` door) and the strict
authoring face, `fields[0].min_rows: 1` answers exactly one issue,
`invalid_type` at `fields.0.min_rows`, whose message leads
``Unrecognized key(s) on this form field: `min_rows`. Did you mean
`min_rows` → `minRows`?`` and goes on to say the value stays the same.
Any value is refused (the refusal is by name). The camelCase key with
the same value parses and is kept on all three doors.
- **The widget (`@object-ui/fields`).** `GridField` is now a thin gate
over the grid body: a field whose metadata carries any snake_case key
(with a defined value) is drawn as `RetiredGridFieldKeys` instead of the
grid, an inline `role="alert"` (`data-testid="grid-field-retired-keys"`)
reading for example ``[object-ui] Grid field `lines` carries retired
snake_case key(s): `min_rows` → `minRows`. …``, plus the same text on
`console.error` once per message. This is `RetiredFieldTombstone`'s
settled shape in this package: nothing thrown, nothing silently
substituted, rows untouched (`onChange` is never called).
Producers move with it: both plugin-form adapters write the camelCase
keys, and their grid object is now `satisfies` Partial of
`GridFieldMetadata` instead of `as any`, so a snake_case key written
there no longer compiles.
## Pins and guards, with ablation readings
All ablations ran through the anchored mutation tool (anchor must hit;
blob change and restore proven on disk; restore leg `git checkout HEAD`,
blob after restore equal to the HEAD blob, `git diff HEAD` empty), from
committed state.
1. **The eight camelCase keys draw**, each through the real `GridField`
with a lit control without the key
(`GridField.retiredSnakeKeys-11610.test.tsx`): `minRows` disables every
Remove at the minimum; `maxRows` disables Add and every Duplicate at the
maximum; `allowAdd: false` removes Add and Duplicate; `allowDelete:
false` removes Remove; `allowReorder: false` removes the drag handles;
`totalField` draws the footer total (30); `addLabel` labels Add;
`sortField` stamps the new indices after a drag. Through the real form
path too: `fields-grid-keys-camelcase-11610.test.tsx` renders the
catalog fixture through `SchemaRenderer` and the form renderer (label
`Add line item`, total `109.97`, two drag handles / Duplicate / Remove,
all enabled), with the snake_case respelling as its control.
2. **A snake_case key is answered by the refusal, never dropped**: per
key, the alert names the key and its replacement, the grid is not drawn,
`console.error` is called once with the alert's text; edges pinned:
several keys in one alert in map order, a snake key beside its camelCase
twin is still refused, an `undefined` value is not refused (as on the TS
and zod faces), a nameless field reads "This grid field", a second mount
logs nothing new.
- ABLATION (widget gate disabled): 12 refusal tests red, the 10 others
(the eight draws, the lit control, the `undefined` row) green. Restored.
3. **zod refusal by name** (`grid-field-keys-camelcase-11610.test.ts`,
three doors × eight keys, plus lit controls and an any-value row).
- ABLATION (delete the `min_rows` arm): 8 tests red (the `min_rows` row
and the any-value row on each of the three doors, and both
`form-field-zod-coverage` rows). Restored.
4. **TS tombstones, each with a deletion guard that does not lean on an
index signature** (same file, read by `tsc -p tsconfig.test.json`): an
`Equal` row per member on both faces, a `keyof` membership row on
`FormField`, and the map-equals-tombstones rows.
- ABLATION A (delete all eight `GridFieldMetadata` tombstones): the
eight `Equal` rows red, `TS2339` naming each key; the two map rows red;
the strict-face lit-control row red; the map's `satisfies` red
(`TS2561`). ⚠️ The eight `@ts-expect-error` rows over fresh
`GridFieldMetadata` literals stayed SILENT: with the member gone the key
is still an excess property and the directive swallows it. That is the
objectui#8347 lesson, reproduced; the `Equal` rows are what catch the
deletion.
- ABLATION A1 (delete `total_field` alone): exactly its `Equal` row
(`TS2339`), the map row and the `satisfies` red.
- ABLATION B (delete all eight `FormField` tombstones): the eight
`Equal` rows and the `keyof` row red, four `TS2578` unused
`@ts-expect-error` (the index signature now accepts the key), the
strict-face by-reference rows red, and the mirror-parity ratchet red on
`FormFieldSchema`.
- ABLATION B1 (delete `sort_field` alone): its `Equal` row, the `keyof`
row, its `TS2578`, the strict-face rows and the parity ratchet red,
naming `sort_field`.
- Every leg restored with blob equal to HEAD; the green `tsc -p
tsconfig.test.json` runs are in the gates below.
5. **Docs face**: respelling one key of `grid.mdx`'s snippet snake_case
turns `pnpm check:doc-snippets` red at that line (`TS2322`), restored
green.
Repo-wide pin sweep: every assertion that read a grid key by its
snake_case spelling now asserts the camelCase key's substance (the
master-detail grid object's exact key set and values, the strict face's
accept and wrong-type rows, the coverage key set, the widget draws). The
rejection rows for genuinely wrong values (a string `minRows`, a numeric
`addLabel`, …) stay, respelled; the guarded surface grows by the eight
refusals.
## Files and landing point
The claim's surface, plus two adjacent files the census named:
`apps/console` `DevLookup.tsx` (a producer) and its registry pin's
prose; `packages/i18n` en locale (one comment naming `add_label`); the
RFC. The landing point is the claim's list; no producer was found in
another package.
## ADR and RFC
- `docs/adr/0001-master-detail-subform.md` (governed, ⛔ not edited)
names the keys in three sentences: "Honors `min_rows` / `max_rows` /
`allow_add` / `allow_delete` from the …", "Drag reorder of lines
(`allow_reorder`), grouping/subtotal rows." and "… **drag-to-reorder**
(a `sort_field` …". Reported for whoever owns that record.
- `content/docs/rfcs/0001-clipboard-paste.md` (status Draft) stated
`max_rows` "from GridFieldMetadata" and wired `field.allow_add` /
`field.max_rows` as the current API, so it is edited to the camelCase
keys.
## Serial and overlap
The claim's serial step is done. objectui#11605's PR #11612 (`fd060f07`)
and objectui#8347's PR #11607 (`b403bb36`) both landed. `main` at
`b403bb36` is merged into this branch by merge commit `8b56ef26`. Its
combined diff is empty, so no hunk was hand-resolved.
The four files this branch shares with them carry `main`'s hunks outside
this branch's hunks: `packages/i18n/src/locales/en.ts`,
`packages/types/src/form.ts`, `packages/types/src/index.ts` and
`zod-mirror-parity.test.ts`. The contract review on the merged head
(`5981193754`) reads every hunk of this branch as byte-identical to the
reviewed head. The gate verdict on the merged head is its own
check-runs.
## Gates (the dev's local readings, tree `82b400f9`; the current head's
verdict is its check-runs)
- type-check, package and test program: `@object-ui/types`,
`@object-ui/fields`, `@object-ui/plugin-form`, `@object-ui/i18n`,
`@object-ui/example-schema-catalog`, `@object-ui/console` — all exit 0
(the console run after building `@object-ui/plugin-tree`, which its
import graph needs). The fields and plugin-form test programs list the
touched test files (`--listFilesOnly`).
- vitest, repo-root form: `packages/types/` 353 files / 9482 tests
passed; `packages/fields/` 233 passed + 1 skipped / 3647 passed + 7
skipped; `packages/plugin-form/` 163 / 1889 passed + 1 skipped;
`examples/schema-catalog/` + the console registry pin + `packages/i18n/`
121 / 3807 passed + 13 skipped. The `types` / `fields` / `plugin-form`
runs measured a working tree byte-identical to `82b400f9` (its two
later-committed files, the changeset and the catalog test, were already
on disk); the rest ran at `82b400f9`.
- lint (`eslint` with the root config, as each package's `lint` runs it)
over the 25 touched TS/TSX files, counted from `--format json`: 0
errors; the one finding on a touched line is the pre-existing `as any`
warning in `GridField.test.tsx`'s fixture. Narrowing statement: the
population is every file under the touched packages, read by the one
root `eslint.config.js`; it enables no type-aware linting and no custom
rule reads the filesystem, so this diff cannot move a verdict on an
untouched file. `pnpm lint` itself is CI's.
- `check:doc-snippets` (777 blocks judged, 0 failed),
`check:doc-examples`, `check:readme-exports`, `check:new-line-citations`
(0 new), `check:control-bytes`, `check:spec-symbols`,
`check:designer-field-key-parity`, `check:prompt-keys`,
`check:handler-key-reads`, `check:unreferenced-sources`,
`check:phantom-deps`, `check:self-import`, `check:test-path-roots`,
`check:doc-types`, `check:doc-example-ids`, `check:doc-fences`,
`check:doc-example-readers`, `check:i18n-drift`, `check:i18n-keys`,
`check:esm-specifiers`, `check:side-effects-array`: all exit 0.
- changeset checkers: presence, no-major, fixed, overwrite, claims,
pending-literals: all exit 0. `.changeset/11610-grid-keys-camelcase.md`:
`@object-ui/types` and `@object-ui/fields` `minor`, breaking banner,
FROM → TO for all eight, `Clause-②: yes (narrowing)`.
- Governed surface: `node scripts/check-governed-queue-guard.mjs --test`
over the diff answers NOT GOVERNED.
## Acceptance notes
- ⚠️ Same name, different meaning: the grid's `totalField` names the
CHILD column summed (the value a spec `amountField` carries), while the
spec's own `totalField` on a master-detail subform or
`record:line_items` names the PARENT field the sum is saved to. The
mechanical camelCase of `total_field` lands on that homonym. Every
declaration, the doc page and the changeset say so; the open question in
the report asks the seat whether the spec's runtime form field should
carry it under this name.
- When the spec seat adds the camelCase keys to the runtime form field
(objectstack-ai/objectstack#21704), the three objectstack comments that
still name the snake_case keys can move with it. Carrier: that stage.
- `DevLookup.tsx` keeps its `as any` field (a dev harness; only the key
moved).
The dispatching session is
`https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Leehom <pm@objectstack.ai>
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).
7
+
8
+
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.)
9
+
10
+
- FROM `min_rows` → TO `minRows`
11
+
- FROM `max_rows` → TO `maxRows`
12
+
- FROM `allow_add` → TO `allowAdd`
13
+
- FROM `allow_delete` → TO `allowDelete`
14
+
- FROM `allow_reorder` → TO `allowReorder`
15
+
- FROM `total_field` → TO `totalField`
16
+
- FROM `add_label` → TO `addLabel`
17
+
- FROM `sort_field` → TO `sortField`
18
+
19
+
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.
20
+
21
+
**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.
22
+
23
+
What each face does with a snake_case key now:
24
+
25
+
-**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`.
26
+
-**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.
27
+
-**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.
28
+
29
+
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`.
30
+
31
+
`@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.
32
+
33
+
**Clause-②: yes (narrowing)**: the camelCase spellings widen each face, and the snake_case spellings narrow it.
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).',
3037
+
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).',
`add_label` and `sort_field`. Each is now refused by name, with the camelCase key
104
+
to write instead, and its value carries over unchanged:
105
+
106
+
-`GridFieldMetadata` and `FormField` declare each as a `never` member, so
107
+
TypeScript refuses it at the authoring site.
108
+
-`objectui validate` refuses it on a `form` field entry with
109
+
``Did you mean `min_rows` → `minRows`?`` (and so on for each key).
110
+
- The `grid` widget draws an alert naming the retired keys and their replacements
111
+
instead of the grid, and logs the same text with `console.error`. It does not
112
+
quietly draw a grid that ignores them.
113
+
98
114
## Column Types
99
115
100
116
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:
329
345
| Feature |`grid` field (`@object-ui/fields`) |`object-grid` (`@object-ui/plugin-grid`) |
330
346
| --- | --- | --- |
331
347
| 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 |
332
-
| 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 |
348
+
| 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 |
333
349
| Sorting and filtering | None | Column-header sorting (a column opts out with `sortable: false`), the query's `filter` and `sort`, and search over `searchableFields`|
334
-
| 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 |
350
+
| 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 |
335
351
| Export | None |`exportOptions`|
336
352
| 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 |
337
-
| Totals | One footer total, the sum of the `total_field` column | A footer summary per column (`columns[].summary`), and aggregates on group headers (`aggregations`) |
353
+
| Totals | One footer total, the sum of the `totalField` column | A footer summary per column (`columns[].summary`), and aggregates on group headers (`aggregations`) |
0 commit comments