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
93 changes: 93 additions & 0 deletions packages/data-objectstack/src/exportDownload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackAdapter } from './index';

/** Build an adapter whose fetch is a spy returning the given Response. */
function makeDS(fetchImpl: any) {
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
ds.connected = true;
ds.connectionState = 'connected';
return ds;
}

function csvResponse(body = 'ID,Name\n1,Acme') {
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/csv' } });
}

describe('ObjectStackAdapter.exportDownload', () => {
it('GETs the /export route with format, fields, orderby, header and limit', async () => {
const fetchImpl = vi.fn(async () => csvResponse());
const ds = makeDS(fetchImpl);

const blob = await ds.exportDownload('task', {
format: 'xlsx',
fields: ['title', 'owner'],
sort: [{ field: 'title', direction: 'desc' }, { field: 'owner' }],
includeHeaders: false,
limit: 5000,
});

expect(blob).toBeInstanceOf(Blob);
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0];
const parsed = new URL(url);
expect(parsed.pathname).toBe('/api/v1/data/task/export');
expect(parsed.searchParams.get('format')).toBe('xlsx');
expect(parsed.searchParams.get('fields')).toBe('title,owner');
// direction defaults to asc when omitted.
expect(parsed.searchParams.get('orderby')).toBe('title:desc,owner:asc');
expect(parsed.searchParams.get('header')).toBe('false');
expect(parsed.searchParams.get('limit')).toBe('5000');
expect(init.method).toBe('GET');
expect(init.credentials).toBe('include');
});

it('defaults to csv and omits optional params when not provided', async () => {
const fetchImpl = vi.fn(async () => csvResponse());
const ds = makeDS(fetchImpl);

await ds.exportDownload('task', {});

const [url] = fetchImpl.mock.calls[0];
const parsed = new URL(url);
expect(parsed.searchParams.get('format')).toBe('csv');
expect(parsed.searchParams.get('fields')).toBeNull();
expect(parsed.searchParams.get('orderby')).toBeNull();
expect(parsed.searchParams.get('header')).toBeNull();
expect(parsed.searchParams.get('limit')).toBeNull();
});

it('serializes the filter to a JSON AST query param', async () => {
const fetchImpl = vi.fn(async () => csvResponse());
const ds = makeDS(fetchImpl);

await ds.exportDownload('task', { filter: [['status', '=', 'open']] });

const [url] = fetchImpl.mock.calls[0];
const raw = new URL(url).searchParams.get('filter');
expect(raw).toBeTruthy();
expect(JSON.parse(raw!)).toEqual([['status', '=', 'open']]);
});

it('throws an error carrying the server message and status on failure', async () => {
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ error: { message: 'Permission denied' } }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
);
const ds = makeDS(fetchImpl);

await expect(ds.exportDownload('task', { format: 'csv' })).rejects.toMatchObject({
message: 'Permission denied',
status: 403,
});
});
});
62 changes: 61 additions & 1 deletion packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

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

/**
* Synchronously download a server-streamed export (csv / json / xlsx).
*
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
* requested format, formats values for readability (lookup → name, select →
* label, boolean → 是/否, dates formatted) and enforces permissions. The
* filter / sort are translated the same way as `rawFindWithPopulate` so the
* exported file mirrors the active list view. Returns the file as a Blob;
* the caller triggers the browser download.
*/
async exportDownload(resource: string, request: ExportDownloadRequest = {}): Promise<Blob> {
const queryParams = new URLSearchParams();

const format = request.format === 'xlsx' ? 'xlsx' : request.format === 'json' ? 'json' : 'csv';
queryParams.set('format', format);

if (request.fields && request.fields.length > 0) {
queryParams.set('fields', request.fields.join(','));
}
if (request.limit && request.limit > 0) {
queryParams.set('limit', String(request.limit));
}
if (request.includeHeaders === false) {
queryParams.set('header', 'false');
}
// Sort → server `orderby` shorthand: "field:dir,field2:dir".
if (request.sort && request.sort.length > 0) {
const orderby = request.sort
.filter(s => s && s.field)
.map(s => `${s.field}:${s.direction === 'desc' ? 'desc' : 'asc'}`)
.join(',');
if (orderby) queryParams.set('orderby', orderby);
}
// Filter → AST tuples, same translation the list GET path uses.
if (request.filter !== undefined && request.filter !== null) {
const translated = translateFilterToAST(request.filter);
if (translated !== undefined) {
queryParams.set('filter', JSON.stringify(translated));
}
}

const baseUrl = this.baseUrl.replace(/\/$/, '');
// Avoid doubling /api/v1 if baseUrl already includes the version suffix.
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;

const headers: Record<string, string> = { ...this.getAuthHeaders() };
// `credentials: 'include'` carries the session cookie for the browser
// console (which authenticates by cookie, not a bearer token).
const res = await this.fetchImpl(url, { method: 'GET', headers, credentials: 'include' });
if (!res.ok) {
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
err.status = res.status;
throw err;
}
return await res.blob();
}

/**
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
* Maps OData-style conventions to ObjectStack conventions.
Expand Down
110 changes: 73 additions & 37 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n';
import {
Badge, Button, NavigationOverlay, EmptyValue,
Popover, PopoverContent, PopoverTrigger,
ExportProgressDialog, useExportJob, RefreshIndicator,
RefreshIndicator,
} from '@object-ui/components';
import { usePullToRefresh } from '@object-ui/mobile';
import { evaluatePlainCondition, buildExpandFields } from '@object-ui/core';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { GroupRow } from './GroupRow';
Expand Down Expand Up @@ -237,8 +237,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
const [useCardView, setUseCardView] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [showExport, setShowExport] = useState(false);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
const exportJob = useExportJob({ dataSource });
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'compact');
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [selectAllMatching, setSelectAllMatching] = useState(false);
Expand Down Expand Up @@ -1220,32 +1220,67 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
}, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName]);

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

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

if (useAsync) {
if (serverEligible) {
const cols = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
const fields = cols.map((c: any) => c.accessorKey).filter(Boolean);
setShowExport(false);
setExportDialogOpen(true);
void exportJob.start(schema.objectName!, {
format: format === 'json' ? 'json' : (format as 'csv' | 'xlsx'),
fields: fields.length ? fields : undefined,
includeHeaders,
limit: maxRecords > 0 ? maxRecords : undefined,
});

const filter = Array.isArray(schemaFilter) ? schemaFilter : undefined;
const sort = Array.isArray(schemaSort)
? schemaSort
.filter((s: any) => s && s.field)
.map((s: any) => ({ field: s.field, direction: (s.order as 'asc' | 'desc') ?? 'asc' }))
: undefined;

setExportError(null);
setExportBusy(true);
void (async () => {
try {
const blob = await dataSource!.exportDownload!(objectName!, {
format: format as 'csv' | 'xlsx' | 'json',
fields: fields.length ? fields : undefined,
filter,
sort,
includeHeaders,
limit: maxRecords > 0 ? maxRecords : undefined,
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${prefix}.${format}`;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setShowExport(false);
} catch (err) {
// Surface the failure instead of swallowing it (e.g. permission denied
// or a server error) — the toolbar shows the message.
console.error('ObjectGrid export failed:', err);
setExportError(err instanceof Error ? err.message : String(err));
} finally {
setExportBusy(false);
}
})();
return;
}

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

if (error) {
return (
Expand Down Expand Up @@ -2044,7 +2079,9 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
// Hide row-height toggle when parent (e.g., ListView) controls density externally,
// signaled by `hideRowHeightToggle` prop on schema.
const showRowHeightToggle = schema.rowHeight !== undefined && !(schema as any).hideRowHeightToggle;
const hasToolbar = schema.exportOptions || showRowHeightToggle;
// Export is offered only when configured AND not blocked by object-level perms.
const exportEnabled = !!schema.exportOptions && schema.operations?.export !== false;
const hasToolbar = exportEnabled || showRowHeightToggle;
const gridToolbar = hasToolbar ? (
<div className="flex items-center justify-end gap-1 px-2 py-1">
{/* Row height toggle */}
Expand All @@ -2062,7 +2099,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
)}

{/* Export */}
{schema.exportOptions && (
{exportEnabled && (
<Popover open={showExport} onOpenChange={setShowExport}>
<PopoverTrigger asChild>
<Button
Expand All @@ -2076,18 +2113,30 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-2">
<div className="space-y-1">
{(schema.exportOptions.formats || ['csv', 'json']).map(format => (
{(schema.exportOptions?.formats || ['csv', 'json']).map(format => (
<Button
key={format}
variant="ghost"
size="sm"
className="w-full justify-start h-8 text-xs"
disabled={exportBusy}
onClick={() => handleExport(format)}
>
<Download className="h-3.5 w-3.5 mr-2" />
{exportBusy
? <Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
: <Download className="h-3.5 w-3.5 mr-2" />}
{t('grid.exportAs', { format: format.toUpperCase() })}
</Button>
))}
{exportError && (
<div
className="px-2 py-1 text-xs"
style={{ color: 'var(--destructive, #ef4444)' }}
role="alert"
>
{exportError}
</div>
)}
</div>
</PopoverContent>
</Popover>
Expand Down Expand Up @@ -2313,17 +2362,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
</>
);

// Shared async-export progress dialog (used by both render paths).
const exportProgressDialog = (
<ExportProgressDialog
open={exportDialogOpen}
onOpenChange={setExportDialogOpen}
job={exportJob}
filename={`${schema.exportOptions?.fileNamePrefix || schema.objectName || 'export'}.${exportJob.progress?.format || 'csv'}`}
closeAfterDownloadMs={400}
/>
);

// Rendered BulkActionDialog (shared across both render branches).
const bulkDialog = (
<BulkActionDialog
Expand Down Expand Up @@ -2364,7 +2402,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
>
{(record) => renderRecordDetail(record)}
</NavigationOverlay>
{exportProgressDialog}
{bulkDialog}
</>
);
Expand Down Expand Up @@ -2405,7 +2442,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
{(record) => renderRecordDetail(record)}
</NavigationOverlay>
)}
{exportProgressDialog}
{bulkDialog}
</div>
);
Expand Down
Loading
Loading