Skip to content

Commit de6f8e2

Browse files
authored
feat(list): server-side export (csv/xlsx/json) with export button + permission gate (#2114)
1 parent 22bb4f3 commit de6f8e2

10 files changed

Lines changed: 675 additions & 44 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { ObjectStackAdapter } from './index';
11+
12+
/** Build an adapter whose fetch is a spy returning the given Response. */
13+
function makeDS(fetchImpl: any) {
14+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
15+
ds.connected = true;
16+
ds.connectionState = 'connected';
17+
return ds;
18+
}
19+
20+
function csvResponse(body = 'ID,Name\n1,Acme') {
21+
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/csv' } });
22+
}
23+
24+
describe('ObjectStackAdapter.exportDownload', () => {
25+
it('GETs the /export route with format, fields, orderby, header and limit', async () => {
26+
const fetchImpl = vi.fn(async () => csvResponse());
27+
const ds = makeDS(fetchImpl);
28+
29+
const blob = await ds.exportDownload('task', {
30+
format: 'xlsx',
31+
fields: ['title', 'owner'],
32+
sort: [{ field: 'title', direction: 'desc' }, { field: 'owner' }],
33+
includeHeaders: false,
34+
limit: 5000,
35+
});
36+
37+
expect(blob).toBeInstanceOf(Blob);
38+
expect(fetchImpl).toHaveBeenCalledTimes(1);
39+
const [url, init] = fetchImpl.mock.calls[0];
40+
const parsed = new URL(url);
41+
expect(parsed.pathname).toBe('/api/v1/data/task/export');
42+
expect(parsed.searchParams.get('format')).toBe('xlsx');
43+
expect(parsed.searchParams.get('fields')).toBe('title,owner');
44+
// direction defaults to asc when omitted.
45+
expect(parsed.searchParams.get('orderby')).toBe('title:desc,owner:asc');
46+
expect(parsed.searchParams.get('header')).toBe('false');
47+
expect(parsed.searchParams.get('limit')).toBe('5000');
48+
expect(init.method).toBe('GET');
49+
expect(init.credentials).toBe('include');
50+
});
51+
52+
it('defaults to csv and omits optional params when not provided', async () => {
53+
const fetchImpl = vi.fn(async () => csvResponse());
54+
const ds = makeDS(fetchImpl);
55+
56+
await ds.exportDownload('task', {});
57+
58+
const [url] = fetchImpl.mock.calls[0];
59+
const parsed = new URL(url);
60+
expect(parsed.searchParams.get('format')).toBe('csv');
61+
expect(parsed.searchParams.get('fields')).toBeNull();
62+
expect(parsed.searchParams.get('orderby')).toBeNull();
63+
expect(parsed.searchParams.get('header')).toBeNull();
64+
expect(parsed.searchParams.get('limit')).toBeNull();
65+
});
66+
67+
it('serializes the filter to a JSON AST query param', async () => {
68+
const fetchImpl = vi.fn(async () => csvResponse());
69+
const ds = makeDS(fetchImpl);
70+
71+
await ds.exportDownload('task', { filter: [['status', '=', 'open']] });
72+
73+
const [url] = fetchImpl.mock.calls[0];
74+
const raw = new URL(url).searchParams.get('filter');
75+
expect(raw).toBeTruthy();
76+
expect(JSON.parse(raw!)).toEqual([['status', '=', 'open']]);
77+
});
78+
79+
it('throws an error carrying the server message and status on failure', async () => {
80+
const fetchImpl = vi.fn(async () =>
81+
new Response(JSON.stringify({ error: { message: 'Permission denied' } }), {
82+
status: 403,
83+
headers: { 'Content-Type': 'application/json' },
84+
}),
85+
);
86+
const ds = makeDS(fetchImpl);
87+
88+
await expect(ds.exportDownload('task', { format: 'csv' })).rejects.toMatchObject({
89+
message: 'Permission denied',
90+
status: 403,
91+
});
92+
});
93+
});

packages/data-objectstack/src/index.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
10-
import type { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
10+
import type { DataSource, QueryParams, QueryResult, FileUploadResult, ExportDownloadRequest } from '@object-ui/types';
1111
import { convertFiltersToAST } from '@object-ui/core';
1212
import { MetadataCache } from './cache/MetadataCache';
1313
import {
@@ -1182,6 +1182,66 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
11821182
return body;
11831183
}
11841184

1185+
/**
1186+
* Synchronously download a server-streamed export (csv / json / xlsx).
1187+
*
1188+
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
1189+
* requested format, formats values for readability (lookup → name, select →
1190+
* label, boolean → 是/否, dates formatted) and enforces permissions. The
1191+
* filter / sort are translated the same way as `rawFindWithPopulate` so the
1192+
* exported file mirrors the active list view. Returns the file as a Blob;
1193+
* the caller triggers the browser download.
1194+
*/
1195+
async exportDownload(resource: string, request: ExportDownloadRequest = {}): Promise<Blob> {
1196+
const queryParams = new URLSearchParams();
1197+
1198+
const format = request.format === 'xlsx' ? 'xlsx' : request.format === 'json' ? 'json' : 'csv';
1199+
queryParams.set('format', format);
1200+
1201+
if (request.fields && request.fields.length > 0) {
1202+
queryParams.set('fields', request.fields.join(','));
1203+
}
1204+
if (request.limit && request.limit > 0) {
1205+
queryParams.set('limit', String(request.limit));
1206+
}
1207+
if (request.includeHeaders === false) {
1208+
queryParams.set('header', 'false');
1209+
}
1210+
// Sort → server `orderby` shorthand: "field:dir,field2:dir".
1211+
if (request.sort && request.sort.length > 0) {
1212+
const orderby = request.sort
1213+
.filter(s => s && s.field)
1214+
.map(s => `${s.field}:${s.direction === 'desc' ? 'desc' : 'asc'}`)
1215+
.join(',');
1216+
if (orderby) queryParams.set('orderby', orderby);
1217+
}
1218+
// Filter → AST tuples, same translation the list GET path uses.
1219+
if (request.filter !== undefined && request.filter !== null) {
1220+
const translated = translateFilterToAST(request.filter);
1221+
if (translated !== undefined) {
1222+
queryParams.set('filter', JSON.stringify(translated));
1223+
}
1224+
}
1225+
1226+
const baseUrl = this.baseUrl.replace(/\/$/, '');
1227+
// Avoid doubling /api/v1 if baseUrl already includes the version suffix.
1228+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
1229+
const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
1230+
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
1231+
1232+
const headers: Record<string, string> = { ...this.getAuthHeaders() };
1233+
// `credentials: 'include'` carries the session cookie for the browser
1234+
// console (which authenticates by cookie, not a bearer token).
1235+
const res = await this.fetchImpl(url, { method: 'GET', headers, credentials: 'include' });
1236+
if (!res.ok) {
1237+
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
1238+
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
1239+
err.status = res.status;
1240+
throw err;
1241+
}
1242+
return await res.blob();
1243+
}
1244+
11851245
/**
11861246
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
11871247
* Maps OData-style conventions to ObjectStack conventions.

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ import { stateMachineNextValues } from './inline-edit-options';
3131
import {
3232
Badge, Button, NavigationOverlay, EmptyValue,
3333
Popover, PopoverContent, PopoverTrigger,
34-
ExportProgressDialog, useExportJob, RefreshIndicator,
34+
RefreshIndicator,
3535
} from '@object-ui/components';
3636
import { usePullToRefresh } from '@object-ui/mobile';
3737
import { evaluatePlainCondition, buildExpandFields } from '@object-ui/core';
38-
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
38+
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
3939
import { useRowColor } from './useRowColor';
4040
import { useGroupedData } from './useGroupedData';
4141
import { GroupRow } from './GroupRow';
@@ -238,8 +238,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
238238
const [useCardView, setUseCardView] = useState(false);
239239
const [refreshKey, setRefreshKey] = useState(0);
240240
const [showExport, setShowExport] = useState(false);
241-
const [exportDialogOpen, setExportDialogOpen] = useState(false);
242-
const exportJob = useExportJob({ dataSource });
241+
const [exportBusy, setExportBusy] = useState(false);
242+
const [exportError, setExportError] = useState<string | null>(null);
243243
const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'compact');
244244
const [selectedRows, setSelectedRows] = useState<any[]>([]);
245245
const [selectAllMatching, setSelectAllMatching] = useState(false);
@@ -1221,32 +1221,67 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12211221
}, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName]);
12221222

12231223
const handleExport = useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
1224+
// Object-level export permission gate. Default-allow: only an explicit
1225+
// `operations.export === false` blocks the export.
1226+
if (schema.operations?.export === false) return;
12241227
const exportConfig = schema.exportOptions;
12251228
const maxRecords = exportConfig?.maxRecords || 0;
12261229
const includeHeaders = exportConfig?.includeHeaders !== false;
12271230
const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export';
12281231

1229-
// Async streaming path — use spec v4 createExportJob when the data source
1230-
// supports it (and the format is something the server can stream).
1231-
const asyncEligible = format === 'csv' || format === 'xlsx' || format === 'json';
1232-
const useAsync = asyncEligible
1233-
&& exportJob.isSupported
1234-
&& schema.objectName
1232+
// Server-streamed path: csv / xlsx / json via dataSource.exportDownload.
1233+
// XLSX is server-only; type-aware value formatting, field resolution and
1234+
// permission enforcement all happen server-side. Mirrors the grid's
1235+
// configured filter + sort so the exported file matches what's shown.
1236+
const serverEligible = (format === 'csv' || format === 'xlsx' || format === 'json')
1237+
&& typeof dataSource?.exportDownload === 'function'
1238+
&& !!objectName
12351239
&& !hasInlineData
12361240
// Honor an opt-out: schema.exportOptions.streaming === false forces client-side.
12371241
&& (exportConfig as any)?.streaming !== false;
12381242

1239-
if (useAsync) {
1243+
if (serverEligible) {
12401244
const cols = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
12411245
const fields = cols.map((c: any) => c.accessorKey).filter(Boolean);
1242-
setShowExport(false);
1243-
setExportDialogOpen(true);
1244-
void exportJob.start(schema.objectName!, {
1245-
format: format === 'json' ? 'json' : (format as 'csv' | 'xlsx'),
1246-
fields: fields.length ? fields : undefined,
1247-
includeHeaders,
1248-
limit: maxRecords > 0 ? maxRecords : undefined,
1249-
});
1246+
1247+
const filter = Array.isArray(schemaFilter) ? schemaFilter : undefined;
1248+
const sort = Array.isArray(schemaSort)
1249+
? schemaSort
1250+
.filter((s: any) => s && s.field)
1251+
.map((s: any) => ({ field: s.field, direction: (s.order as 'asc' | 'desc') ?? 'asc' }))
1252+
: undefined;
1253+
1254+
setExportError(null);
1255+
setExportBusy(true);
1256+
void (async () => {
1257+
try {
1258+
const blob = await dataSource!.exportDownload!(objectName!, {
1259+
format: format as 'csv' | 'xlsx' | 'json',
1260+
fields: fields.length ? fields : undefined,
1261+
filter,
1262+
sort,
1263+
includeHeaders,
1264+
limit: maxRecords > 0 ? maxRecords : undefined,
1265+
});
1266+
const url = URL.createObjectURL(blob);
1267+
const a = document.createElement('a');
1268+
a.href = url;
1269+
a.download = `${prefix}.${format}`;
1270+
a.rel = 'noopener';
1271+
document.body.appendChild(a);
1272+
a.click();
1273+
a.remove();
1274+
URL.revokeObjectURL(url);
1275+
setShowExport(false);
1276+
} catch (err) {
1277+
// Surface the failure instead of swallowing it (e.g. permission denied
1278+
// or a server error) — the toolbar shows the message.
1279+
console.error('ObjectGrid export failed:', err);
1280+
setExportError(err instanceof Error ? err.message : String(err));
1281+
} finally {
1282+
setExportBusy(false);
1283+
}
1284+
})();
12501285
return;
12511286
}
12521287

@@ -1285,7 +1320,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12851320
downloadFile(new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }), `${prefix}.json`);
12861321
}
12871322
setShowExport(false);
1288-
}, [data, schema.exportOptions, schema.objectName, generateColumns, exportJob, hasInlineData]);
1323+
}, [data, schema.exportOptions, schema.operations?.export, schema.objectName, objectName, generateColumns, dataSource, hasInlineData, schemaFilter, schemaSort]);
12891324

12901325
if (error) {
12911326
return (
@@ -2056,7 +2091,9 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
20562091
// Hide row-height toggle when parent (e.g., ListView) controls density externally,
20572092
// signaled by `hideRowHeightToggle` prop on schema.
20582093
const showRowHeightToggle = schema.rowHeight !== undefined && !(schema as any).hideRowHeightToggle;
2059-
const hasToolbar = schema.exportOptions || showRowHeightToggle;
2094+
// Export is offered only when configured AND not blocked by object-level perms.
2095+
const exportEnabled = !!schema.exportOptions && schema.operations?.export !== false;
2096+
const hasToolbar = exportEnabled || showRowHeightToggle;
20602097
const gridToolbar = hasToolbar ? (
20612098
<div className="flex items-center justify-end gap-1 px-2 py-1">
20622099
{/* Row height toggle */}
@@ -2074,7 +2111,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
20742111
)}
20752112

20762113
{/* Export */}
2077-
{schema.exportOptions && (
2114+
{exportEnabled && (
20782115
<Popover open={showExport} onOpenChange={setShowExport}>
20792116
<PopoverTrigger asChild>
20802117
<Button
@@ -2088,18 +2125,30 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
20882125
</PopoverTrigger>
20892126
<PopoverContent align="end" className="w-48 p-2">
20902127
<div className="space-y-1">
2091-
{(schema.exportOptions.formats || ['csv', 'json']).map(format => (
2128+
{(schema.exportOptions?.formats || ['csv', 'json']).map(format => (
20922129
<Button
20932130
key={format}
20942131
variant="ghost"
20952132
size="sm"
20962133
className="w-full justify-start h-8 text-xs"
2134+
disabled={exportBusy}
20972135
onClick={() => handleExport(format)}
20982136
>
2099-
<Download className="h-3.5 w-3.5 mr-2" />
2137+
{exportBusy
2138+
? <Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
2139+
: <Download className="h-3.5 w-3.5 mr-2" />}
21002140
{t('grid.exportAs', { format: format.toUpperCase() })}
21012141
</Button>
21022142
))}
2143+
{exportError && (
2144+
<div
2145+
className="px-2 py-1 text-xs"
2146+
style={{ color: 'var(--destructive, #ef4444)' }}
2147+
role="alert"
2148+
>
2149+
{exportError}
2150+
</div>
2151+
)}
21032152
</div>
21042153
</PopoverContent>
21052154
</Popover>
@@ -2325,17 +2374,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
23252374
</>
23262375
);
23272376

2328-
// Shared async-export progress dialog (used by both render paths).
2329-
const exportProgressDialog = (
2330-
<ExportProgressDialog
2331-
open={exportDialogOpen}
2332-
onOpenChange={setExportDialogOpen}
2333-
job={exportJob}
2334-
filename={`${schema.exportOptions?.fileNamePrefix || schema.objectName || 'export'}.${exportJob.progress?.format || 'csv'}`}
2335-
closeAfterDownloadMs={400}
2336-
/>
2337-
);
2338-
23392377
// Rendered BulkActionDialog (shared across both render branches).
23402378
const bulkDialog = (
23412379
<BulkActionDialog
@@ -2376,7 +2414,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
23762414
>
23772415
{(record) => renderRecordDetail(record)}
23782416
</NavigationOverlay>
2379-
{exportProgressDialog}
23802417
{bulkDialog}
23812418
</>
23822419
);
@@ -2417,7 +2454,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
24172454
{(record) => renderRecordDetail(record)}
24182455
</NavigationOverlay>
24192456
)}
2420-
{exportProgressDialog}
24212457
{bulkDialog}
24222458
</div>
24232459
);

0 commit comments

Comments
 (0)