@@ -30,11 +30,11 @@ import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n';
3030import {
3131 Badge , Button , NavigationOverlay , EmptyValue ,
3232 Popover , PopoverContent , PopoverTrigger ,
33- ExportProgressDialog , useExportJob , RefreshIndicator ,
33+ RefreshIndicator ,
3434} from '@object-ui/components' ;
3535import { usePullToRefresh } from '@object-ui/mobile' ;
3636import { evaluatePlainCondition , buildExpandFields } from '@object-ui/core' ;
37- import { ChevronRight , ChevronDown , ChevronLeft , ChevronsLeft , ChevronsRight , Download , Rows2 , Rows3 , Rows4 , AlignJustify , Type , Hash , Calendar , CheckSquare , User , Tag , Clock } from 'lucide-react' ;
37+ import { ChevronRight , ChevronDown , ChevronLeft , ChevronsLeft , ChevronsRight , Download , Rows2 , Rows3 , Rows4 , AlignJustify , Type , Hash , Calendar , CheckSquare , User , Tag , Clock , Loader2 } from 'lucide-react' ;
3838import { useRowColor } from './useRowColor' ;
3939import { useGroupedData } from './useGroupedData' ;
4040import { GroupRow } from './GroupRow' ;
@@ -237,8 +237,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
237237 const [ useCardView , setUseCardView ] = useState ( false ) ;
238238 const [ refreshKey , setRefreshKey ] = useState ( 0 ) ;
239239 const [ showExport , setShowExport ] = useState ( false ) ;
240- const [ exportDialogOpen , setExportDialogOpen ] = useState ( false ) ;
241- const exportJob = useExportJob ( { dataSource } ) ;
240+ const [ exportBusy , setExportBusy ] = useState ( false ) ;
241+ const [ exportError , setExportError ] = useState < string | null > ( null ) ;
242242 const [ rowHeightMode , setRowHeightMode ] = useState < 'compact' | 'short' | 'medium' | 'tall' | 'extra_tall' > ( schema . rowHeight ?? 'compact' ) ;
243243 const [ selectedRows , setSelectedRows ] = useState < any [ ] > ( [ ] ) ;
244244 const [ selectAllMatching , setSelectAllMatching ] = useState ( false ) ;
@@ -1220,32 +1220,67 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12201220 } , [ objectSchema , schemaFields , schemaColumns , dataConfig , hasInlineData , navigation . handleClick , executeAction , data , resolveFieldLabel , translateOptions , schema . objectName ] ) ;
12211221
12221222 const handleExport = useCallback ( ( format : 'csv' | 'xlsx' | 'json' | 'pdf' ) => {
1223+ // Object-level export permission gate. Default-allow: only an explicit
1224+ // `operations.export === false` blocks the export.
1225+ if ( schema . operations ?. export === false ) return ;
12231226 const exportConfig = schema . exportOptions ;
12241227 const maxRecords = exportConfig ?. maxRecords || 0 ;
12251228 const includeHeaders = exportConfig ?. includeHeaders !== false ;
12261229 const prefix = exportConfig ?. fileNamePrefix || schema . objectName || 'export' ;
12271230
1228- // Async streaming path — use spec v4 createExportJob when the data source
1229- // supports it (and the format is something the server can stream).
1230- const asyncEligible = format === 'csv' || format === 'xlsx' || format === 'json' ;
1231- const useAsync = asyncEligible
1232- && exportJob . isSupported
1233- && schema . objectName
1231+ // Server-streamed path: csv / xlsx / json via dataSource.exportDownload.
1232+ // XLSX is server-only; type-aware value formatting, field resolution and
1233+ // permission enforcement all happen server-side. Mirrors the grid's
1234+ // configured filter + sort so the exported file matches what's shown.
1235+ const serverEligible = ( format === 'csv' || format === 'xlsx' || format === 'json' )
1236+ && typeof dataSource ?. exportDownload === 'function'
1237+ && ! ! objectName
12341238 && ! hasInlineData
12351239 // Honor an opt-out: schema.exportOptions.streaming === false forces client-side.
12361240 && ( exportConfig as any ) ?. streaming !== false ;
12371241
1238- if ( useAsync ) {
1242+ if ( serverEligible ) {
12391243 const cols = generateColumns ( ) . filter ( ( c : any ) => c . accessorKey !== '_actions' ) ;
12401244 const fields = cols . map ( ( c : any ) => c . accessorKey ) . filter ( Boolean ) ;
1241- setShowExport ( false ) ;
1242- setExportDialogOpen ( true ) ;
1243- void exportJob . start ( schema . objectName ! , {
1244- format : format === 'json' ? 'json' : ( format as 'csv' | 'xlsx' ) ,
1245- fields : fields . length ? fields : undefined ,
1246- includeHeaders,
1247- limit : maxRecords > 0 ? maxRecords : undefined ,
1248- } ) ;
1245+
1246+ const filter = Array . isArray ( schemaFilter ) ? schemaFilter : undefined ;
1247+ const sort = Array . isArray ( schemaSort )
1248+ ? schemaSort
1249+ . filter ( ( s : any ) => s && s . field )
1250+ . map ( ( s : any ) => ( { field : s . field , direction : ( s . order as 'asc' | 'desc' ) ?? 'asc' } ) )
1251+ : undefined ;
1252+
1253+ setExportError ( null ) ;
1254+ setExportBusy ( true ) ;
1255+ void ( async ( ) => {
1256+ try {
1257+ const blob = await dataSource ! . exportDownload ! ( objectName ! , {
1258+ format : format as 'csv' | 'xlsx' | 'json' ,
1259+ fields : fields . length ? fields : undefined ,
1260+ filter,
1261+ sort,
1262+ includeHeaders,
1263+ limit : maxRecords > 0 ? maxRecords : undefined ,
1264+ } ) ;
1265+ const url = URL . createObjectURL ( blob ) ;
1266+ const a = document . createElement ( 'a' ) ;
1267+ a . href = url ;
1268+ a . download = `${ prefix } .${ format } ` ;
1269+ a . rel = 'noopener' ;
1270+ document . body . appendChild ( a ) ;
1271+ a . click ( ) ;
1272+ a . remove ( ) ;
1273+ URL . revokeObjectURL ( url ) ;
1274+ setShowExport ( false ) ;
1275+ } catch ( err ) {
1276+ // Surface the failure instead of swallowing it (e.g. permission denied
1277+ // or a server error) — the toolbar shows the message.
1278+ console . error ( 'ObjectGrid export failed:' , err ) ;
1279+ setExportError ( err instanceof Error ? err . message : String ( err ) ) ;
1280+ } finally {
1281+ setExportBusy ( false ) ;
1282+ }
1283+ } ) ( ) ;
12491284 return ;
12501285 }
12511286
@@ -1284,7 +1319,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12841319 downloadFile ( new Blob ( [ JSON . stringify ( exportData , null , 2 ) ] , { type : 'application/json' } ) , `${ prefix } .json` ) ;
12851320 }
12861321 setShowExport ( false ) ;
1287- } , [ data , schema . exportOptions , schema . objectName , generateColumns , exportJob , hasInlineData ] ) ;
1322+ } , [ data , schema . exportOptions , schema . operations ?. export , schema . objectName , objectName , generateColumns , dataSource , hasInlineData , schemaFilter , schemaSort ] ) ;
12881323
12891324 if ( error ) {
12901325 return (
@@ -2044,7 +2079,9 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
20442079 // Hide row-height toggle when parent (e.g., ListView) controls density externally,
20452080 // signaled by `hideRowHeightToggle` prop on schema.
20462081 const showRowHeightToggle = schema . rowHeight !== undefined && ! ( schema as any ) . hideRowHeightToggle ;
2047- const hasToolbar = schema . exportOptions || showRowHeightToggle ;
2082+ // Export is offered only when configured AND not blocked by object-level perms.
2083+ const exportEnabled = ! ! schema . exportOptions && schema . operations ?. export !== false ;
2084+ const hasToolbar = exportEnabled || showRowHeightToggle ;
20482085 const gridToolbar = hasToolbar ? (
20492086 < div className = "flex items-center justify-end gap-1 px-2 py-1" >
20502087 { /* Row height toggle */ }
@@ -2062,7 +2099,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
20622099 ) }
20632100
20642101 { /* Export */ }
2065- { schema . exportOptions && (
2102+ { exportEnabled && (
20662103 < Popover open = { showExport } onOpenChange = { setShowExport } >
20672104 < PopoverTrigger asChild >
20682105 < Button
@@ -2076,18 +2113,30 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
20762113 </ PopoverTrigger >
20772114 < PopoverContent align = "end" className = "w-48 p-2" >
20782115 < div className = "space-y-1" >
2079- { ( schema . exportOptions . formats || [ 'csv' , 'json' ] ) . map ( format => (
2116+ { ( schema . exportOptions ? .formats || [ 'csv' , 'json' ] ) . map ( format => (
20802117 < Button
20812118 key = { format }
20822119 variant = "ghost"
20832120 size = "sm"
20842121 className = "w-full justify-start h-8 text-xs"
2122+ disabled = { exportBusy }
20852123 onClick = { ( ) => handleExport ( format ) }
20862124 >
2087- < Download className = "h-3.5 w-3.5 mr-2" />
2125+ { exportBusy
2126+ ? < Loader2 className = "h-3.5 w-3.5 mr-2 animate-spin" />
2127+ : < Download className = "h-3.5 w-3.5 mr-2" /> }
20882128 { t ( 'grid.exportAs' , { format : format . toUpperCase ( ) } ) }
20892129 </ Button >
20902130 ) ) }
2131+ { exportError && (
2132+ < div
2133+ className = "px-2 py-1 text-xs"
2134+ style = { { color : 'var(--destructive, #ef4444)' } }
2135+ role = "alert"
2136+ >
2137+ { exportError }
2138+ </ div >
2139+ ) }
20912140 </ div >
20922141 </ PopoverContent >
20932142 </ Popover >
@@ -2313,17 +2362,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
23132362 </ >
23142363 ) ;
23152364
2316- // Shared async-export progress dialog (used by both render paths).
2317- const exportProgressDialog = (
2318- < ExportProgressDialog
2319- open = { exportDialogOpen }
2320- onOpenChange = { setExportDialogOpen }
2321- job = { exportJob }
2322- filename = { `${ schema . exportOptions ?. fileNamePrefix || schema . objectName || 'export' } .${ exportJob . progress ?. format || 'csv' } ` }
2323- closeAfterDownloadMs = { 400 }
2324- />
2325- ) ;
2326-
23272365 // Rendered BulkActionDialog (shared across both render branches).
23282366 const bulkDialog = (
23292367 < BulkActionDialog
@@ -2364,7 +2402,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
23642402 >
23652403 { ( record ) => renderRecordDetail ( record ) }
23662404 </ NavigationOverlay >
2367- { exportProgressDialog }
23682405 { bulkDialog }
23692406 </ >
23702407 ) ;
@@ -2405,7 +2442,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
24052442 { ( record ) => renderRecordDetail ( record ) }
24062443 </ NavigationOverlay >
24072444 ) }
2408- { exportProgressDialog }
24092445 { bulkDialog }
24102446 </ div >
24112447 ) ;
0 commit comments