Skip to content

Commit ea99466

Browse files
baozhoutaoclaude
andauthored
fix(plugin-list,plugin-grid): drop undeliverable formats from the export menu (#2999)
The export popover rendered whatever exportOptions.formats declared, but the runtime can only deliver a subset: the server stream handles csv/xlsx/json and the client fallback only csv/json. A declared 'pdf' (never implemented anywhere — declined platform-side in objectstack#1301) rendered as a menu item whose click silently did nothing: no request, no file, popover just closed. Same for 'xlsx' whenever the server stream is unavailable (no exportDownload on the data source, inline data, or streaming: false). Tracked as the export row of the #2942 dead-value audit. Filter the menu to formats the current data source can actually produce, hide the export button entirely when nothing survives, and console.warn the dropped declarations so app authors find out at dev time instead of via a dead button. Closes the export row of #2942. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bbdb498 commit ea99466

4 files changed

Lines changed: 140 additions & 6 deletions

File tree

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,30 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12341234
return generatedColumns;
12351235
}, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms]);
12361236

1237+
// Formats this grid can actually deliver (objectui#2942): the server stream
1238+
// handles csv/xlsx/json, the client fallback only csv/json, and pdf exists
1239+
// nowhere (declined platform-side — objectstack#1301). Declared-but-dead
1240+
// formats used to render as menu items whose click did nothing; now they're
1241+
// dropped from the menu (with a one-time warning for the app author).
1242+
// (Hoisted above the error/loading early returns to satisfy hooks rules.)
1243+
const exportableFormats = useMemo(() => {
1244+
const declared = schema.exportOptions?.formats || ['csv', 'json'];
1245+
const serverAvailable = typeof dataSource?.exportDownload === 'function'
1246+
&& !!objectName
1247+
&& !hasInlineData
1248+
&& (schema.exportOptions as any)?.streaming !== false;
1249+
const supported = serverAvailable ? ['csv', 'xlsx', 'json'] : ['csv', 'json'];
1250+
return declared.filter((f: string) => supported.includes(f));
1251+
}, [schema.exportOptions, dataSource, objectName, hasInlineData]);
1252+
useEffect(() => {
1253+
const declared = schema.exportOptions?.formats;
1254+
if (!declared) return;
1255+
const dropped = declared.filter((f) => !exportableFormats.includes(f));
1256+
if (dropped.length > 0) {
1257+
console.warn(`[ObjectUI] ObjectGrid export: unsupported format(s) hidden from the menu: ${dropped.join(', ')}`);
1258+
}
1259+
}, [schema.exportOptions, exportableFormats]);
1260+
12371261
const handleExport = useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
12381262
// Object-level export permission gate. Default-allow: an explicit
12391263
// `operations.export === false` blocks it, and — when the server hands down
@@ -2299,6 +2323,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
22992323
// and it excludes `export`, the button is hidden. Missing set → unchanged.
23002324
const exportEnabled =
23012325
!!schema.exportOptions &&
2326+
exportableFormats.length > 0 &&
23022327
schema.operations?.export !== false &&
23032328
(effectiveApiOps ? effectiveApiOps.includes('export') : true);
23042329
const hasToolbar = exportEnabled || showRowHeightToggle;
@@ -2333,7 +2358,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
23332358
</PopoverTrigger>
23342359
<PopoverContent align="end" className="w-48 p-2">
23352360
<div className="space-y-1">
2336-
{(schema.exportOptions?.formats || ['csv', 'json']).map(format => (
2361+
{exportableFormats.map(format => (
23372362
<Button
23382363
key={format}
23392364
variant="ghost"

packages/plugin-grid/src/__tests__/exportGate.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* is hidden; default-allow keeps it visible when the key is omitted.
66
*/
77
import { describe, it, expect } from 'vitest';
8-
import { render, screen } from '@testing-library/react';
8+
import { render, screen, fireEvent } from '@testing-library/react';
99
import '@testing-library/jest-dom';
1010
import React from 'react';
1111

@@ -52,3 +52,26 @@ describe('ObjectGrid export permission gate', () => {
5252
expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument();
5353
});
5454
});
55+
56+
/**
57+
* Dead-format gate (objectui#2942): declared formats the runtime cannot
58+
* deliver must not render as menu items whose click silently does nothing.
59+
* pdf is implemented nowhere; xlsx needs the server stream, which inline
60+
* (provider: 'value') data never has.
61+
*/
62+
describe('ObjectGrid export dead formats', () => {
63+
it('drops pdf and (with inline data) xlsx from the menu, keeping csv', async () => {
64+
renderGrid({ exportOptions: { formats: ['csv', 'xlsx', 'pdf'] } });
65+
66+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
67+
68+
expect(await screen.findByRole('button', { name: /export as csv/i })).toBeInTheDocument();
69+
expect(screen.queryByRole('button', { name: /export as pdf/i })).toBeNull();
70+
expect(screen.queryByRole('button', { name: /export as xlsx/i })).toBeNull();
71+
});
72+
73+
it('hides the export button entirely when no declared format is deliverable', () => {
74+
renderGrid({ exportOptions: { formats: ['pdf'] } });
75+
expect(screen.queryAllByRole('button', { name: /export/i }).length).toBe(0);
76+
});
77+
});

packages/plugin-list/src/ListView.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,28 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
745745
return schema.exportOptions;
746746
}, [schema.exportOptions]);
747747

748+
// Formats this list can actually deliver (objectui#2942): the server stream
749+
// handles csv/xlsx/json, the client fallback only csv/json, and pdf exists
750+
// nowhere (declined platform-side — objectstack#1301). Declared-but-dead
751+
// formats used to render as menu items whose click did nothing; now they're
752+
// dropped from the menu (with a one-time warning for the app author).
753+
const exportableFormats = React.useMemo(() => {
754+
const declared = resolvedExportOptions?.formats || ['csv', 'json'];
755+
const serverAvailable = typeof dataSource?.exportDownload === 'function'
756+
&& !!schema.objectName
757+
&& (resolvedExportOptions as any)?.streaming !== false;
758+
const supported = serverAvailable ? ['csv', 'xlsx', 'json'] : ['csv', 'json'];
759+
return declared.filter((f: string) => supported.includes(f));
760+
}, [resolvedExportOptions, dataSource, schema.objectName]);
761+
React.useEffect(() => {
762+
const declared = resolvedExportOptions?.formats;
763+
if (!declared) return;
764+
const dropped = declared.filter((f: string) => !exportableFormats.includes(f));
765+
if (dropped.length > 0) {
766+
console.warn(`[ObjectUI] ListView export: unsupported format(s) hidden from the menu: ${dropped.join(', ')}`);
767+
}
768+
}, [resolvedExportOptions, exportableFormats]);
769+
748770
// Toolbar density, resolved from the spec-canonical `rowHeight` (#2890). The
749771
// legacy `densityMode` is folded into it by `normalizeListViewSchema` above —
750772
// it used to be read FIRST here, so a view carrying both rendered the legacy
@@ -2237,7 +2259,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
22372259
)}
22382260

22392261
{/* --- Separator: Appearance | Export --- */}
2240-
{(toolbarFlags.showColor || toolbarFlags.showDensity || toolbarFlags.compactToolbar) && resolvedExportOptions && exportPermitted && (
2262+
{(toolbarFlags.showColor || toolbarFlags.showDensity || toolbarFlags.compactToolbar) && resolvedExportOptions && exportPermitted && exportableFormats.length > 0 && (
22412263
<div className="h-5 w-px bg-border/50 mx-1 shrink-0" />
22422264
)}
22432265

@@ -2261,7 +2283,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
22612283
)}
22622284

22632285
{/* Export */}
2264-
{resolvedExportOptions && exportPermitted && (
2286+
{resolvedExportOptions && exportPermitted && exportableFormats.length > 0 && (
22652287
<Popover open={showExport} onOpenChange={setShowExport}>
22662288
<PopoverTrigger asChild>
22672289
<Button
@@ -2275,7 +2297,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
22752297
</PopoverTrigger>
22762298
<PopoverContent align="start" className="w-48 p-2">
22772299
<div className="space-y-1">
2278-
{(resolvedExportOptions.formats || ['csv', 'json']).map((format: any) => (
2300+
{exportableFormats.map((format: any) => (
22792301
<Button
22802302
key={format}
22812303
variant="ghost"
@@ -2334,7 +2356,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
23342356

23352357
{/* --- Separator: Print/Share/Export | Search --- */}
23362358
{(() => {
2337-
const hasLeftSideItems = schema.allowPrinting || !!schema.sharing?.type || (resolvedExportOptions && exportPermitted);
2359+
const hasLeftSideItems = schema.allowPrinting || !!schema.sharing?.type || (resolvedExportOptions && exportPermitted && exportableFormats.length > 0);
23382360
return toolbarFlags.showSearch && hasLeftSideItems ? (
23392361
<div className="h-5 w-px bg-border/50 mx-1 shrink-0" />
23402362
) : null;

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,6 +2164,70 @@ describe('ListView', () => {
21642164
});
21652165
});
21662166

2167+
// Declared formats the runtime cannot deliver (objectui#2942): pdf has no
2168+
// implementation anywhere, and xlsx only exists on the server stream. They
2169+
// must not render as menu items whose click silently does nothing.
2170+
describe('exportOptions dead formats', () => {
2171+
const serverDs: any = {
2172+
find: vi.fn().mockResolvedValue([]),
2173+
findOne: vi.fn(),
2174+
create: vi.fn(),
2175+
update: vi.fn(),
2176+
delete: vi.fn(),
2177+
exportDownload: vi.fn(),
2178+
};
2179+
2180+
it('hides pdf from the export menu even when declared', async () => {
2181+
const schema: ListViewSchema = {
2182+
type: 'list-view',
2183+
objectName: 'contacts',
2184+
viewType: 'grid',
2185+
fields: ['name', 'email'],
2186+
exportOptions: { formats: ['xlsx', 'pdf'] as any },
2187+
};
2188+
2189+
render(
2190+
<SchemaRendererProvider dataSource={serverDs}>
2191+
<ListView schema={schema} dataSource={serverDs} />
2192+
</SchemaRendererProvider>
2193+
);
2194+
2195+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
2196+
expect(await screen.findByRole('button', { name: /export as xlsx/i })).toBeInTheDocument();
2197+
expect(screen.queryByRole('button', { name: /export as pdf/i })).toBeNull();
2198+
});
2199+
2200+
it('hides xlsx when the data source has no server export stream', async () => {
2201+
const schema: ListViewSchema = {
2202+
type: 'list-view',
2203+
objectName: 'contacts',
2204+
viewType: 'grid',
2205+
fields: ['name', 'email'],
2206+
exportOptions: { formats: ['csv', 'xlsx'] },
2207+
};
2208+
2209+
renderWithProvider(<ListView schema={schema} />);
2210+
2211+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
2212+
expect(await screen.findByRole('button', { name: /export as csv/i })).toBeInTheDocument();
2213+
expect(screen.queryByRole('button', { name: /export as xlsx/i })).toBeNull();
2214+
});
2215+
2216+
it('hides the export button entirely when no declared format is deliverable', () => {
2217+
const schema: ListViewSchema = {
2218+
type: 'list-view',
2219+
objectName: 'contacts',
2220+
viewType: 'grid',
2221+
fields: ['name', 'email'],
2222+
exportOptions: { formats: ['pdf'] as any },
2223+
};
2224+
2225+
renderWithProvider(<ListView schema={schema} />);
2226+
2227+
expect(screen.queryAllByRole('button', { name: /export/i }).length).toBe(0);
2228+
});
2229+
});
2230+
21672231
// ============================
21682232
// conditionalFormatting spec format
21692233
// ============================

0 commit comments

Comments
 (0)