diff --git a/.changeset/export-options-spec-object-form-4535.md b/.changeset/export-options-spec-object-form-4535.md new file mode 100644 index 000000000..d461096a9 --- /dev/null +++ b/.changeset/export-options-spec-object-form-4535.md @@ -0,0 +1,20 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-grid': patch +--- + +`exportOptions` is the spec's object form: `streaming` is declared, `'pdf'` is retired, and the alignment comment is finally true + +`ObjectGridSchema.exportOptions` carried four keys under a comment claiming alignment with `@objectstack/spec`'s `ListViewSchema.exportOptions`. The comment was false in both directions. The spec declared a bare format ARRAY, not an object, so no authored document could satisfy both spellings at once; and `ObjectGrid` read a fifth key — `streaming`, the opt-out that forces the client-side export path — which appeared in no declaration anywhere, reachable only through an `as any` cast in the renderer. An author had no way to discover the key except by reading the renderer's source, and no schema would have refused it or honoured it. + +objectstack#8010 closed that upstream by declaring `ListViewExportOptionsSchema` with exactly the five keys this renderer reads. This change lands the objectui half of the reconciliation: + +- The five keys are now one exported type, `ListViewExportOptions` — `formats`, `maxRecords`, `includeHeaders`, `fileNamePrefix`, `streaming` — shared by `ObjectGridSchema` and by a saved `NamedListView`, so the two authoring surfaces cannot grow apart. The comment above it names the spec symbol and version it mirrors, which makes it checkable rather than reassuring. +- `streaming` is declared, and the renderer's `as any` casts are gone. Removing them against the old four-key type produced two `TS2339: Property 'streaming' does not exist` errors — that red is what the declaration fixes. +- `'pdf'` is retired from the local format union, published as `ListViewExportFormat`. PDF export was declined platform-side (objectstack#1301 NOT_PLANNED) and the value left the spec's format enum in `@objectstack/spec` 17.0.0, where authoring it is now a parse-time refusal carrying `os migrate meta --from 16`. No ObjectUI path has ever produced a PDF: a declared `'pdf'` reached the user only as a browser console line. + +Runtime behavior of the export menu is unchanged. The filter that drops undeliverable formats is format-agnostic — it keeps what the active path can deliver — so it still hides `xlsx` when no server stream is available, and it still hides a legacy `'pdf'` that pre-17 stored metadata carries until the migration rewrites it. There was no `'pdf'`-specific branch to delete. + +Two guards keep the contract from re-opening. On the type side, a compile-time assertion pins the interface's key set to exactly the spec's five, so a sixth key fails the build. On the renderer side, a source scan collects every property `ObjectGrid` reads off `exportOptions` — through the alias it binds, and through any cast, since a cast is how `streaming` stayed invisible — and fails if the renderer reads anything the type does not declare. + +`@object-ui/types` is a minor: `ListViewExportFormat` and `ListViewExportOptions` are new exports, `streaming` is a new optional key, and `formats` no longer admits `'pdf'`. Anything still writing that value was authoring metadata the platform now refuses at publish. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index b2754124e..e45088bdc 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -22,7 +22,7 @@ */ import React, { useEffect, useState, useCallback, useMemo } from 'react'; -import type { ObjectGridSchema, DataSource, ListColumn, ViewData, TableSortItem, DataTableSchema } from '@object-ui/types'; +import type { ObjectGridSchema, DataSource, ListColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat } from '@object-ui/types'; import { isSystemManagedField } from '@object-ui/types'; import type { I18nLabel } from '@objectstack/spec/ui'; import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions } from '@object-ui/react'; @@ -1688,17 +1688,25 @@ export const ObjectGrid: React.FC = ({ }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms]); // Formats this grid can actually deliver (objectui#2942): the server stream - // handles csv/xlsx/json, the client fallback only csv/json, and pdf exists - // nowhere (declined platform-side — objectstack#1301). Declared-but-dead + // handles csv/xlsx/json, the client fallback only csv/json. Declared-but-dead // formats used to render as menu items whose click did nothing; now they're // dropped from the menu (with a one-time warning for the app author). + // + // The filter is format-AGNOSTIC — it keeps what `supported` lists — so it + // still covers the live case (`xlsx` declared with no server stream) and, for + // free, the legacy one: `'pdf'` was declined platform-side + // (objectstack#1301) and left the spec's format enum in 17.0.0 + // (objectstack#8010), so it is no longer authorable, but metadata stored + // before the retirement still carries it until `os migrate meta --from 16` + // runs. Such a value reaches here and is dropped by the same rule, with no + // `'pdf'`-specific branch to keep alive (objectui#4535). // (Hoisted above the error/loading early returns to satisfy hooks rules.) const exportableFormats = useMemo(() => { const declared = schema.exportOptions?.formats || ['csv', 'json']; const serverAvailable = typeof dataSource?.exportDownload === 'function' && !!objectName && !hasInlineData - && (schema.exportOptions as any)?.streaming !== false; + && schema.exportOptions?.streaming !== false; const supported = serverAvailable ? ['csv', 'xlsx', 'json'] : ['csv', 'json']; return declared.filter((f: string) => supported.includes(f)); }, [schema.exportOptions, dataSource, objectName, hasInlineData]); @@ -1711,7 +1719,7 @@ export const ObjectGrid: React.FC = ({ } }, [schema.exportOptions, exportableFormats]); - const handleExport = useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => { + const handleExport = useCallback((format: ListViewExportFormat) => { // Object-level export permission gate. Default-allow: an explicit // `operations.export === false` blocks it, and — when the server hands down // an effective API operation set for this object (#3391) — so does its @@ -1741,7 +1749,7 @@ export const ObjectGrid: React.FC = ({ && !!objectName && !hasInlineData // Honor an opt-out: schema.exportOptions.streaming === false forces client-side. - && (exportConfig as any)?.streaming !== false; + && exportConfig?.streaming !== false; if (serverEligible) { const cols = generateColumns().filter((c: any) => c.accessorKey !== '_actions'); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.exportOptionsKeys.test.ts b/packages/plugin-grid/src/__tests__/ObjectGrid.exportOptionsKeys.test.ts new file mode 100644 index 000000000..6509373a4 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.exportOptionsKeys.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Structural drift guard for `exportOptions` (objectui#4535 / objectstack#8010). + * + * `ObjectGrid` reads a handful of keys off `schema.exportOptions`. Upstream, + * `@objectstack/spec` derived `ListViewExportOptionsSchema`'s FIVE keys from + * exactly that read set — so the declaration and the reads are one contract seen + * from either end, and the whole point of objectstack#8010 was that they had + * come apart: `streaming` was read here for releases while no schema declared + * it, so authoring it was refused by nothing and honoured by nobody, and the + * only way to discover the key was to read this renderer's source. + * + * That defect is silent by construction — an undeclared key does not fail to + * compile, fail to parse, or fail to render; it simply has no authoring + * surface. So the guard is mechanical, in the shape of the objectui#4302 + * package-door guard: scan `ObjectGrid.tsx` for the properties it actually + * reads off `exportOptions` (through the `schema.exportOptions` expression and + * through any local alias bound to it), scan `ListViewExportOptions` in + * `@object-ui/types` for the properties it declares, and fail if the renderer + * reads anything the type does not declare. + * + * Direction matters and is deliberate: read ⊆ declared. A DECLARED key with no + * reader is not failed here — that is capability surface with no consumer, + * caught on the type side by `objectql.exportOptions.test.ts`'s exact key-set + * assertion. What this file forbids is the objectstack#8010 shape specifically: + * a sixth key that the renderer honours and no author can legally write. + * + * Scope of the scan, stated rather than implied: it follows `schema.exportOptions` + * and identifiers assigned from it in the same file. A key read through a helper + * defined elsewhere, or through a dynamic `opts[name]`, would not be seen — + * which is why both sides carry a floor assertion, so a scanner that goes blind + * reds instead of passing on an empty set. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// packages/plugin-grid/src/__tests__ -> repo root +const repoRoot = path.resolve(here, '../../../..'); + +const GRID_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/ObjectGrid.tsx'); +const TYPES_SOURCE = path.join(repoRoot, 'packages/types/src/objectql.ts'); + +/** + * The spec's five keys (`ListViewExportOptionsSchema`, `@objectstack/spec` + * 17.0.0). Neither side of this test may be edited to make the other pass — + * both are compared against this list, so widening the contract means changing + * the spec first and this constant with it. + */ +const SPEC_KEYS = [ + 'formats', + 'maxRecords', + 'includeHeaders', + 'fileNamePrefix', + 'streaming', +] as const; + +/* ── Source scanning ─────────────────────────────────────────────────────── */ + +/** + * Strip line and block comments and string/template literals. + * + * Without this, the prose above `exportableFormats` — which names + * `schema.exportOptions.streaming` in a sentence — would be scanned as a read, + * and a comment could silence a real one. Character-by-character rather than by + * regex because `//` inside a string and a quote inside a comment each break + * the naive version, in opposite directions. + */ +function stripCommentsAndStrings(src: string): string { + let out = ''; + let i = 0; + while (i < src.length) { + const two = src.slice(i, i + 2); + if (two === '//') { + const nl = src.indexOf('\n', i); + i = nl === -1 ? src.length : nl; + continue; + } + if (two === '/*') { + const end = src.indexOf('*/', i + 2); + i = end === -1 ? src.length : end + 2; + continue; + } + const ch = src[i]; + if (ch === '"' || ch === "'" || ch === '`') { + i++; + while (i < src.length) { + if (src[i] === '\\') { i += 2; continue; } + if (src[i] === ch) { i++; break; } + i++; + } + out += ' '; + continue; + } + out += ch; + i++; + } + return out; +} + +/** + * Identifiers bound to `schema.exportOptions` in the same file, e.g. + * `const exportConfig = schema.exportOptions;`. The renderer reads through both + * spellings, so a scan that only followed `schema.exportOptions` would miss + * every key read off the alias — which is most of them. + * + * The trailing lookahead is load-bearing: without it + * `const declared = schema.exportOptions?.formats` binds `declared` as an alias + * of the OPTIONS, when it is really the format array — and every `declared.…` + * call downstream (`.filter`) is then scanned as an undeclared option key. The + * first run of this guard failed exactly that way, on `filter`. + */ +function exportOptionAliases(src: string): string[] { + const aliases: string[] = []; + const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*schema\s*\??\.\s*exportOptions\s*(?!\??\.)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src)) !== null) aliases.push(m[1]); + return aliases; +} + +/** + * Property names read off `exportOptions` (directly or via an alias). + * + * Tolerates the intervening forms the renderer may legally use — a wrapping + * paren, an `as T` assertion, `?.` — so that re-introducing a cast cannot hide + * a key from the scan. That matters: the `as any` this card deleted is exactly + * how `streaming` stayed invisible. + */ +function readKeys(src: string): Set { + const clean = stripCommentsAndStrings(src); + const roots = ['schema\\s*\\??\\.\\s*exportOptions', ...exportOptionAliases(clean).map((a) => `\\b${a}`)]; + const found = new Set(); + for (const root of roots) { + const re = new RegExp(`${root}\\s*(?:as\\s+[A-Za-z_$][\\w$<>\\[\\]., ]*)?\\s*\\)*\\s*\\??\\.\\s*([A-Za-z_$][\\w$]*)`, 'g'); + let m: RegExpExecArray | null; + while ((m = re.exec(clean)) !== null) found.add(m[1]); + } + return found; +} + +/** + * Property names declared by the `ListViewExportOptions` interface body. + * + * Read from the type's own source rather than restated here: a restated list is + * a third copy of the contract, and the copy is what drifts. + */ +function declaredKeys(src: string): Set { + const at = src.indexOf('export interface ListViewExportOptions'); + if (at === -1) return new Set(); + const open = src.indexOf('{', at); + let depth = 0; + let end = -1; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) { end = i; break; } + } + } + if (end === -1) return new Set(); + const body = stripCommentsAndStrings(src.slice(open + 1, end)); + const found = new Set(); + const re = /(?:^|;|\n)\s*([A-Za-z_$][\w$]*)\s*\??\s*:/g; + let m: RegExpExecArray | null; + while ((m = re.exec(body)) !== null) found.add(m[1]); + return found; +} + +const gridSource = readFileSync(GRID_SOURCE, 'utf8'); +const typesSource = readFileSync(TYPES_SOURCE, 'utf8'); + +describe('exportOptions — the renderer reads only what the spec declares (objectui#4535)', () => { + it('scans something: the alias and both sources are found', () => { + // Non-vacuity floor. Every assertion below is a subset check, and a subset + // check over an empty set passes for the worst possible reason. + expect(gridSource).toContain('schema.exportOptions'); + expect(exportOptionAliases(stripCommentsAndStrings(gridSource)).length).toBeGreaterThan(0); + expect(readKeys(gridSource).size).toBeGreaterThanOrEqual(4); + expect(declaredKeys(typesSource).size).toBe(SPEC_KEYS.length); + }); + + it('declares exactly the spec\'s five keys — no more, no fewer', () => { + expect([...declaredKeys(typesSource)].sort()).toEqual([...SPEC_KEYS].sort()); + }); + + it('reads no key the type does not declare', () => { + const declared = declaredKeys(typesSource); + const undeclared = [...readKeys(gridSource)].filter((k) => !declared.has(k)); + // Named rather than counted: a failure must say WHICH key went undeclared, + // because the fix is to declare it in the spec first — not to widen the + // local type and re-open objectstack#8010 from the other side. + expect(undeclared).toEqual([]); + }); + + it('still reads the keys the export menu depends on', () => { + // The other direction of the floor: silently dropping a read would leave + // the subset check green while the feature stopped working. + const read = readKeys(gridSource); + for (const key of ['formats', 'maxRecords', 'includeHeaders', 'fileNamePrefix', 'streaming']) { + expect(read.has(key)).toBe(true); + } + }); + + it('reads `streaming` without a cast — the key is declared now', () => { + // objectui#4535 item 3: the read went through `as any` for as long as no + // schema declared the key. A re-introduced cast here means the type and the + // reader have come apart again. + const clean = stripCommentsAndStrings(gridSource); + expect(clean).not.toMatch(/exportOptions\s+as\s+any/); + expect(clean).not.toMatch(/exportConfig\s+as\s+any/); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/exportGate.test.tsx b/packages/plugin-grid/src/__tests__/exportGate.test.tsx index f97670a82..2a6c0b7e9 100644 --- a/packages/plugin-grid/src/__tests__/exportGate.test.tsx +++ b/packages/plugin-grid/src/__tests__/exportGate.test.tsx @@ -56,11 +56,23 @@ describe('ObjectGrid export permission gate', () => { /** * Dead-format gate (objectui#2942): declared formats the runtime cannot * deliver must not render as menu items whose click silently does nothing. - * pdf is implemented nowhere; xlsx needs the server stream, which inline - * (provider: 'value') data never has. + * xlsx needs the server stream, which inline (provider: 'value') data never + * has; pdf is implemented nowhere. + * + * `'pdf'` is no longer AUTHORABLE (objectui#4535): it left the spec's format + * enum in @objectstack/spec 17.0.0 (objectstack#8010, after PDF export itself + * was declined as objectstack#1301 NOT_PLANNED), and the local type dropped it + * with the spec. These two cases therefore no longer pin a supported + * declaration — they pin the LEGACY one: metadata stored before the retirement + * still carries `'pdf'` until `os migrate meta --from 16` rewrites it, and such + * a value must keep reaching the user as "not in the menu" rather than as a + * dead menu item. The schema here is `any`, which is what a stored document + * arriving from the wire is, so the cases read the same after the retirement as + * before it — the format filter that drops them is format-agnostic and has no + * `'pdf'` branch to lose. */ describe('ObjectGrid export dead formats', () => { - it('drops pdf and (with inline data) xlsx from the menu, keeping csv', async () => { + it('drops a legacy pdf and (with inline data) xlsx from the menu, keeping csv', async () => { renderGrid({ exportOptions: { formats: ['csv', 'xlsx', 'pdf'] } }); fireEvent.click(screen.getByRole('button', { name: /export/i })); @@ -71,6 +83,8 @@ describe('ObjectGrid export dead formats', () => { }); it('hides the export button entirely when no declared format is deliverable', () => { + // A pre-17 document whose only format is the retired `'pdf'`: nothing is + // deliverable, so the toolbar offers no export at all. renderGrid({ exportOptions: { formats: ['pdf'] } }); expect(screen.queryAllByRole('button', { name: /export/i }).length).toBe(0); }); diff --git a/packages/types/src/__tests__/objectql.exportOptions.test.ts b/packages/types/src/__tests__/objectql.exportOptions.test.ts new file mode 100644 index 000000000..65edecfce --- /dev/null +++ b/packages/types/src/__tests__/objectql.exportOptions.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `exportOptions` is the spec's object form, and only that (objectui#4535). + * + * The card behind this file: `ObjectGridSchema.exportOptions` carried four keys + * under a comment claiming alignment with `@objectstack/spec`'s + * `ListViewSchema.exportOptions`, which at the time was a bare format ARRAY — + * so the comment was false in both directions, and the `streaming` key the + * renderer honoured appeared in no declaration at all. objectstack#8010 closed + * that upstream by declaring `ListViewExportOptionsSchema` with exactly the five + * keys the renderer reads; this file pins the local half to those five. + * + * The assertions are mostly type-level on purpose. `keyof` comparisons run in + * `tsc -p tsconfig.test.json` (part of this package's `type-check`), so a sixth + * key added to the interface fails the build rather than waiting for a reviewer + * to notice — which is the failure mode objectstack#8010 was reported for. + */ + +import { describe, it, expect } from 'vitest'; +import type { ListViewExportFormat, ListViewExportOptions, NamedListView, ObjectGridSchema } from '../index'; + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/* ── The key set is the spec's five ──────────────────────────────────────── */ + +/** + * `ListViewExportOptionsSchema` in `@objectstack/spec` 17.0.0 + * (objectstack#8010 / objectstack#8324) declares exactly these. Adding a key + * here to make a new local read compile is the defect, not the fix: the key has + * to exist in the spec first, or the platform refuses metadata that declares it. + */ +type SpecDeclaredKeys = 'formats' | 'maxRecords' | 'includeHeaders' | 'fileNamePrefix' | 'streaming'; + +type _KeysAreExactlyTheSpecFive = Expect< Equal< keyof ListViewExportOptions, SpecDeclaredKeys > >; + +/** The grid's `exportOptions` IS that type — not a lookalike that can drift. */ +type _GridUsesTheSharedType = Expect< + Equal< NonNullable< ObjectGridSchema['exportOptions'] >, ListViewExportOptions > +>; + +/** So does a saved named view, so one authoring surface cannot outgrow the other. */ +type _NamedViewUsesTheSharedType = Expect< + Equal< NonNullable< NamedListView['exportOptions'] >, ListViewExportOptions > +>; + +/* ── `'pdf'` is retired ──────────────────────────────────────────────────── */ + +/** + * The surviving formats. PDF export was declined platform-side + * (objectstack#1301 NOT_PLANNED) and the value left the spec enum in 17.0.0 + * (objectstack#8010), where declaring it is now a parse-time refusal carrying + * `os migrate meta --from 16`. + */ +type _FormatsAreCsvXlsxJson = Expect< Equal< ListViewExportFormat, 'csv' | 'xlsx' | 'json' > >; + +describe('exportOptions — the spec object form (objectui#4535)', () => { + it('accepts the five spec keys', () => { + const options: ListViewExportOptions = { + formats: ['csv', 'xlsx', 'json'], + maxRecords: 5000, + includeHeaders: false, + fileNamePrefix: 'contracts', + streaming: false, + }; + // Runtime half: the type-level assertions above are erased, so without a + // value that actually carries all five keys, a rename would still compile. + expect(Object.keys(options).sort()).toEqual( + ['fileNamePrefix', 'formats', 'includeHeaders', 'maxRecords', 'streaming'], + ); + }); + + it('refuses a retired `pdf` format', () => { + const options: ListViewExportOptions = { + // @ts-expect-error 'pdf' left the spec's format enum in @objectstack/spec + // 17.0.0 (objectstack#8010; PDF export declined as objectstack#1301 + // NOT_PLANNED). Restoring it to the union makes this directive unused and + // reds the type-check — which is the point: the value must not come back + // locally while the platform refuses it at publish. + formats: ['csv', 'pdf'], + }; + expect(options.formats).toEqual(['csv', 'pdf']); + }); + + it('refuses a key the spec does not declare', () => { + const options: ListViewExportOptions = { + // @ts-expect-error A sixth key is capability surface with no reader and no + // authoring surface — objectstack#8010 was filed for exactly that shape. + compression: 'gzip', + }; + expect(options).toBeDefined(); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5f4c544c4..9bd232db9 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -361,6 +361,8 @@ export type { KanbanNativeConditionalFormattingRule, ObjectChartSchema, ListViewSchema, + ListViewExportFormat, + ListViewExportOptions, ObjectGridSchema, ObjectFormSchema, ObjectFormSection, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index cc9b7dbc6..84754dc84 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -456,6 +456,66 @@ export interface BulkActionDef { actionDef?: Record; } +/** + * Export formats a list view may offer (objectui#4535). + * + * `'pdf'` is NOT here: PDF export was declined platform-side + * (objectstack#1301 NOT_PLANNED) and the value left the spec's format enum in + * `@objectstack/spec` 17.0.0 (objectstack#8010), where declaring it is now a + * parse-time refusal carrying a migration prescription. It was never + * renderable on this side either — no ObjectUI export path has ever produced a + * PDF, so a declared `'pdf'` only ever reached the user as a console line. + * `'xlsx'` is delivered by the server stream alone; the client fallback path + * produces `'csv'` and `'json'`. + */ +export type ListViewExportFormat = 'csv' | 'xlsx' | 'json'; + +/** + * Export options for a list view — the object form of `exportOptions`. + * + * **This key set is the spec's, and it is exactly what `ObjectGrid` reads.** + * It mirrors `ListViewExportOptionsSchema` in `@objectstack/spec` 17.0.0 + * (`packages/spec/src/ui/view.zod.ts`, added by objectstack#8010 / + * objectstack#8324): `formats`, `maxRecords`, `includeHeaders`, + * `fileNamePrefix`, `streaming`. Upstream derived those five keys FROM this + * renderer's reads, so the two are one contract read from either end: + * + * - a sixth key declared here is capability surface with no reader — the + * compile-time key-set assertion in `objectql.exportOptions.test.ts` reds; + * - a sixth key READ by `ObjectGrid` without being declared here recreates the + * undeclared-but-read defect objectstack#8010 closed — the source scan in + * `plugin-grid`'s `ObjectGrid.exportOptionsKeys.test.ts` reds. + * + * NOTE (objectui#4535): this restates the five keys rather than deriving them + * from the spec symbol, because objectui still pins + * `@objectstack/spec@17.0.0-rc.6`, whose `ListView.exportOptions` is the LEGACY + * bare format array (`('csv' | 'xlsx' | 'json' | 'pdf')[]`) — the object form is + * not importable from the pin yet. Deriving this type from the published spec + * symbol becomes possible when the pin bumps; the shape below IS the new spec + * shape, so nothing here changes when it does. + */ +export interface ListViewExportOptions { + /** + * Formats offered in the export menu (default: `['csv', 'json']`). + * XLSX is delivered by the server stream only. + */ + formats?: ListViewExportFormat[]; + /** Maximum number of records to export; 0 or absent = unlimited. */ + maxRecords?: number; + /** Include column headers in the exported file (default true). */ + includeHeaders?: boolean; + /** + * Download file name prefix — replaces the object label and suppresses the + * view label in the generated file name. + */ + fileNamePrefix?: string; + /** + * Set false to force the client-side export path (csv/json only) instead of + * the server stream. + */ + streaming?: boolean; +} + /** * ObjectGrid Schema * A specialized grid component that automatically fetches and displays data from ObjectQL objects. @@ -779,19 +839,10 @@ export interface ObjectGridSchema extends BaseSchema { /** * Export options configuration for exporting grid data. - * Supports csv, xlsx, json, and pdf formats. - * Aligned with @objectstack/spec ListViewSchema.exportOptions. - */ - exportOptions?: { - /** Formats available for export */ - formats?: Array<'csv' | 'xlsx' | 'json' | 'pdf'>; - /** Maximum number of records to export (0 = unlimited) */ - maxRecords?: number; - /** Include column headers in export */ - includeHeaders?: boolean; - /** Custom file name prefix */ - fileNamePrefix?: string; - }; + * See {@link ListViewExportOptions} — the key set is the spec's, and the one + * that `ObjectGrid` reads. + */ + exportOptions?: ListViewExportOptions; /** * Navigation configuration for row click behavior. @@ -1649,13 +1700,12 @@ export interface NamedListView { /** Fields to hide from the current view */ hiddenFields?: string[]; - /** Export options configuration */ - exportOptions?: { - formats?: Array<'csv' | 'xlsx' | 'json' | 'pdf'>; - maxRecords?: number; - includeHeaders?: boolean; - fileNamePrefix?: string; - }; + /** + * Export options configuration — the same object form the grid reads, so a + * saved view and a directly-authored grid cannot declare different export + * surfaces (objectui#4535). See {@link ListViewExportOptions}. + */ + exportOptions?: ListViewExportOptions; /** Row action identifiers */ rowActions?: string[];