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
20 changes: 20 additions & 0 deletions .changeset/export-options-spec-object-form-4535.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 14 additions & 6 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1688,17 +1688,25 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
}, [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]);
Expand All @@ -1711,7 +1719,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
}
}, [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
Expand Down Expand Up @@ -1741,7 +1749,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
&& !!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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> {
const clean = stripCommentsAndStrings(src);
const roots = ['schema\\s*\\??\\.\\s*exportOptions', ...exportOptionAliases(clean).map((a) => `\\b${a}`)];
const found = new Set<string>();
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<string> {
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<string>();
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/);
});
});
20 changes: 17 additions & 3 deletions packages/plugin-grid/src/__tests__/exportGate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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);
});
Expand Down
Loading
Loading