Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/21229-object-grid-export-options-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an `object-grid` page block's `exportOptions` is the list view's export options object, and a bare format array is refused (#21229)

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-object-grid-export-options-closed -->

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.

**`@objectstack/spec`**

- **`ComponentPropsMap['object-grid'].exportOptions`** was `z.unknown()`, so any value passed. The console's `ObjectGrid` reads `exportOptions.formats`, `.maxRecords`, `.includeHeaders`, `.fileNamePrefix` and `.streaming`, and lifts nothing: a bare format array — legal on a list view, which lifts it to `{ formats }` at parse — showed the export menu with its csv/json default and dropped the author's list without a report. The row now takes the list view's own five-member export options object, by identity and not the list view's union, so the legacy spelling does not spread to the grid:
- a bare array is refused with the object form named (`{ formats: ['csv', 'xlsx'] }`);
- a format outside `csv` / `xlsx` / `json` is refused at its index, and `pdf` keeps its retirement text;
- a key the object does not declare is named, with the rename a near-miss gets (`maxRecord` → `maxRecords`);
- `null` and other non-object values are refused.
- **`ObjectGridProps['exportOptions']`** (and `ObjectGridPropsParsed`) is the object type `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }` instead of `unknown`.
- The list view's `exportOptions` accepts and lifts exactly what it did. One message changed there, nested only: when a bare array also fails the array arm (a format outside the enum), the object arm's branch of the union now names the object form instead of zod's `expected object, received array`.

## FROM → TO

| you wrote on an `object-grid` | write instead |
|:--|:--|
| `exportOptions: ['csv', 'xlsx']` | `exportOptions: { formats: ['csv', 'xlsx'] }` — the grid now offers exactly those formats; write `{}` to keep the csv/json default it has been offering |
| `exportOptions: { formats: ['csv', 'pdf'] }` | `exportOptions: { formats: ['csv'] }` |
| `exportOptions: { formats: ['csv'], maxRecord: 100 }` | `exportOptions: { formats: ['csv'], maxRecords: 100 }` |
| `exportOptions: null` | omit `exportOptions` |

The one-line fix: write `exportOptions` on an `object-grid` as the object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from `csv`, `xlsx` and `json`.

## Who is affected, measured

On `origin/main` `f148852752`: zero `object-grid` blocks authoring `exportOptions` in the examples, the package fixtures, the documentation and the published skills, against ten authored `object-grid` blocks through the same census (nine in TypeScript, one in a YAML documentation example) and four list-view `exportOptions` authorings as the key's control. No conversion is registered: nothing on the metadata load path refuses the shape, and a bare array has no rewrite that both keeps what the grid shows today and honours the author's list. Deployed metadata was not measured.
12 changes: 11 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ Sort field and direction pair
| **reorderableColumns** | `boolean` | optional | Allow column drag-reorder |
| **frozenColumns** | `number` | optional | How many leading columns stay frozen (default 1) |
| **showColumnTypeIcons** | `boolean` | optional | Show field-type icons in column headers |
| **exportOptions** | `any` | optional | Export config (`{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`). Unvalidated here (`z.unknown()`), so this list is the whole account of the shape; `ListViewSchema.exportOptions` declares the same five members with their per-member contract |
| **exportOptions** | `{ formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export config — the object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, the same block a list view's `exportOptions` declares, with `formats` drawn from `csv`, `xlsx` and `json`. A bare format array is refused: the grid reads `exportOptions.formats`, so write `{ formats: ['csv', 'xlsx'] }` |
| **operations** | `any` | optional | Operation toggles (`{ export: false, … }`) |
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` |
| **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` |
Expand Down Expand Up @@ -772,6 +772,16 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **fields** | `{ field: string; order: Enum<'asc' \| 'desc'>; collapsed: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |

### Nested Shape: `ObjectGridProps.exportOptions`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **formats** | `Enum<'csv' \| 'xlsx' \| 'json'>[]` | optional | Formats offered in the export menu (default: ['csv', 'json']). XLSX is delivered by the server stream only. |
| **maxRecords** | `integer` | optional | Maximum number of records to export; 0 or absent = unlimited |
| **includeHeaders** | `boolean` | optional | Include column headers in the exported file (default true) |
| **fileNamePrefix** | `string` | optional | Download file name prefix — replaces the object label and suppresses the view label in the generated file name |
| **streaming** | `boolean` | optional | Set false to force the client-side export path (csv/json only) instead of the server stream |

### Nested Shape: `ObjectGridProps.data[provider='object']`

| Property | Type | Required | Description |
Expand Down
12 changes: 6 additions & 6 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 189 | 179 | 3 | 0 | 7 |
| `ui/` | 188 | 178 | 3 | 0 | 7 |

## `ui/` — sites

Expand All @@ -44,25 +44,25 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `report.zod.ts` | 3 |
| `responsive.zod.ts` | 1 |
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 61 |
| `view.zod.ts` | 60 |
| `widget.zod.ts` | 1 |
| **total** | **189** |
| **total** | **188** |

## `ui/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**7 strip of 189**, in 4 file(s).
**7 strip of 188**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 61 |
| `view.zod.ts` | 4 | 60 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **189** |
| **total** | **7** | **188** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #21229 — an `object-grid` page block's `exportOptions` was `z.unknown()`, so a
// bare format array (the list view's legacy spelling, which the list view lifts
// to `{ formats }`) was accepted on the grid, whose renderer reads
// `exportOptions.formats` and lifts nothing. The row now takes the list view's
// five-member export options OBJECT by identity, not the list view's union. D3
// only: page-component `properties` is not parsed on the metadata save or load
// path, so a stored page is never refused and there is no load-path refusal for
// a conversion to pre-empt; the bare array never worked here, and lifting it
// would change the export menu a deployed grid shows today; the authored census
// found nothing to respell.
export const entry: SemanticMigration = {
id: 'ui-object-grid-export-options-closed',
surface: 'page `object-grid` components — `properties.exportOptions` (which used to accept any value)',
replacement: 'the export options object a list view\'s `exportOptions` declares: `{ formats?, '
+ 'maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from '
+ '`csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / '
+ '`streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to '
+ 'offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the '
+ 'csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the '
+ 'object does not declare; delete an `exportOptions: null` (it never enabled the menu).',
reason: 'The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, '
+ '`.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, '
+ 'but the page-component row declared the key `z.unknown()`, so any value passed the '
+ 'component-props gate. The trap was the list view\'s legacy spelling: a bare format array is '
+ 'legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, '
+ 'which lifts nothing — the export menu appeared, offering the csv/json default, and the '
+ 'author\'s list was dropped without a report. The row now takes the list view\'s export '
+ 'options object itself rather than its union, so a legacy spelling does not spread to a '
+ 'surface that never read it: a bare array is refused with the object form named, a format '
+ 'outside the enum is refused at its index (`pdf` with its retirement text), and a key the '
+ 'object does not declare is named. It is read where every page component\'s props are: the '
+ 'component-props gate reports these as an advisory `component-props-invalid` / '
+ '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and '
+ '`objectstack lint`, and a stored page still saves and loads, because a page component\'s '
+ '`properties` is not parsed on the metadata save or load path. No conversion is registered: '
+ 'nothing on the load path refuses the shape; a bare array has no rewrite that both keeps '
+ 'what the grid shows today and honours what the author wrote, which is the judgment this '
+ 'entry leaves to the upgrader; and the authored census found nothing to respell. Population '
+ 'measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring '
+ '`exportOptions` in the examples, the package fixtures, the documentation and the published '
+ 'skills, against ten authored `object-grid` blocks through the same matcher (nine in '
+ 'TypeScript, one in a YAML documentation example) and four list-view `exportOptions` '
+ 'authorings as the key\'s control. Deployed metadata NOT MEASURED.',
acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no '
+ '`component-props-invalid` / `component-props-unknown-key` finding on a '
+ '`properties.exportOptions` path. Every `exportOptions` on an `object-grid` is an object '
+ 'carrying only the five declared keys, with every `formats` entry `csv`, `xlsx` or `json`, '
+ 'and the grid\'s export menu offers the declared formats the active export path delivers '
+ '(`xlsx` on the server stream only).',
};
67 changes: 67 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6008,6 +6008,23 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
+ '`columns` untouched) on `object-form` page components, on every form payload a view '
+ 'carries, and on the assembled-manifest `viewItems` channel.',
},
{
id: 'ui-object-grid-export-options-closed',
order: 59,
text:
'It also closes the export options of an `object-grid` page block (#21229). `exportOptions` '
+ 'was `z.unknown()`, so a bare format array — the list view\'s legacy spelling, which the list '
+ 'view lifts to `{ formats }` — was accepted on the grid, whose renderer reads '
+ '`exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and '
+ 'the author\'s list was dropped. The row now takes the list view\'s five-member export '
+ 'options object by identity, not the list view\'s union, and refuses a bare array with the '
+ 'object form named, a format outside the enum and an undeclared key. Page-component '
+ '`properties` is read by the component-props gate, which reports these as advisory '
+ 'findings, and is not parsed on the metadata save or load path, so a stored page still saves '
+ 'and loads and no conversion is registered: the bare array never worked here, and lifting it '
+ 'would change the menu a deployed grid shows. The authored census found nothing to respell. '
+ 'Its D3 record is the semantic entry `ui-object-grid-export-options-closed`.',
},
{
id: 'ui-object-master-detail-form-details-closed',
order: 56,
Expand Down Expand Up @@ -18698,6 +18715,56 @@ const step18: MigrationStep = {
+ 'group per value of that field, and a board that showed one swimlane shows one swimlane per value — '
+ 'check that this is the grouping you meant.',
},
// #21229 — an `object-grid` page block's `exportOptions` was `z.unknown()`, so a
// bare format array (the list view's legacy spelling, which the list view lifts
// to `{ formats }`) was accepted on the grid, whose renderer reads
// `exportOptions.formats` and lifts nothing. The row now takes the list view's
// five-member export options OBJECT by identity, not the list view's union. D3
// only: page-component `properties` is not parsed on the metadata save or load
// path, so a stored page is never refused and there is no load-path refusal for
// a conversion to pre-empt; the bare array never worked here, and lifting it
// would change the export menu a deployed grid shows today; the authored census
// found nothing to respell.
{
id: 'ui-object-grid-export-options-closed',
surface: 'page `object-grid` components — `properties.exportOptions` (which used to accept any value)',
replacement: 'the export options object a list view\'s `exportOptions` declares: `{ formats?, '
+ 'maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from '
+ '`csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / '
+ '`streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to '
+ 'offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the '
+ 'csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the '
+ 'object does not declare; delete an `exportOptions: null` (it never enabled the menu).',
reason: 'The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, '
+ '`.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, '
+ 'but the page-component row declared the key `z.unknown()`, so any value passed the '
+ 'component-props gate. The trap was the list view\'s legacy spelling: a bare format array is '
+ 'legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, '
+ 'which lifts nothing — the export menu appeared, offering the csv/json default, and the '
+ 'author\'s list was dropped without a report. The row now takes the list view\'s export '
+ 'options object itself rather than its union, so a legacy spelling does not spread to a '
+ 'surface that never read it: a bare array is refused with the object form named, a format '
+ 'outside the enum is refused at its index (`pdf` with its retirement text), and a key the '
+ 'object does not declare is named. It is read where every page component\'s props are: the '
+ 'component-props gate reports these as an advisory `component-props-invalid` / '
+ '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and '
+ '`objectstack lint`, and a stored page still saves and loads, because a page component\'s '
+ '`properties` is not parsed on the metadata save or load path. No conversion is registered: '
+ 'nothing on the load path refuses the shape; a bare array has no rewrite that both keeps '
+ 'what the grid shows today and honours what the author wrote, which is the judgment this '
+ 'entry leaves to the upgrader; and the authored census found nothing to respell. Population '
+ 'measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring '
+ '`exportOptions` in the examples, the package fixtures, the documentation and the published '
+ 'skills, against ten authored `object-grid` blocks through the same matcher (nine in '
+ 'TypeScript, one in a YAML documentation example) and four list-view `exportOptions` '
+ 'authorings as the key\'s control. Deployed metadata NOT MEASURED.',
acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no '
+ '`component-props-invalid` / `component-props-unknown-key` finding on a '
+ '`properties.exportOptions` path. Every `exportOptions` on an `object-grid` is an object '
+ 'carrying only the five declared keys, with every `formats` entry `csv`, `xlsx` or `json`, '
+ 'and the grid\'s export menu offers the declared formats the active export path delivers '
+ '(`xlsx` on the server stream only).',
},
{
id: 'ui-object-grid-page-size-positive-integer-refused',
surface: '`object-grid` page-component page sizes '
Expand Down
Loading
Loading