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
57 changes: 57 additions & 0 deletions .changeset/list-view-export-options-object-form.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/spec": major
---

refactor(spec)!: `view.exportOptions` adopts the object form the renderer reads; `'pdf'` leaves the format enum (#8010, ruling 2026-08-12)

`ListViewSchema` (and its `ObjectListViewSchema` copy) typed `exportOptions` as
a bare format array while the only renderer of the property — objectui
`ObjectGrid.tsx` — reads an **object** (`schema.exportOptions?.formats`, plus
`maxRecords`, `includeHeaders`, `fileNamePrefix` and an undeclared `streaming`
opt-out; measured on objectui `origin/main@878140b`, `:1596–:1642`). A project
following the published type wrote `exportOptions: ['xlsx']`, the renderer saw
`.formats === undefined` and fell back to `['csv', 'json']` — so **no
declaration was both type-legal and functional** (reported from a live customer
project). The maintainer ruling adopts option A: the spec's contract is now the
object form, declaring exactly the five renderer-read keys, `streaming`
included so no undeclared-but-read key survives the fix.

The same ruling removes `'pdf'` from the format enum: PDF export was declined
platform-side (#1301 NOT_PLANNED), so the member was declared-but-unrenderable
— ObjectGrid dropped it from the menu with only a runtime `console.warn`. This
is an enum-VALUE narrowing (the `crypto.hash` precedent): the enum's own error
map carries the prescription, keyed on the received value so only the spelling
that used to be legal is told it "was removed", and a union-level dispatch
makes the refusal the top-level parse message in either authored form.

FROM → TO:

| Was | Now |
|:--|:--|
| `exportOptions: ['csv', 'xlsx']` | still accepted (legacy spelling) — lifts to `{ formats: ['csv', 'xlsx'] }` at parse; prefer the object form |
| `exportOptions: ['xlsx', 'pdf']` | refused at parse with the prescription — delete `'pdf'`; the surviving formats are `'csv'`, `'xlsx'`, `'json'` |
| *(unwritable before)* `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }` | the contract — a strict object; unknown keys are refused with a suggestion |

The retirement kit (for the `'pdf'` half):

- **No `retiredKey()` tombstone** — an enum VALUE has no key to tombstone; the
format enum's error map + the union error carry the prescription
(why the member never worked, the surviving formats, the one-line fix).
- **ADR-0087 D2 conversion + D3 chain step** (`view-export-options-pdf-removed`):
`os migrate meta --from 16` strips `'pdf'` from `list.exportOptions` and named
`listViews.*.exportOptions` in both spellings, one notice per occurrence,
keeping an emptied `formats` array. `retiredFromLoadPath` — the enum owns the
refusal; stored pre-removal rows replay clean via the stored-row chain. The
conversion deliberately does **not** rewrite the array spelling to the object
form — the array is back-compat, not retired.
- **Liveness ledger**: the `view.json` `exportOptions` row stays `live`, re-cited
to the five measured renderer reads and re-dated.

**Behaviour that changes:** a declaration carrying `'pdf'` is now refused at
parse with the reason instead of silently rendering a menu without PDF. The
object form — the only spelling the renderer has ever read — becomes type-legal
for the first time. The objectui side (type comment claiming alignment, the
undeclared `streaming` read, `'pdf'` in its local type) is reconciled in a
follow-up card on that repo.

<!-- adr-0087: registered view-export-options-pdf-removed -->
4 changes: 2 additions & 2 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -498,7 +498,7 @@ List chart view configuration
| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['<name>']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. |
| **conditionalFormatting** | `{ condition: string \| object; style: Record<string, string> }[]` | optional | Conditional formatting rules for list rows |
| **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view |
| **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'pdf' \| 'json'>[]` | optional | Available export format options |
| **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. |
| **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar |
| **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration |
| **tabs** | `{ name: string; label?: string \| Record<string, string>; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface |
Expand DownExpand Up@@ -586,7 +586,7 @@ List chart view configuration
| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['<name>']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. |
| **conditionalFormatting** | `{ condition: string \| object; style: Record<string, string> }[]` | optional | Conditional formatting rules for list rows |
| **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view |
| **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'pdf' \| 'json'>[]` | optional | Available export format options |
| **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. |
| **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar |
| **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration |
| **tabs** | `{ name: string; label?: string \| Record<string, string>; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/views.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ A List View controls how a collection of records is presented. It supports multi
| `bulkActions` | `array` | optional | Bulk selection actions, by action name — see [Actions](/docs/ui/actions) |
| `bulkActionDefs` | `array` | optional | Rich bulk action definitions — mass edits, and the aggregate single-call mode (below) |
| `inlineEdit` | `boolean` | optional | Enable inline editing |
| `exportOptions` | `string[]` | optional | Enabled export formats (`csv`, `xlsx`, `pdf`, `json`) |
| `exportOptions` | `object` | optional | Export menu configuration: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. `formats` accepts `csv`, `xlsx`, `json` (default `['csv', 'json']`; PDF export was declined — #1301). A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse |

The view's machine name is its **key** in the container (`listViews.urgent` on
object `task` becomes `task.urgent`); the default `list` claims `task.default`.
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 426 |
| Object sites in them | 427 |
| Still-open (strip) sites | 132 |
| Files carrying at least one | 24 |

Expand All@@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 161 | 150 | 5 | 0 | 6 |
| `ui/` | 162 | 151 | 5 | 0 | 6 |
| `data/` | 153 | 62 | 1 | 0 | 90 |
| `automation/` | 65 | 42 | 0 | 0 | 23 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **426** | **288** | **6** | **0** | **132** |
| **total** | **427** | **289** | **6** | **0** | **132** |

## File-level triage — site counts

Expand All@@ -75,9 +75,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `responsive.zod.ts` | 4 |
| `sharing.zod.ts` | 1 |
| `theme.zod.ts` | 6 |
| `view.zod.ts` | 54 |
| `view.zod.ts` | 55 |
| `widget.zod.ts` | 1 |
| **total** | **161** |
| **total** | **162** |

### `data/` — sites

Expand DownExpand Up@@ -156,15 +156,15 @@ over it is here.

### `ui/` — open

**6 strip of 161**, in 4 file(s).
**6 strip of 162**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `view.zod.ts` | 3 | 54 |
| `view.zod.ts` | 3 | 55 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **6** | **161** |
| **total** | **6** | **162** |

| Bucket | Sites |
|---|---|
Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,8 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049,

It also removes the three pass-through-only list-view display keys `striped` / `bordered` / `virtualScroll` (#7176, ADR-0049 enforce-or-remove, maintainer ruling 2026-08-10). All three were graded live on reads that turned out to be forwarding copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the key onto the next node, and the chain ends at ObjectGrid, which never spells any of the three — so an author who wrote `striped: true` got a parse-clean no-op, the exact silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in effect; per the ruling, if objectui wants one of these as real behavior, that is an implementation card filed first, and the key stays retired pending it.

Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, maintainer ruling 2026-08-12). PDF export was declined platform-side (#1301 NOT_PLANNED), so the member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu with only a runtime console.warn, so `exportOptions: ['xlsx', 'pdf']` type-checked, validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT form for `exportOptions` — `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, exactly the key set the renderer reads, ending the state where no declaration was both type-legal and functional — with the legacy bare array still accepted and lifted to `{ formats: [...] }` at parse, which is why the conversion strips only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the format enum's error map carries the prescription, keyed on the received value so only the spelling that used to be legal is told it "was removed", plus a union-level dispatch so the refusal is the top-level message in either authored form. The strip keeps an emptied `formats` array rather than deleting the declaration.

### Mechanical (applied for you)

| Conversion | Surface | Change | Load window |
Expand All@@ -264,6 +266,7 @@ It also removes the three pass-through-only list-view display keys `striped` / `
| `flow-inert-keys-removed` | `flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId` | flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle) | retired — `migrate meta` only |
| `view-inert-keys-removed` | `view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria` | view keys removed (#3896 close-out): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live) | retired — `migrate meta` only |
| `view-list-passthrough-keys-removed` | `view.list.striped / view.list.bordered / view.list.virtualScroll` | view list keys removed (#7176): 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (pass-through-only; ADR-0049 enforce-or-remove) | retired — `migrate meta` only |
| `view-export-options-pdf-removed` | `view.list.exportOptions / view.listViews.*.exportOptions` | list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn) | retired — `migrate meta` only |
| `dashboard-inert-keys-removed` | `dashboard.aria / dashboard.performance / dashboard.widgets[].performance` | dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them) | retired — `migrate meta` only |
| `dashboard-widget-responsive-removed` | `dashboard.widgets[].responsive` | dashboard widget key 'responsive' removed (#4876 — no renderer ever applied per-widget breakpoint overrides; page.components[].responsive is unaffected) | retired — `migrate meta` only |
| `dashboard-widget-action-aria-removed` | `dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria` | dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed (#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never reached the DOM; use header.actions[] and the widget title/description) | retired — `migrate meta` only |
Expand Down
4 changes: 2 additions & 2 deletions examples/app-crm/src/views/opportunity.view.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export const OpportunityViews = defineView({
{ field: 'expected_revenue' },
{ field: 'close_date' },
],
exportOptions: ['csv', 'xlsx', 'json'],
exportOptions: { formats: ['csv', 'xlsx', 'json'] },
},
listViews: {
all: {
Expand All@@ -32,7 +32,7 @@ export const OpportunityViews = defineView({
{ field: 'expected_revenue' },
{ field: 'close_date' },
],
exportOptions: ['csv', 'xlsx', 'json'],
exportOptions: { formats: ['csv', 'xlsx', 'json'] },
},
pipeline: {
label: 'Pipeline (Kanban)',
Expand Down
4 changes: 3 additions & 1 deletion examples/app-showcase/src/ui/views/task.view.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,9 @@ export const TaskViews = defineView({
data,
columns: [{ field: 'title' }, { field: 'project' }, { field: 'assignee' }, { field: 'status' }, { field: 'priority' }, { field: 'due_date' }],
filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }],
exportOptions: ['csv', 'xlsx', 'json'],
// Object form (#8010): `formats` is what the renderer reads; the bare
// array is the legacy spelling and lifts to this shape at parse.
exportOptions: { formats: ['csv', 'xlsx', 'json'] },
},
urgent: {
label: 'Urgent',
Expand Down
3 changes: 2 additions & 1 deletion packages/spec/liveness/view.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -185,7 +185,8 @@
},
"exportOptions": {
"status": "live",
"note": "objectui: ObjectGrid.tsx (audit L15)."
"verifiedAt": "2026-08-13",
"note": "objectui: ObjectGrid.tsx reads five object keys — formats/streaming (:1596-:1611), maxRecords/includeHeaders/fileNamePrefix (:1620-:1631) — re-measured objectui origin/main@878140b. Object form adopted at #8010 (option A ruling 2026-08-12): the spec used to publish a bare format array the renderer never read (.formats on an array is undefined), so no declaration was both type-legal and functional; the bare array stays accepted and lifts to { formats } at parse. Sub-value 'pdf' removed same change (#1301 NOT_PLANNED) — enum error map carries the prescription; conversion view-export-options-pdf-removed strips stored sources."
},
"userActions": {
"status": "live",
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/spec-changes.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -188,6 +188,12 @@
"conversionId": "view-list-passthrough-keys-removed",
"toMajor": 17
},
{
"surface": "view.list.exportOptions / view.listViews.*.exportOptions",
"to": "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn)",
"conversionId": "view-export-options-pdf-removed",
"toMajor": 17
},
{
"surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance",
"to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)",
Expand DownExpand Up@@ -1260,6 +1266,12 @@
"conversionId": "view-list-passthrough-keys-removed",
"toMajor": 17
},
{
"surface": "view.list.exportOptions / view.listViews.*.exportOptions",
"to": "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn)",
"conversionId": "view-export-options-pdf-removed",
"toMajor": 17
},
{
"surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance",
"to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)",
Expand Down
Loading
Loading