diff --git a/packages/data-objectstack/src/exportDownload.test.ts b/packages/data-objectstack/src/exportDownload.test.ts new file mode 100644 index 000000000..c248d7f59 --- /dev/null +++ b/packages/data-objectstack/src/exportDownload.test.ts @@ -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, + }); + }); +}); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 0f63dba38..d2d32a161 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -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 { @@ -1182,6 +1182,66 @@ export class ObjectStackAdapter implements DataSource { 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 { + 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 = { ...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. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index f6aa5c222..63da48e47 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -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'; @@ -237,8 +237,8 @@ export const ObjectGrid: React.FC = ({ 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(null); const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'compact'); const [selectedRows, setSelectedRows] = useState([]); const [selectAllMatching, setSelectAllMatching] = useState(false); @@ -1220,32 +1220,67 @@ export const ObjectGrid: React.FC = ({ }, [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; } @@ -1284,7 +1319,7 @@ export const ObjectGrid: React.FC = ({ 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 ( @@ -2044,7 +2079,9 @@ export const ObjectGrid: React.FC = ({ // 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 ? (
{/* Row height toggle */} @@ -2062,7 +2099,7 @@ export const ObjectGrid: React.FC = ({ )} {/* Export */} - {schema.exportOptions && ( + {exportEnabled && ( ))} + {exportError && ( +
+ {exportError} +
+ )}
@@ -2313,17 +2362,6 @@ export const ObjectGrid: React.FC = ({ ); - // Shared async-export progress dialog (used by both render paths). - const exportProgressDialog = ( - - ); - // Rendered BulkActionDialog (shared across both render branches). const bulkDialog = ( = ({ > {(record) => renderRecordDetail(record)} - {exportProgressDialog} {bulkDialog} ); @@ -2405,7 +2442,6 @@ export const ObjectGrid: React.FC = ({ {(record) => renderRecordDetail(record)} )} - {exportProgressDialog} {bulkDialog} ); diff --git a/packages/plugin-grid/src/__tests__/exportGate.test.tsx b/packages/plugin-grid/src/__tests__/exportGate.test.tsx new file mode 100644 index 000000000..105e81b4d --- /dev/null +++ b/packages/plugin-grid/src/__tests__/exportGate.test.tsx @@ -0,0 +1,54 @@ +/** + * Export permission gate — object-level `operations.export`. + * + * Covers the hard gate: when `operations.export === false` the export button + * is hidden; default-allow keeps it visible when the key is omitted. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; + +registerAllFields(); + +const data = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, +]; + +function renderGrid(opts?: Record) { + const schema: any = { + type: 'object-grid', + objectName: 'test_object', + columns: [{ field: 'name', label: 'Name' }], + data: { provider: 'value', items: data }, + exportOptions: { formats: ['csv', 'xlsx'] }, + ...opts, + }; + return render( + + + + ); +} + +describe('ObjectGrid export permission gate', () => { + it('shows the export button by default (operations.export omitted)', () => { + renderGrid(); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); + + it('hides the export button when operations.export is false', () => { + renderGrid({ operations: { export: false } }); + expect(screen.queryAllByRole('button', { name: /export/i }).length).toBe(0); + }); + + it('keeps the export button when operations is set but export is omitted (default-allow)', () => { + renderGrid({ operations: { create: false } }); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/exportServer.test.tsx b/packages/plugin-grid/src/__tests__/exportServer.test.tsx new file mode 100644 index 000000000..391d4045a --- /dev/null +++ b/packages/plugin-grid/src/__tests__/exportServer.test.tsx @@ -0,0 +1,95 @@ +/** + * Server-streamed export routing — ObjectGrid. + * + * A server-backed grid (provider:object + dataSource.exportDownload) routes the + * export through the streaming `exportDownload` adapter, passing the grid's + * configured filter + sort and the visible field set, and downloads the Blob. + * Failures surface in the toolbar instead of being swallowed. + */ +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; + +registerAllFields(); + +beforeAll(() => { + // jsdom has no object-URL plumbing; the download path calls these. + if (!URL.createObjectURL) (URL as any).createObjectURL = () => 'blob:export'; + if (!URL.revokeObjectURL) (URL as any).revokeObjectURL = () => {}; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeDataSource(exportDownload: any) { + return { + find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })), + getObjectSchema: async (name: string) => ({ + name, + fields: { id: { type: 'text' }, name: { type: 'text' } }, + }), + exportDownload, + } as any; +} + +function renderGrid(dataSource: any, opts?: Record) { + const schema: any = { + type: 'object-grid', + objectName: 'task', + columns: [{ field: 'name', label: 'Name' }], + exportOptions: { formats: ['csv', 'xlsx'] }, + ...opts, + }; + return render( + + + , + ); +} + +describe('ObjectGrid — server-streamed export', () => { + it('routes export through exportDownload with the grid filter + sort', async () => { + const exportDownload = vi.fn().mockResolvedValue(new Blob(['ID,Name\n1,Acme'], { type: 'text/csv' })); + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:export'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const ds = makeDataSource(exportDownload); + + renderGrid(ds, { + filter: [['status', '=', 'open']], + sort: [{ field: 'name', order: 'desc' }], + }); + + fireEvent.click(await screen.findByRole('button', { name: /export/i })); + fireEvent.click(await screen.findByRole('button', { name: /export as xlsx/i })); + + await waitFor(() => expect(exportDownload).toHaveBeenCalledTimes(1)); + const [resource, request] = exportDownload.mock.calls[0]; + expect(resource).toBe('task'); + expect(request).toMatchObject({ + format: 'xlsx', + filter: [['status', '=', 'open']], + sort: [{ field: 'name', direction: 'desc' }], + }); + await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1)); + }); + + it('surfaces export failures in the toolbar', async () => { + const exportDownload = vi.fn().mockRejectedValue(new Error('Permission denied')); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:export'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const ds = makeDataSource(exportDownload); + + renderGrid(ds); + + fireEvent.click(await screen.findByRole('button', { name: /export/i })); + fireEvent.click(await screen.findByRole('button', { name: /export as csv/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Permission denied'); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 8cab0b1ba..36917b3b5 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, RefreshIndicator, DataEmptyState } from '@object-ui/components'; import type { SortItem } from '@object-ui/components'; -import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, RotateCw, icons, type LucideIcon } from 'lucide-react'; +import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, RotateCw, Loader2, icons, type LucideIcon } from 'lucide-react'; import type { FilterGroup } from '@object-ui/components'; import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher'; import { ViewSettingsPopover } from './components/ViewSettingsPopover'; @@ -639,6 +639,13 @@ export const ListView = React.forwardRef(({ // Export State const [showExport, setShowExport] = React.useState(false); + // Server-streamed export (xlsx / type-aware csv|json) in-flight + last error. + const [exportBusy, setExportBusy] = React.useState(false); + const [exportError, setExportError] = React.useState(null); + + // Object-level export permission gate. Default-allow: export stays enabled + // unless `allowExport === false` or `operations.export === false`. + const exportPermitted = schema.allowExport !== false && schema.operations?.export !== false; // Normalize exportOptions: support both ObjectUI object format and spec string[] format const resolvedExportOptions = React.useMemo(() => { @@ -1511,10 +1518,80 @@ export const ListView = React.forwardRef(({ // Export handler const handleExport = React.useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => { + // Object-level export permission gate. Default-allow. + if (!exportPermitted) return; const exportConfig = resolvedExportOptions; const maxRecords = exportConfig?.maxRecords || 0; const includeHeaders = exportConfig?.includeHeaders !== false; const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export'; + + // 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 active view's + // filter + sort so the exported file matches what the user sees. + const serverEligible = (format === 'csv' || format === 'xlsx' || format === 'json') + && typeof dataSource?.exportDownload === 'function' + && !!schema.objectName + && (exportConfig as any)?.streaming !== false; + if (serverEligible) { + const fields = effectiveFields + .map((f: any) => typeof f === 'string' ? f : (f.name || f.fieldName || f.field)) + .filter(Boolean); + + // Merge the same filter sources as the data fetch (base + user + conditions). + const baseFilter = schema.filters || []; + const userFilter = convertFilterGroupToAST(currentFilters); + const normalizedUserFilterConditions = normalizeFilters(userFilterConditions); + const allFilters = [ + ...(baseFilter.length > 0 ? [baseFilter] : []), + ...(userFilter.length > 0 ? [userFilter] : []), + ...normalizedUserFilterConditions, + ].filter((f: any) => Array.isArray(f) && f.length > 0); + const finalFilter = allFilters.length > 1 + ? ['and', ...allFilters] + : allFilters.length === 1 ? allFilters[0] : undefined; + + const sort = currentSort.length > 0 + ? currentSort + .filter(item => item.field) + .map(item => ({ field: item.field, direction: item.order as 'asc' | 'desc' })) + : undefined; + + setExportError(null); + setExportBusy(true); + void (async () => { + try { + const blob = await dataSource!.exportDownload!(schema.objectName!, { + format: format as 'csv' | 'xlsx' | 'json', + fields: fields.length ? fields : undefined, + filter: finalFilter, + 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('ListView export failed:', err); + setExportError(err instanceof Error ? err.message : String(err)); + } finally { + setExportBusy(false); + } + })(); + return; + } + + // Client-side fallback (csv / json only). const exportData = maxRecords > 0 ? data.slice(0, maxRecords) : data; if (format === 'csv') { @@ -1562,7 +1639,7 @@ export const ListView = React.forwardRef(({ URL.revokeObjectURL(url); } setShowExport(false); - }, [data, effectiveFields, resolvedExportOptions, schema.objectName]); + }, [data, effectiveFields, resolvedExportOptions, schema.objectName, schema.filters, exportPermitted, dataSource, currentFilters, userFilterConditions, currentSort]); // All available fields for hide/show (with i18n) const allFields = React.useMemo(() => { @@ -2010,12 +2087,12 @@ export const ListView = React.forwardRef(({ )} {/* --- Separator: Appearance | Export --- */} - {(toolbarFlags.showColor || toolbarFlags.showDensity || toolbarFlags.compactToolbar) && resolvedExportOptions && schema.allowExport !== false && ( + {(toolbarFlags.showColor || toolbarFlags.showDensity || toolbarFlags.compactToolbar) && resolvedExportOptions && exportPermitted && (
)} {/* Export */} - {resolvedExportOptions && schema.allowExport !== false && ( + {resolvedExportOptions && exportPermitted && ( ))} + {exportError && ( +
+ {exportError} +
+ )}
@@ -2076,7 +2165,7 @@ export const ListView = React.forwardRef(({ {/* --- Separator: Print/Share/Export | Search --- */} {(() => { - const hasLeftSideItems = schema.allowPrinting || (schema.sharing?.enabled || schema.sharing?.type) || (resolvedExportOptions && schema.allowExport !== false); + const hasLeftSideItems = schema.allowPrinting || (schema.sharing?.enabled || schema.sharing?.type) || (resolvedExportOptions && exportPermitted); return toolbarFlags.showSearch && hasLeftSideItems ? (
) : null; diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx index 1cf6f21a6..fbe7d49ee 100644 --- a/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -347,6 +347,148 @@ describe('ListView', () => { expect(exportButtons.length).toBe(0); }); + it('hides export button when operations.export is false', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + exportOptions: { formats: ['csv', 'json'] }, + operations: { export: false }, + }; + + renderWithProvider(); + + const exportButtons = screen.queryAllByRole('button', { name: /export/i }); + expect(exportButtons.length).toBe(0); + }); + + it('keeps export button when operations is set but export is omitted (default-allow)', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + exportOptions: { formats: ['csv', 'json'] }, + operations: { create: false }, + }; + + renderWithProvider(); + + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); + + it('routes export to the server exportDownload stream when the data source supports it', async () => { + const exportDownload = vi.fn().mockResolvedValue( + new Blob(['ID,Name\n1,Acme'], { type: 'text/csv' }), + ); + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:export'); + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const ds: any = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exportDownload, + }; + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + exportOptions: { formats: ['csv', 'xlsx'] }, + }; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /export/i })); + fireEvent.click(await screen.findByRole('button', { name: /export as xlsx/i })); + + await vi.waitFor(() => { + expect(exportDownload).toHaveBeenCalledTimes(1); + }); + const [resource, request] = exportDownload.mock.calls[0]; + expect(resource).toBe('contacts'); + expect(request).toMatchObject({ format: 'xlsx', fields: ['name', 'email'] }); + // The returned Blob is handed to the browser download path. + await vi.waitFor(() => { + expect(createObjectURL).toHaveBeenCalledTimes(1); + }); + + createObjectURL.mockRestore(); + revokeObjectURL.mockRestore(); + }); + + it('surfaces export failures instead of swallowing them', async () => { + const exportDownload = vi.fn().mockRejectedValue(new Error('Permission denied')); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:export'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const ds: any = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exportDownload, + }; + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + exportOptions: { formats: ['csv', 'xlsx'] }, + }; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /export/i })); + fireEvent.click(await screen.findByRole('button', { name: /export as csv/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Permission denied'); + }); + + it('does not call exportDownload when operations.export is false (programmatic guard)', () => { + const exportDownload = vi.fn(); + const ds: any = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exportDownload, + }; + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + exportOptions: { formats: ['csv', 'xlsx'] }, + operations: { export: false }, + }; + + render( + + + + ); + + // Button is gated out, so there is no entry point to trigger an export. + expect(screen.queryAllByRole('button', { name: /export/i }).length).toBe(0); + expect(exportDownload).not.toHaveBeenCalled(); + }); + it('should apply hiddenFields to effective fields', () => { const schema: ListViewSchema = { type: 'list-view', diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index ed49c5cba..49fadbfa4 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -527,6 +527,51 @@ export interface DataSource { * @returns Promise resolving to a downloadable URL (may be short-lived). */ getExportJobDownloadUrl?(jobId: string): Promise; + + /** + * Synchronously download a server-streamed export of a resource. + * + * Unlike the async `createExportJob` family, this resolves directly to the + * exported file as a `Blob`: the server streams matching rows in the chosen + * format (`csv` / `json` / `xlsx`), applies type-aware value formatting + * (lookup → name, select → label, boolean → 是/否, dates formatted) and + * enforces object / field / row permissions. Suited to interactive + * "click Export → file downloads" flows up to the server's row cap (tens of + * thousands of rows), with no client-side buffering of the full dataset + * during generation. + * + * Optional — when not implemented, callers fall back to the client-side + * export path (csv / json only, raw values, no type-aware formatting). + * + * @param resource - Resource name (e.g., 'account', 'opportunity') + * @param request - Export request (format, fields, filter, sort, limit, …) + * @returns Promise resolving to the exported file as a Blob. + */ + exportDownload?( + resource: string, + request: ExportDownloadRequest, + ): Promise; +} + +/** + * Request payload for `DataSource.exportDownload` (synchronous streamed export). + * + * Mirrors the active list view: pass the same `filter` / `sort` the list is + * showing so the exported file matches what the user sees. + */ +export interface ExportDownloadRequest { + /** Output file format. Defaults to 'csv'. */ + format?: 'csv' | 'json' | 'xlsx'; + /** Subset of fields to include (defaults to all readable columns). */ + fields?: string[]; + /** Server-side filter (engine-specific shape, often the active view filter). */ + filter?: unknown; + /** Sort instructions; multiple keys allowed, order preserved. */ + sort?: Array<{ field: string; direction?: 'asc' | 'desc' }>; + /** Hard cap on records exported (server enforces its own ceiling too). */ + limit?: number; + /** Whether to write a header row (csv / xlsx). Default true. */ + includeHeaders?: boolean; } /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2ad6be505..53b53e548 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -279,6 +279,7 @@ export type { CreateExportJobRequest, CreateExportJobResult, ExportJobProgressInfo, + ExportDownloadRequest, } from './data'; // ============================================================================ diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index d813bd1f9..fdbe25125 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1653,6 +1653,22 @@ export interface ListViewSchema extends BaseSchema { /** Allow data export @default undefined */ allowExport?: boolean; + /** + * Enable/disable built-in operations. The toolbar honors `export` as a hard + * gate: when `operations.export === false` the export button is hidden and + * any export request is blocked, regardless of `exportOptions`. Other keys + * are accepted for parity with grid schemas. Default-allow: an omitted + * `export` key (or `undefined`) leaves export enabled. + */ + operations?: { + create?: boolean; + read?: boolean; + update?: boolean; + delete?: boolean; + export?: boolean; + import?: boolean; + }; + /** Color field for row/card coloring */ color?: string;