diff --git a/.changeset/inline-related-columns-strict.md b/.changeset/inline-related-columns-strict.md new file mode 100644 index 0000000000..3e9fd8fa7e --- /dev/null +++ b/.changeset/inline-related-columns-strict.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): strict element schemas for `Field.inlineColumns` and `Field.relatedListColumns` (#9227) + +**BREAKING** accept-set narrowing on a published authoring surface, landing +after the v17.0.0 cut (the lockstep launch-window convention ships it as +`minor`, the #9221/#9250 precedent). + +Both keys were `z.array(z.any())`: every column object validated — right keys, +wrong keys, misspelled keys, empty objects — so a mis-keyed column published +clean and surfaced only in the browser, as a grid with the right row count and +every cell blank (the objectui#3951 failure, reachable from the authoring side). + +- `inlineColumns` entries are now `InlineGridColumnSchema` (exported): a + strict, `name`-keyed column mirroring the objectui inline-grid renderer's + measured reads — `name` (required), `label?`, `type?`, `width?`, `required?`, + `options?`, `prefix?`, `step?`, `reference?`, `displayField?`, `idField?`, + `multiple?`, `accept?`, `defaultHidden?`, `computed?`, `expr?`, `scale?`, + `autofill?`, `readonlyWhen?`, `requiredWhen?`. Unknown keys are a named + rejection at publish time; the retired `field` spelling is refused with the + prescription naming `name` (objectui#3951 aligned the widget to `name` with + deliberately no tolerant alias). `expr` is the grid evaluator's BARE + arithmetic string — a CEL envelope there is refused. Identity-only entries + (`{ name: 'quantity' }`) remain the recommended form: objectui's + `hydrateColumns` fills everything else from the child object's fields. +- `relatedListColumns` entries are now child FIELD-NAME STRINGS (e.g. + `['name', 'status']`) — the only authored form in-repo and the only form the + related-list renderer hydrates fully (labels, cell types and formatting + derive from the child object's field definitions); the page-block sibling + `record:related_list.columns` is the same strings-only shape. A column + object is refused with a prescription pointing at the child fields. + +Migration: respell `{ field: 'x' }` inline-grid columns as `{ name: 'x' }`; +replace related-list column objects with the child field name string — or run +`os migrate meta`, which rewrites both mechanically (registered conversion +`field-column-lists-canonicalized`, protocol 18). The one in-repo usage +(`examples/app-showcase` invoice line items) is migrated in this change. + + + diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index e28084fe3f..8bb247abe5 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -14,8 +14,8 @@ Field Type Enum ## TypeScript Usage ```typescript -import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; -import type { CurrencyConfig, CurrencyValue, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; +import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, InlineGridColumnSchema, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; +import type { CurrencyConfig, CurrencyValue, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, InlineGridColumn, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; // Validate data const result = CurrencyConfigSchema.parse(data); @@ -79,11 +79,11 @@ const result = CurrencyConfigSchema.parse(data); | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | -| **inlineColumns** | `any[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted) | +| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn, #3951); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | -| **relatedListColumns** | `any[]` | optional | Explicit columns for the detail-page related list (derived from the child object when omitted) | +| **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | | **displayField** | `string` | optional | Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title). | | **descriptionField** | `string` | optional | Secondary field shown under the label in the quick-select popover. | @@ -274,6 +274,36 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` * `vector` +--- + +## InlineGridColumn + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name, #3951). The retired `field` spelling is refused. | +| **label** | `string` | optional | Column header; defaults to the child field's label via hydration. | +| **type** | `Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>` | optional | Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself. | +| **width** | `number` | optional | Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed). | +| **required** | `boolean` | optional | Cell is flagged inline-invalid while empty. Computed columns are never required. | +| **options** | `{ label: string; value: string }[]` | optional | Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`. | +| **prefix** | `string` | optional | Currency symbol rendered inside a `currency` cell (default '¥'). | +| **step** | `number` | optional | Input step for numeric cells. | +| **reference** | `string` | optional | Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`. | +| **displayField** | `string` | optional | Label field shown for a picked lookup record. | +| **idField** | `string` | optional | Id field stored for a picked lookup record. | +| **multiple** | `boolean` | optional | Multi-value column: multi-record lookup, or multi-file upload cell. | +| **accept** | `string[]` | optional | Accepted MIME types / extensions for a `file` cell's picker (e.g. ['image/*', '.pdf']); omit to accept anything. | +| **defaultHidden** | `boolean` | optional | Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden. | +| **computed** | `boolean` | optional | Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row. | +| **expr** | `string` | optional | Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here. | +| **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to. | +| **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. | +| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. | + + --- ## LocationCoordinates diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 57e3c30b79..b586a40cdc 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1586 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [API Protocol](/docs/references/api) | 28 | 413 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | -| [Data Protocol](/docs/references/data) | 29 | 165 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 31 | 176 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 161 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1585** | 14 protocol modules | +| **Total** | **198** | **1586** | 14 protocol modules | --- @@ -146,7 +146,7 @@ Environments, packages and versions, marketplace, developer portal, tenancy. ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 165 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 166 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -170,7 +170,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`driver/turso.zod.ts`](/docs/references/data/driver-turso) | `TursoConfig`, `TursoTransportMode` | | [`external-catalog.zod.ts`](/docs/references/data/external-catalog) | `ExternalCatalog`, `ExternalColumn`, `ExternalTable` | | [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` | -| [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | +| [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | | [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | | [`filter.zod.ts`](/docs/references/data/filter) | `EqualityOperator`, `FieldReference`, `FilterArray`, `FilterCondition`, `QueryFilter`, `SetOperator`, `SpecialOperator`, `StringOperator` | | [`hook.zod.ts`](/docs/references/data/hook) | `HookContext`, `HookEvent` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 4bb479722f..ea3246ce13 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 441 | +| Object sites in them | 443 | | Still-open (strip) sites | 123 | | Files carrying at least one | 22 | @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| | `ui/` | 175 | 164 | 5 | 0 | 6 | -| `data/` | 154 | 72 | 1 | 0 | 81 | +| `data/` | 156 | 74 | 1 | 0 | 81 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **441** | **312** | **6** | **0** | **123** | +| **total** | **443** | **314** | **6** | **0** | **123** | ## File-level triage — site counts @@ -98,7 +98,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 3 | -| `field.zod.ts` | 11 | +| `field.zod.ts` | 13 | | `filter.zod.ts` | 11 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 7 | @@ -108,7 +108,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **154** | +| **total** | **156** | ### `automation/` — sites @@ -177,7 +177,7 @@ over it is here. ### `data/` — open -**81 strip of 154**, in 12 file(s). +**81 strip of 156**, in 12 file(s). | File | Strip | Sites | |---|---|---| @@ -188,12 +188,12 @@ over it is here. | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | | `field-value.zod.ts` | 2 | 3 | -| `field.zod.ts` | 2 | 11 | +| `field.zod.ts` | 2 | 13 | | `filter.zod.ts` | 10 | 11 | | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **81** | **154** | +| **total** | **81** | **156** | | Bucket | Sites | |---|---| diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index 480be2eac8..79a8c24214 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -204,20 +204,23 @@ export const InvoiceLine = ObjectSchema.create({ * `defaultHidden`), so all seven stay default-visible and `receipt`'s * visibility stops depending on a tie-break it happens to be losing. * - * Bare `{ field }` entries on purpose: `hydrateColumns` fills label, type, - * options, lookup target, `readonlyWhen`/`requiredWhen` and the computed - * `expression` from the schema, so labels stay translatable and the - * columns cannot drift from the field definitions above. `position` is - * absent because it is the grid's drag-reorder sort field, never a cell. + * Identity-only `{ name }` entries on purpose: `hydrateColumns` fills + * label, type, options, lookup target, `readonlyWhen`/`requiredWhen` and + * the computed `expression` from the schema, so labels stay translatable + * and the columns cannot drift from the field definitions above. + * (`name` is the grid's column identity since objectui#3951 — the + * retired `field` spelling this block originally used is now refused at + * parse, #9227.) `position` is absent because it is the grid's + * drag-reorder sort field, never a cell. */ inlineColumns: [ - { field: 'product' }, - { field: 'description' }, - { field: 'service_start' }, - { field: 'quantity' }, - { field: 'unit_price' }, - { field: 'receipt' }, - { field: 'amount' }, + { name: 'product' }, + { name: 'description' }, + { name: 'service_start' }, + { name: 'quantity' }, + { name: 'unit_price' }, + { name: 'receipt' }, + { name: 'amount' }, ], }), // Catalog lookup. Picking a product auto-fills `description` + `unit_price` diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 8e7ca74648..c123d5c45d 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -338,6 +338,9 @@ "IndexSchema (const)", "InjectedColumnProvenance (type)", "InjectedSystemColumnPlan (interface)", + "InlineGridColumn (type)", + "InlineGridColumnParsed (type)", + "InlineGridColumnSchema (const)", "InstantValue (type)", "InstantValueSchema (const)", "JSONValidation (type)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 9ef84147f4..2273628ed6 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -463,6 +463,26 @@ "data/Index:partial [RETIRED]", "data/Index:type [RETIRED]", "data/Index:unique", + "data/InlineGridColumn:accept", + "data/InlineGridColumn:autofill", + "data/InlineGridColumn:computed", + "data/InlineGridColumn:defaultHidden", + "data/InlineGridColumn:displayField", + "data/InlineGridColumn:expr", + "data/InlineGridColumn:idField", + "data/InlineGridColumn:label", + "data/InlineGridColumn:multiple", + "data/InlineGridColumn:name", + "data/InlineGridColumn:options", + "data/InlineGridColumn:prefix", + "data/InlineGridColumn:readonlyWhen", + "data/InlineGridColumn:reference", + "data/InlineGridColumn:required", + "data/InlineGridColumn:requiredWhen", + "data/InlineGridColumn:scale", + "data/InlineGridColumn:step", + "data/InlineGridColumn:type", + "data/InlineGridColumn:width", "data/JSONValidation:_lock", "data/JSONValidation:_lockDocsUrl", "data/JSONValidation:_lockReason", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index c83c2a02c4..1b528d8874 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -338,6 +338,9 @@ "IndexSchema": "src/data/object.zod.ts#IndexSchema (const)", "InjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#InjectedColumnProvenance (type)", "InjectedSystemColumnPlan": "src/data/injected-system-columns.ts#InjectedSystemColumnPlan (interface)", + "InlineGridColumn": "src/data/field.zod.ts#InlineGridColumn (type)", + "InlineGridColumnParsed": "src/data/field.zod.ts#InlineGridColumnParsed (type)", + "InlineGridColumnSchema": "src/data/field.zod.ts#InlineGridColumnSchema (const)", "InstantValue": "src/data/field-value.zod.ts#InstantValue (type)", "InstantValueSchema": "src/data/field-value.zod.ts#InstantValueSchema (const)", "JSONValidation": "src/data/validation.zod.ts#JSONValidation (type)", diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 5b878d8a3c..3b7bd08323 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -96,6 +96,7 @@ "data/HookEvent", "data/ImportFieldMapping", "data/Index", + "data/InlineGridColumn", "data/InstantValue", "data/JSONValidation", "data/Lifecycle", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 162597a2c0..2fc020ea4b 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -265,8 +265,92 @@ }, "inlineColumns": { "status": "live", - "evidence": "objectui: packages/plugin-form/src/deriveMasterDetail.ts + app-shell/MetadataProvider.tsx", - "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui." + "verifiedAt": "2026-08-17", + "evidenceScope": "cross-repo", + "evidence": "objectui: packages/app-shell/src/providers/MetadataProvider.tsx (attachInlineSubforms passes d.inlineColumns as the subform's columns) → plugin-form/MasterDetailForm.tsx (hydrateColumns) → fields/GridField.tsx (the name-keyed GridColumn renderer, objectui#3951)", + "note": "LIVE via objectui renderer. #9227 closed the element shape (strict, name-keyed InlineGridColumnSchema): the child keys below are exactly the GridColumn keys the widget has a live read for, measured against objectui main 2026-08-17. NOTE the .objectui-sha pin (665661ab) predates objectui#3951 — the PINNED console still reads the retired field spelling; the drilled evidence below cites objectui main, which is the contract this schema mirrors.", + "children": { + "name": { + "status": "live", + "note": "objectui GridField.tsx — the column's row key: every cell read/write is row[c.name]; hydrateColumns resolves the child field def by col.name (deriveMasterDetail.ts). #3951 aligned this from the retired `field` spelling." + }, + "label": { + "status": "live", + "note": "objectui GridField.tsx header cells render c.label; hydrateColumns fills it from the child field's label when absent." + }, + "type": { + "status": "live", + "note": "objectui GridField.tsx — selects the cell control (text/number/currency/date/datetime/time/select/lookup/file) and drives type-based min widths (minWidthFor/widthStyle); hydrateColumns derives it from the child field type when absent." + }, + "width": { + "status": "live", + "note": "objectui GridField.tsx minWidthFor/widthStyle — a declared width pins the column; otherwise type-based role sizing." + }, + "required": { + "status": "live", + "note": "objectui GridField.tsx — header asterisk and inline-invalid flag on empty cells; curation keeps required columns always visible (deriveMasterDetail curateColumns)." + }, + "options": { + "status": "live", + "note": "objectui GridField.tsx select cells — SelectItem per {label,value}; display formatting resolves the stored value to its label." + }, + "prefix": { + "status": "live", + "note": "objectui GridField.tsx currency cells — rendered symbol (c.prefix || '¥') in both display and edit." + }, + "step": { + "status": "live", + "note": "objectui GridField.tsx numeric cells — input step (c.step ?? 'any')." + }, + "reference": { + "status": "live", + "note": "objectui GridField.tsx lookup cells — passed to LookupField as the referenced object." + }, + "displayField": { + "status": "live", + "note": "objectui GridField.tsx lookup cells — LookupField display_field." + }, + "idField": { + "status": "live", + "note": "objectui GridField.tsx lookup cells — LookupField id_field." + }, + "multiple": { + "status": "live", + "note": "objectui GridField.tsx — multi-record lookup cells and multi-file upload cells." + }, + "accept": { + "status": "live", + "note": "objectui GridField.tsx file cells — joined into the file input's accept attribute." + }, + "defaultHidden": { + "status": "live", + "note": "objectui GridField.tsx — column chooser visibility; deriveColumns sets it past the curation budget, and an authored value is honoured the same way." + }, + "computed": { + "status": "live", + "note": "objectui GridField.tsx — read-only computed cells (recomputeRow filters c.computed && c.expr); computed columns are exempt from required." + }, + "expr": { + "status": "live", + "note": "objectui GridField.tsx evalArith — the BARE arithmetic string the grid's own safe evaluator tokenizes (never a CEL envelope; the schema refuses the envelope form, #9227)." + }, + "scale": { + "status": "live", + "note": "objectui GridField.tsx — rounding for computed results (c.scale ?? currency default 2)." + }, + "autofill": { + "status": "live", + "note": "objectui GridField.tsx lookup pick — copies same-named fields from the picked record into sibling columns unless col.autofill === false." + }, + "readonlyWhen": { + "status": "live", + "note": "objectui GridField.tsx — per-row CEL predicate via resolveFieldRuleState (record + parent scope); cell renders read-only when TRUE." + }, + "requiredWhen": { + "status": "live", + "note": "objectui GridField.tsx — per-row CEL predicate via resolveFieldRuleState; cell flagged required when TRUE." + } + } }, "inlineAmountField": { "status": "live", @@ -289,8 +373,10 @@ }, "relatedListColumns": { "status": "live", - "evidence": "objectui: packages/app-shell/src/utils/deriveRelatedLists.ts + views/RecordDetailView.tsx", - "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui." + "verifiedAt": "2026-08-17", + "evidenceScope": "cross-repo", + "evidence": "objectui: packages/app-shell/src/utils/deriveRelatedLists.ts (columns override on the derived descriptor) → views/RecordDetailView.tsx → plugin-detail/RelatedList.tsx (normalizeColumn hydrates STRING entries: header from the child field label, type-aware cell via makeCell)", + "note": "LIVE via objectui renderer. #9227 narrowed the elements to child FIELD-NAME STRINGS — the only authored form in-repo (three showcase objects) and the only form the renderer hydrates fully; the page-block sibling record:related_list.columns (ui/component.zod.ts) is the same strings-only shape. Measured 2026-08-17 against objectui main: object entries span two vocabularies (RelatedList resolves identity via columnIdentity, canonical `field`; the data-table accessor resolves `accessorKey || name` only), so a spec-canonical {field} object renders BLANK cells — refused at parse instead." }, "relatedListFilter": { "status": "planned", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index da7c5d9918..0d42b4bd0f 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 50 | 0 | 0 | 1 | 51 | -| `field` | 69 | 0 | 0 | 2 | 71 | +| `field` | 88 | 0 | 0 | 2 | 90 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **777** | **6** | **55** | **10** | **848** | +| **total** | **796** | **6** | **55** | **10** | **867** | diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index be44181b79..1f38c2fe1b 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7025,6 +7025,155 @@ const elementFilterRemoved: MetadataConversion = { }, }; +/** + * `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half + * of the #9227 strict-element narrowing (protocol 18). + * + * Both keys were `z.array(z.any())`: every column object validated, so a + * mis-keyed column published clean and surfaced only in the browser as blank + * cells (the objectui#3951 failure, reachable from the authoring side). The + * schemas are now closed: + * + * - `inlineColumns` entries are the strict, `name`-keyed + * `InlineGridColumnSchema` — `name` is the grid's column identity since + * objectui#3951 retired the `field` spelling from the widget, with + * deliberately no tolerant alias in the renderer. The conversion respells + * `{ field: 'x' }` (the pre-#3951 authored form — the shape the showcase + * invoice carried) as `{ name: 'x' }`, preserving every other key. An + * entry already carrying `name` is left alone — rewriting a live key on + * the strength of a stale one would guess; the parse refuses the mixed + * shape loudly instead. + * - `relatedListColumns` entries are child FIELD-NAME STRINGS. The + * conversion folds an object entry to its identity string + * (`field` → `name` → `fieldName`, objectui's `columnIdentity` order) and + * drops the decoration keys: measured on objectui main, an object entry's + * display keys span two vocabularies (`columnIdentity` is + * canonical-`field`, the data-table accessor reads `accessorKey || name`), + * so no object spelling renders reliably — the derived-from-schema string + * is the one form that always has. An object with no resolvable identity + * is left for the parse to refuse; a conversion must not invent data. + */ +const fieldColumnListsCanonicalized: MetadataConversion = { + id: 'field-column-lists-canonicalized', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'field.inlineColumns[].field / field.relatedListColumns[] object entries', + summary: + "inline-grid column entries respelled 'field' → 'name' (objectui#3951's name-keyed GridColumn) " + + 'and related-list column objects folded to their child field-name string (#9227 — both lists ' + + 'were z.any(); a mis-keyed column published clean and rendered blank cells)', + apply(stack, emit) { + const convertFieldDef = (def: Dict, path: string): Dict => { + let next: Dict = def; + const inline = def.inlineColumns; + if (Array.isArray(inline)) { + let changed = false; + const cols = inline.map((entry, i) => { + if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry; + const renamed = renameKey(entry, 'field', 'name'); + if (!renamed) return entry; + emit({ from: 'field', to: 'name', path: `${path}.inlineColumns[${i}].name` }); + changed = true; + return renamed; + }); + if (changed) next = { ...next, inlineColumns: cols }; + } + const related = def.relatedListColumns; + if (Array.isArray(related)) { + let changed = false; + const cols = related.map((entry, i) => { + if (!isDict(entry)) return entry; + const identity = [entry.field, entry.name, entry.fieldName].find( + (v): v is string => typeof v === 'string' && v.length > 0, + ); + if (!identity) return entry; // nothing to fold to — the parse refuses it loudly + emit({ from: 'object entry', to: identity, path: `${path}.relatedListColumns[${i}]` }); + changed = true; + return identity; + }); + if (changed) next = { ...next, relatedListColumns: cols }; + } + return next; + }; + const convertCollection = (input: Dict, collection: string): Dict => + mapCollection(input, collection, (owner, path) => { + const fields = owner.fields; + if (!isDict(fields)) return owner; + let changed = false; + const nextFields: Dict = {}; + for (const [name, def] of Object.entries(fields)) { + if (!isDict(def)) { + nextFields[name] = def; + continue; + } + const converted = convertFieldDef(def, `${path}.fields.${name}`); + nextFields[name] = converted; + if (converted !== def) changed = true; + } + return changed ? { ...owner, fields: nextFields } : owner; + }); + return convertCollection(convertCollection(stack, 'objects'), 'objectExtensions'); + }, + fixture: { + before: { + objects: [{ + name: 'showcase_invoice_line', + label: 'Invoice Line', + fields: { + invoice: { + type: 'master_detail', + reference: 'showcase_invoice', + inlineEdit: 'grid', + inlineColumns: [ + // The pre-#3951 authored form (the showcase invoice's real shape). + { field: 'product' }, + { field: 'quantity', label: 'Qty' }, + // Already name-keyed — untouched. + { name: 'unit_price' }, + ], + }, + project: { + type: 'lookup', + reference: 'showcase_project', + relatedListColumns: [ + // Object entries fold to their identity string, whichever legacy + // spelling carries it; string entries are untouched. + { field: 'status', label: 'Status' }, + { name: 'amount' }, + 'issued_on', + ], + }, + }, + }], + }, + after: { + objects: [{ + name: 'showcase_invoice_line', + label: 'Invoice Line', + fields: { + invoice: { + type: 'master_detail', + reference: 'showcase_invoice', + inlineEdit: 'grid', + inlineColumns: [ + { name: 'product' }, + { name: 'quantity', label: 'Qty' }, + { name: 'unit_price' }, + ], + }, + project: { + type: 'lookup', + reference: 'showcase_project', + relatedListColumns: ['status', 'amount', 'issued_on'], + }, + }, + }], + }, + // 2 inline respells + 2 related-list folds. + expectedNotices: 4, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -7102,6 +7251,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly strictObject({ + surface: 'this inline grid column', + history: INLINE_GRID_COLUMN_HISTORY, + aliases: { + // The retired grid spelling: objectui#3951 aligned the widget to `name` + // (the FORM-layer identity key), with deliberately no tolerant alias in + // the renderer — the refusal here is the producer-side half of that. + field: 'name', fieldName: 'name', key: 'name', + title: 'label', header: 'label', + size: 'width', + // The field-level formula key; a grid column's computed cell reads the + // bare arithmetic `expr` (paired with `computed`), never a CEL envelope. + expression: 'expr', + hidden: 'defaultHidden', + }, +}, { + name: z.string().min(1).describe('Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name, #3951). The retired `field` spelling is refused.'), + label: z.string().optional().describe("Column header; defaults to the child field's label via hydration."), + type: z.enum(['text', 'number', 'currency', 'date', 'datetime', 'time', 'select', 'lookup', 'file']).optional().describe("Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself."), + width: z.number().positive().optional().describe('Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed).'), + required: z.boolean().optional().describe('Cell is flagged inline-invalid while empty. Computed columns are never required.'), + options: z.array(strictObject({ + surface: 'this inline grid column option', + history: INLINE_GRID_COLUMN_HISTORY, + aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value' }, + }, { + label: z.string().describe('Option label shown in the select cell.'), + value: z.string().min(1).describe("Stored option value; must match the child select field's option values."), + })).optional().describe("Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`."), + prefix: z.string().optional().describe("Currency symbol rendered inside a `currency` cell (default '¥')."), + step: z.number().positive().optional().describe('Input step for numeric cells.'), + reference: z.string().optional().describe("Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`."), + displayField: z.string().optional().describe('Label field shown for a picked lookup record.'), + idField: z.string().optional().describe('Id field stored for a picked lookup record.'), + multiple: z.boolean().optional().describe('Multi-value column: multi-record lookup, or multi-file upload cell.'), + accept: z.array(z.string()).optional().describe("Accepted MIME types / extensions for a `file` cell's picker (e.g. ['image/*', '.pdf']); omit to accept anything."), + defaultHidden: z.boolean().optional().describe("Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden."), + computed: z.boolean().optional().describe('Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row.'), + expr: z.string().min(1).optional().describe("Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here."), + scale: z.number().int().nonnegative().optional().describe('Decimal places to round a computed numeric/currency result to.'), + autofill: z.boolean().optional().describe("For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable."), + readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`)."), + requiredWhen: ExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`.'), +})); + export const FieldSchema = lazySchema(() => strictObject({ surface: 'this field', history: FIELD_HISTORY, @@ -831,8 +908,14 @@ export const FieldSchema = lazySchema(() => strictObject({ inlineEdit: z.union([z.boolean(), z.enum(['grid', 'form'])]).optional().describe('Edit these child records inline within the parent\'s form (atomic master-detail). true = auto-pick grid/form by child shape; \'grid\' = editable line-item grid; \'form\' = list + per-row full form.'), /** Optional section title for the inline grid (defaults to the child object label). */ inlineTitle: z.string().optional().describe('Title for the inline master-detail grid'), - /** Optional explicit grid columns for the inline editor (derived from the child object when omitted). */ - inlineColumns: z.array(z.any()).optional().describe('Explicit columns for the inline grid (derived from the child object when omitted)'), + /** + * Optional explicit grid columns for the inline editor (derived from the + * child object when omitted). Strict `name`-keyed element schema + * ({@link InlineGridColumnSchema}, #9227) mirroring the objectui grid + * renderer's measured reads — an unknown or retired key (`field`) is a + * named rejection at publish time, never a blank cell at render time. + */ + inlineColumns: z.array(InlineGridColumnSchema).optional().describe("Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn, #3951); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse."), /** Optional numeric child field summed for the inline grid running total. */ inlineAmountField: z.string().optional().describe('Numeric child field summed for the inline grid total'), @@ -866,8 +949,25 @@ export const FieldSchema = lazySchema(() => strictObject({ relatedList: z.union([z.boolean(), z.literal('primary')]).optional().describe('Show this child collection as a related list on the parent\'s detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); \'primary\' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085).'), /** Optional section title for the detail-page related list (defaults to the child object label). */ relatedListTitle: z.string().optional().describe('Title for the detail-page related list'), - /** Optional explicit columns for the detail-page related list (derived from the child object when omitted). */ - relatedListColumns: z.array(z.any()).optional().describe('Explicit columns for the detail-page related list (derived from the child object when omitted)'), + /** + * Optional explicit columns for the detail-page related list (derived from + * the child object when omitted). Child FIELD-NAME STRINGS only (#9227) — + * the read-side list derives labels, cell types and formatting from the + * child object's field definitions, so the columns cannot drift from them. + * Deliberately narrower than `inlineColumns`: the related list is not an + * editable grid, and per-column display overrides are not part of its + * measured renderer contract (objectui RelatedList hydrates string entries + * fully; the page-block sibling `record:related_list.columns` is the same + * strings-only shape). Column OBJECTS are refused with a prescription. + */ + relatedListColumns: z.array(z.string({ + error: (issue) => issue.code === 'invalid_type' + ? "Related-list columns are child FIELD-NAME strings (e.g. ['name', 'status', 'total']). " + + 'Column objects are not authorable here: the related list derives labels, cell types and ' + + "formatting from the child object's field definitions, so declare display changes on the " + + 'child fields themselves.' + : undefined, + }).min(1)).optional().describe("Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse."), /** * Declarative default FILTER for the detail-page related list (#8704). The * auto-derived related list for this relationship queries the child object @@ -1407,6 +1507,10 @@ export type FieldParsed = z.infer; export type SelectOption = z.input; /** Post-parse shape of {@link SelectOption} — defaults applied, transforms run (ADR-0122). */ export type SelectOptionParsed = z.infer; +/** One authored `inlineColumns` entry (#9227) — the strict, name-keyed inline grid column. */ +export type InlineGridColumn = z.input; +/** Post-parse shape of {@link InlineGridColumn} — bare-string CEL predicates normalized to Expression envelopes (ADR-0122). */ +export type InlineGridColumnParsed = z.infer; export type LocationCoordinates = z.input; export type Address = z.input; export type CurrencyConfig = z.input; diff --git a/packages/spec/src/data/inline-related-columns.test.ts b/packages/spec/src/data/inline-related-columns.test.ts new file mode 100644 index 0000000000..6557445a86 --- /dev/null +++ b/packages/spec/src/data/inline-related-columns.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9227 — `inlineColumns` / `relatedListColumns` strictness. + * + * Both keys were `z.array(z.any())`: every column object validated — right + * keys, wrong keys, misspelled keys, empty objects — so a mis-keyed column + * published clean and the only place it was ever noticed was the browser, as + * a grid with the right row COUNT and every cell blank (objectui#3951 + * measured exactly this failure in the renderer; `z.any()` kept it reachable + * from the authoring side after the renderer was fixed). + * + * What is pinned here: + * + * 1. THE DOOR — both column lists are reached THROUGH `FieldSchema` on a + * real relationship field, not as standalone schemas: strictness does + * not recurse, so a closed parent proves nothing about a nested element. + * 2. `inlineColumns`: the strict `name`-keyed element mirrors the objectui + * grid renderer's measured reads (GridField.tsx `GridColumn` + + * deriveMasterDetail.ts hydration). The retired `field` spelling is + * refused WITH the prescription naming `name` (the maintainer-ruled + * #4001 refusal shape); unknown keys get the named-surface refusal. + * 3. `expr` stays a BARE arithmetic string — the grid's own evaluator + * (`evalArith`) tokenizes a string; a CEL envelope authored there would + * parse clean and render every computed cell '—'. The refusal is the + * producer-side guard for that renderer fact. + * 4. `relatedListColumns`: child field-name STRINGS only, matching every + * in-repo usage and the strings-only page-block sibling + * (`record:related_list.columns`, ui/component.zod.ts). A column OBJECT + * is refused with the derivation prescription. + * 5. The showcase invoice fixture form — identity-only `{ name }` entries — + * parses, so the one authored in-repo usage stays green in the spelling + * the renderer actually reads. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema, InlineGridColumnSchema } from './field.zod'; + +/** Reject `value` through `schema` and return its issues as a searchable string. */ +function reject( + schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, + value: unknown, +): string { + const r = schema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return JSON.stringify((r.error as { issues?: unknown })?.issues ?? r.error ?? []); +} + +/** Parse `value` and fail loudly (with the issues) if it does not succeed. */ +function accept( + schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown; data?: unknown } }, + value: unknown, +): unknown { + const r = schema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify((r.error as { issues?: unknown })?.issues ?? '')}`).toBe(true); + return r.data; +} + +/** A minimal master_detail relationship field — the real carrier of both keys. */ +const MD_FIELD = { + name: 'invoice', + label: 'Invoice', + type: 'master_detail', + reference: 'showcase_invoice', +} as const; + +const acceptField = (patch: Record): unknown => + accept(FieldSchema, { ...MD_FIELD, ...patch }); +const rejectField = (patch: Record): string => + reject(FieldSchema, { ...MD_FIELD, ...patch }); + +// =========================================================================== +// 1. inlineColumns — the strict name-keyed grid column +// =========================================================================== +describe('#9227 inlineColumns — strict name-keyed element', () => { + it('accepts identity-only entries (the showcase invoice fixture form)', () => { + const parsed = acceptField({ + inlineEdit: 'grid', + inlineTitle: 'Line Items', + inlineColumns: [ + { name: 'product' }, + { name: 'description' }, + { name: 'service_start' }, + { name: 'quantity' }, + { name: 'unit_price' }, + { name: 'receipt' }, + { name: 'amount' }, + ], + }) as { inlineColumns: Array<{ name: string }> }; + expect(parsed.inlineColumns).toHaveLength(7); + expect(parsed.inlineColumns[0]).toEqual({ name: 'product' }); + }); + + it('accepts every measured renderer-read key on one column, round-tripping the values', () => { + const parsed = acceptField({ + inlineEdit: 'grid', + inlineColumns: [{ + name: 'unit_price', + label: 'Unit Price', + type: 'currency', + width: 140, + required: true, + prefix: '$', + step: 0.01, + defaultHidden: false, + scale: 2, + }, { + name: 'product', + type: 'lookup', + reference: 'showcase_product', + displayField: 'name', + idField: 'id', + multiple: false, + autofill: true, + readonlyWhen: "parent.status == 'paid'", + }, { + name: 'status', + type: 'select', + options: [{ label: 'Draft', value: 'draft' }, { label: 'Sent', value: 'sent' }], + requiredWhen: { dialect: 'cel', source: 'record.quantity > 100' }, + }, { + name: 'receipt', + type: 'file', + accept: ['image/*', '.pdf'], + }, { + name: 'amount', + computed: true, + expr: 'quantity * unit_price', + scale: 2, + }], + }) as { inlineColumns: Array> }; + expect(parsed.inlineColumns[0]).toMatchObject({ name: 'unit_price', type: 'currency', width: 140, prefix: '$' }); + // Bare-string CEL predicates normalize to the Expression envelope, exactly + // as the field-level readonlyWhen does (same ExpressionInputSchema). + expect(parsed.inlineColumns[1].readonlyWhen).toEqual({ dialect: 'cel', source: "parent.status == 'paid'" }); + expect(parsed.inlineColumns[2].requiredWhen).toMatchObject({ dialect: 'cel', source: 'record.quantity > 100' }); + // The computed column's expr survives as the BARE string the grid evaluator reads. + expect(parsed.inlineColumns[4].expr).toBe('quantity * unit_price'); + }); + + it('refuses the retired `field` spelling with the prescription naming `name`', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ field: 'amount', label: 'Amount', type: 'currency' }], + }); + expect(issues).toContain('this inline grid column'); + expect(issues).toContain('`field`'); + expect(issues).toContain('`field` → `name`'); + // The refusal also fires because `name` is missing — the element is not + // merely stripped-and-accepted. + expect(issues).toContain('name'); + }); + + it('refuses an unknown key with the named surface and a distance suggestion', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', lable: 'Amount' }], + }); + expect(issues).toContain('Unrecognized key(s) on this inline grid column'); + expect(issues).toContain('`lable` → `label`'); + }); + + it('refuses a nonsense key outright (the issue repro: publish-time, not blank cells)', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', zzz: 1 }], + }); + expect(issues).toContain('Unrecognized key(s) on this inline grid column'); + expect(issues).toContain('`zzz`'); + }); + + it('refuses a bare string where a column object belongs', () => { + rejectField({ inlineEdit: 'grid', inlineColumns: ['amount'] }); + }); + + it('refuses a `type` outside the grid renderer cell-control vocabulary', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'notes', type: 'textarea' }], + }); + // The refusal lands at the column's own `type` path and names the whole + // cell-control vocabulary (the enum echoes the options, not the input). + expect(issues).toContain('"path":["inlineColumns",0,"type"]'); + expect(issues).toContain('"select"'); + expect(issues).toContain('"lookup"'); + }); + + it('refuses a CEL envelope on `expr` — the grid evaluator reads a bare arithmetic string', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', computed: true, expr: { dialect: 'cel', source: 'quantity * unit_price' } }], + }); + expect(issues).toContain('expr'); + }); + + it('refuses the field-level `expression` spelling on a column, prescribing `expr`', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', expression: 'quantity * unit_price' }], + }); + expect(issues).toContain('`expression` → `expr`'); + }); + + it('refuses a mis-keyed select option inside `options`', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'status', type: 'select', options: [{ text: 'Draft', value: 'draft' }] }], + }); + expect(issues).toContain('this inline grid column option'); + expect(issues).toContain('`text` → `label`'); + }); + + it('the element schema is exported and closed on its own', () => { + accept(InlineGridColumnSchema, { name: 'quantity' }); + reject(InlineGridColumnSchema, { name: 'quantity', field: 'quantity' }); + }); +}); + +// =========================================================================== +// 2. relatedListColumns — child field-name strings only +// =========================================================================== +describe('#9227 relatedListColumns — strings only', () => { + it('accepts the in-repo showcase spellings', () => { + const parsed = acceptField({ + relatedListColumns: ['name', 'status', 'total', 'issued_on'], + }) as { relatedListColumns: string[] }; + expect(parsed.relatedListColumns).toEqual(['name', 'status', 'total', 'issued_on']); + acceptField({ relatedListColumns: ['title', 'status', 'priority', 'assignee', 'due_date'] }); + acceptField({ relatedListColumns: ['name', 'status', 'health', 'budget', 'end_date'] }); + }); + + it('refuses a column OBJECT with the derivation prescription', () => { + const issues = rejectField({ + relatedListColumns: [{ name: 'amount', label: 'Amount' }], + }); + expect(issues).toContain('FIELD-NAME strings'); + expect(issues).toContain("child object's field definitions"); + }); + + it('refuses the object form in the retired grid spelling too — same named refusal', () => { + const issues = rejectField({ + relatedListColumns: [{ field: 'amount' }], + }); + expect(issues).toContain('FIELD-NAME strings'); + }); + + it('refuses an empty-string column', () => { + rejectField({ relatedListColumns: [''] }); + }); +}); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e269e62b59..c7a157620a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -4895,12 +4895,23 @@ const step18: MigrationStep = { 'are retiredKey tombstones; the mechanical conversion strips them from old sources ' + '(pure lossless deletes) and leaves the bare node, inert as it always was. List ' + 'surfaces own their filtering: a view\'s `userFilters` quick-filter bar / the list ' + - 'toolbar\'s filter builder.', + 'toolbar\'s filter builder. ' + + 'It also closes the two explicit column lists on relationship fields (#9227): ' + + '`field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema ' + + '(mirroring the objectui grid renderer\'s measured reads — objectui#3951 aligned the ' + + 'widget to `name` and retired the `field` spelling with no tolerant alias), and ' + + '`field.relatedListColumns` entries are child field-name strings (the only form the ' + + 'related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed ' + + 'column published clean and rendered as blank cells with the right row count. The ' + + 'mechanical conversion respells inline `{ field }` entries as `{ name }` and folds ' + + 'related-list column objects to their identity string; unknown keys are named ' + + 'rejections at publish from this major.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', 'element-input-target-variable-removed', 'element-filter-removed', + 'field-column-lists-canonicalized', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by