diff --git a/.changeset/list-view-export-options-object-form.md b/.changeset/list-view-export-options-object-form.md new file mode 100644 index 0000000000..98e928bc4b --- /dev/null +++ b/.changeset/list-view-export-options-object-form.md @@ -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. + + diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 5dbf3df754..6ce6ae86bc 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -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: ['']` 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 }[]` | 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; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | @@ -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: ['']` 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 }[]` | 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; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 88f297c51e..2a845fbe1b 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -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`. 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 96230e37e4..ba56b0c90d 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 | 426 | +| Object sites in them | 427 | | Still-open (strip) sites | 132 | | Files carrying at least one | 24 | @@ -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 @@ -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 @@ -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 | |---|---| diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a1d8e2f64b..53c2c2a2cc 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -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 | @@ -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 | diff --git a/examples/app-crm/src/views/opportunity.view.ts b/examples/app-crm/src/views/opportunity.view.ts index 999cef5740..472119a5a0 100644 --- a/examples/app-crm/src/views/opportunity.view.ts +++ b/examples/app-crm/src/views/opportunity.view.ts @@ -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: { @@ -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)', diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 337230eb45..69290c1cd4 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -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', diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 1a3a6da4ee..52c66651a5 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -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", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index a8f7c4e8ff..4a651b7622 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -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)", @@ -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)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0cfa735b49..499e4cb91f 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -2328,6 +2328,121 @@ const viewListPassthroughKeysRemoved: MetadataConversion = { }, }; +/** + * The `'pdf'` export format leaves `view.exportOptions` (protocol 17, #8010 — + * maintainer ruling 2026-08-12). + * + * PDF export was declined platform-side (#1301 NOT_PLANNED), so the enum + * 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? }` — + * the key set the renderer actually reads); the legacy bare array stays + * accepted and lifts to `{ formats: [...] }` at parse, so this conversion does + * NOT rewrite the array form to the object form — the array is back-compat, + * not retired. + * + * This is an enum-VALUE retirement, so there is no `retiredKey()` tombstone to + * hang the prescription on — the format enum's own error map carries it + * (`LIST_VIEW_EXPORT_PDF_RETIRED`, ui/view.zod.ts), keyed on `issue.input` so + * only the value which used to be legal gets the "was removed" message, plus a + * union-level dispatch so the refusal is the TOP-level message in either + * authored spelling (the `hook-body-crypto-hash-removed` precedent, one level + * deeper again: the value sits inside either a bare array or an object's + * `formats`, so `stripKeys` cannot reach it). + * + * `retiredFromLoadPath`: the enum rejects the value outright, so a live author + * is taught at parse rather than silently rewritten. The entry exists so + * stored 16.x/17-rc rows replay clean (`applyConversionsToStoredItem`) and so + * `os migrate meta --from 16` rewrites author sources. As with `crypto.hash`, + * the strip keeps the surrounding declaration — an emptied `formats` array + * stays (an export menu configured to offer nothing is a declaration, not an + * accident this conversion may invent an answer for). + */ +const viewExportOptionsPdfRemoved: MetadataConversion = { + id: 'view-export-options-pdf-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'view.list.exportOptions / view.listViews.*.exportOptions', + summary: + "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)', + apply(stack, emit) { + const stripPdf = (slot: unknown, path: string): unknown => { + if (!slot || typeof slot !== 'object' || Array.isArray(slot)) return slot; + const eo = (slot as Dict).exportOptions; + if (Array.isArray(eo) && eo.includes('pdf')) { + emit({ from: 'pdf', to: '(removed)', path: `${path}.exportOptions` }); + return { ...(slot as Dict), exportOptions: eo.filter((f) => f !== 'pdf') }; + } + if (eo && typeof eo === 'object' && !Array.isArray(eo)) { + const formats = (eo as Dict).formats; + if (Array.isArray(formats) && formats.includes('pdf')) { + emit({ from: 'pdf', to: '(removed)', path: `${path}.exportOptions.formats` }); + return { + ...(slot as Dict), + exportOptions: { ...(eo as Dict), formats: formats.filter((f) => f !== 'pdf') }, + }; + } + } + return slot; + }; + return mapCollection(stack, 'views', (view, path) => { + let touched = false; + const next: Record = { ...view }; + const list = stripPdf(next.list, `${path}.list`); + if (list !== next.list) { next.list = list; touched = true; } + const named = next.listViews; + if (named && typeof named === 'object' && !Array.isArray(named)) { + const rebuilt: Record = { ...(named as Record) }; + let subTouched = false; + for (const [name, lv] of Object.entries(rebuilt)) { + const cleaned = stripPdf(lv, `${path}.listViews.${name}`); + if (cleaned !== lv) { rebuilt[name] = cleaned; subTouched = true; } + } + if (subTouched) { next.listViews = rebuilt; touched = true; } + } + return touched ? next : view; + }); + }, + fixture: { + before: { + views: [{ + object: 'crm_contract', + // Legacy array spelling: 'pdf' is stripped, the survivors stay an array + // (the array form is back-compat, not retired — no lift here). + list: { type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }, + listViews: { + // Object spelling: 'pdf' leaves `formats`; the sibling keys survive. + archive: { + type: 'grid', + columns: ['name'], + exportOptions: { formats: ['csv', 'pdf'], maxRecords: 100 }, + }, + // No 'pdf' declared → untouched. + all: { type: 'grid', columns: ['name'], exportOptions: ['csv', 'json'] }, + }, + }], + }, + after: { + views: [{ + object: 'crm_contract', + list: { type: 'grid', columns: ['name'], exportOptions: ['xlsx'] }, + listViews: { + archive: { + type: 'grid', + columns: ['name'], + exportOptions: { formats: ['csv'], maxRecords: 100 }, + }, + all: { type: 'grid', columns: ['name'], exportOptions: ['csv', 'json'] }, + }, + }], + }, + expectedNotices: 2, + }, +}; + /** dashboard.aria / dashboard.performance / widgets[].performance. */ const dashboardInertKeysRemoved: MetadataConversion = { id: 'dashboard-inert-keys-removed', @@ -6409,6 +6524,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { + it('accepts the object form with all five renderer-read keys, byte-preserved (no unrecognized_keys)', () => { + const exportOptions = { + formats: ['csv', 'xlsx', 'json'] as const, + maxRecords: 5000, + includeHeaders: false, + fileNamePrefix: 'contracts', + streaming: false, + }; + const parsed = ListViewSchema.parse({ type: 'grid', columns: ['name'], exportOptions }); + expect(parsed.exportOptions).toStrictEqual(exportOptions); + }); + + it('LIFTS the legacy bare array to `{ formats }` at parse (the back-compat normalization)', () => { + const parsed = ListViewSchema.parse({ + type: 'grid', columns: ['name'], exportOptions: ['csv', 'xlsx'], + }); + expect(parsed.exportOptions).toStrictEqual({ formats: ['csv', 'xlsx'] }); + }); + + it("REJECTS 'pdf' in the legacy array form with the prescription naming #1301 and the survivors", () => { + let message = ''; + try { + ListViewSchema.parse({ type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }); + } catch (e) { message = String((e as Error).message); } + expect(message).toMatch(/'pdf' was removed from `view\.exportOptions` formats/); + expect(message).toMatch(/#1301/); + expect(message).toMatch(/'csv', 'xlsx' and 'json'/); + expect(message).toMatch(/Run `os migrate meta --from 16` to rewrite existing sources automatically\./); + }); + + it("REJECTS 'pdf' in the object form's `formats` with the same prescription", () => { + expect(() => ListViewSchema.parse({ + type: 'grid', columns: ['name'], exportOptions: { formats: ['csv', 'pdf'] }, + })).toThrow(/'pdf' was removed from `view\.exportOptions` formats.*#1301/s); + }); + + it('a wrong format that was NEVER legal keeps the plain enum message, not the retirement text', () => { + let message = ''; + try { + ListViewSchema.parse({ type: 'grid', columns: ['name'], exportOptions: ['docx'] }); + } catch (e) { message = String((e as Error).message); } + expect(message).not.toMatch(/was removed/); + }); + + it('an unknown key on the object form is refused with the strict-surface suggestion', () => { + let message = ''; + try { + ListViewSchema.parse({ + type: 'grid', columns: ['name'], exportOptions: { formats: ['csv'], maxRecord: 10 }, + }); + } catch (e) { message = String((e as Error).message); } + expect(message).toMatch(/maxRecord/); + expect(message).toMatch(/maxRecords/); + }); + + it('ObjectListViewSchema (the .extend copy) lifts the array and rejects pdf identically', () => { + const parsed = ObjectListViewSchema.parse({ + type: 'grid', columns: ['name'], exportOptions: ['json'], + }); + expect(parsed.exportOptions).toStrictEqual({ formats: ['json'] }); + expect(() => ObjectListViewSchema.parse({ + type: 'grid', columns: ['name'], exportOptions: { formats: ['pdf'] }, + })).toThrow(/'pdf' was removed from `view\.exportOptions` formats/s); + }); +}); + describe('HttpMethodSubsetSchema/HttpRequestSchema backward compat', () => { it('should still be importable from view.zod', () => { expect(HttpMethodSubsetSchema).toBeDefined(); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 026aa35813..3401310bab 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1236,10 +1236,81 @@ export const NavigationConfigSchema = lazySchema(() => strictObject({ width: z.union([z.string(), z.number()]).optional().describe('[DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket.'), })); +// `'pdf'` retirement prescription (#8010). Declared with `//` on purpose — the +// hook-body precedent's placement note applies here too: build-docs takes a +// file's first JSDoc per exported symbol, and this constant needs no doc page. +const LIST_VIEW_EXPORT_PDF_RETIRED = + "'pdf' was removed from `view.exportOptions` formats in @objectstack/spec 17.0.0 (#8010; " + + 'PDF export itself was declined as #1301 NOT_PLANNED) — no renderer has ever produced a PDF ' + + 'export: ObjectGrid dropped the declared format from the export menu with only a runtime ' + + "console.warn, so authoring it was a parse-clean no-op. Delete the value; the surviving " + + "formats are 'csv', 'xlsx' and 'json'. " + + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.'; + +/** + * Export formats the platform actually delivers (#8010): `csv`/`json` on both + * export paths, `xlsx` on the server stream only. + * + * `'pdf'` was REMOVED in 17 (#8010): PDF export was declined platform-side + * (#1301 NOT_PLANNED), so the enum member was a declared-but-unrenderable + * format whose only failure signal was a browser console line. This is an + * enum-VALUE narrowing, so there is no `retiredKey()` tombstone to hang the + * prescription on — the enum's own error map carries it + * ({@link LIST_VIEW_EXPORT_PDF_RETIRED}), keyed on `issue.input` so that only + * the value which used to be legal gets the "was removed" message (the + * `HookBodyCapability` / `object.managedBy: 'system'` precedent). + */ +const ListViewExportFormatSchema = z.enum(['csv', 'xlsx', 'json'], { + error: (issue) => (issue.input === 'pdf' ? LIST_VIEW_EXPORT_PDF_RETIRED : undefined), +}); + +/** + * Object form of `view.exportOptions` (#8010, maintainer ruling 2026-08-12 — + * option A). The declared key set is exactly what the only renderer reads, + * measured on objectui `origin/main@878140b` (`ObjectGrid.tsx:1596–1642`): + * `formats`, `maxRecords`, `includeHeaders`, `fileNamePrefix`, and the + * previously UNDECLARED `streaming` opt-out — declared here so no + * undeclared-but-read key survives the fix. Declaring anything more would be + * capability surface with no reader; declaring less recreates the defect. + */ +const ListViewExportOptionsSchema = strictObject({ + surface: 'this export options block', + history: VIEW_HISTORY, +}, { + formats: z.array(ListViewExportFormatSchema).optional() + .describe("Formats offered in the export menu (default: ['csv', 'json']). XLSX is delivered by the server stream only."), + maxRecords: z.number().int().nonnegative().optional() + .describe('Maximum number of records to export; 0 or absent = unlimited'), + includeHeaders: z.boolean().optional() + .describe('Include column headers in the exported file (default true)'), + fileNamePrefix: z.string().optional() + .describe('Download file name prefix — replaces the object label and suppresses the view label in the generated file name'), + streaming: z.boolean().optional() + .describe('Set false to force the client-side export path (csv/json only) instead of the server stream'), +}); + +/** + * Loud top-level refusal for a retired `'pdf'` anywhere in `exportOptions` + * (#8010). Without this, the prescription raised inside a union BRANCH is + * buried in `invalid_union` sub-errors; with it, the union's own message IS + * the prescription whenever the authored value — either spelling — declares + * `'pdf'`. Every other union failure keeps zod's default message, with the + * branch detail nested beneath it. + */ +const exportOptionsPdfUnionError = (issue: { input?: unknown }): string | undefined => { + const input = issue.input; + const declaresPdf = Array.isArray(input) + ? input.includes('pdf') + : (!!input && typeof input === 'object' + && Array.isArray((input as { formats?: unknown }).formats) + && ((input as { formats: unknown[] }).formats).includes('pdf')); + return declaresPdf ? LIST_VIEW_EXPORT_PDF_RETIRED : undefined; +}; + /** * List View Schema (Expanded) * Defines how a collection of records is displayed to the user. - * + * * **NAMING CONVENTION:** * View names (when provided) are machine identifiers and must be lowercase snake_case. * @@ -1433,7 +1504,21 @@ export const ListViewSchema = lazySchema(() => strictObject({ inlineEdit: z.boolean().optional().describe('Allow inline editing of records directly in the list view'), /** Export */ - exportOptions: z.array(z.enum(['csv', 'xlsx', 'pdf', 'json'])).optional().describe('Available export format options'), + // Object form adopted at #8010 (maintainer ruling 2026-08-12 — option A): + // the spec published a bare format array while the only renderer (objectui + // ObjectGrid) read `exportOptions.formats` — no declaration was both + // type-legal and functional. The object form is now the contract; the legacy + // bare array stays accepted and LIFTS to `{ formats: [...] }` at parse + // (z.input keeps both spellings, z.output is the object form only). + // `'pdf'` left the format enum in the same change (#1301 NOT_PLANNED) — the + // enum error map and the union error above carry the prescription. + exportOptions: z.union([ + z.array(ListViewExportFormatSchema).transform((formats) => ({ formats })), // Legacy: bare format array + ListViewExportOptionsSchema, + ], { error: exportOptionsPdfUnionError }).optional().describe( + '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.', + ), /** User Actions (Airtable Interface parity) */ userActions: UserActionsConfigSchema.optional().describe('User action toggles for the view toolbar'),