@@ -31,11 +31,11 @@ import { stateMachineNextValues } from './inline-edit-options';
3131import {
3232 Badge , Button , NavigationOverlay , EmptyValue ,
3333 Popover , PopoverContent , PopoverTrigger ,
34- ExportProgressDialog , useExportJob , RefreshIndicator ,
34+ RefreshIndicator ,
3535} from '@object-ui/components' ;
3636import { usePullToRefresh } from '@object-ui/mobile' ;
3737import { 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' ;
3939import { useRowColor } from './useRowColor' ;
4040import { useGroupedData } from './useGroupedData' ;
4141import { 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