Skip to content

Commit f9d8bc4

Browse files
Copilothotlong
andcommitted
feat: add export button with CSV/JSON support to ObjectGrid (Phase 10 L1)
- Add exportOptions property to ObjectGridSchema type definition - Implement handleExport callback with CSV and JSON format support - Add export toolbar with Popover dropdown in ObjectGrid component - Add unit tests verifying export button rendering and format options Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 0493a4b commit f9d8bc4

3 files changed

Lines changed: 220 additions & 8 deletions

File tree

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,13 @@ import React, { useEffect, useState, useCallback } from 'react';
2525
import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
2626
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react';
2727
import { getCellRenderer } from '@object-ui/fields';
28-
import { Button, NavigationOverlay } from '@object-ui/components';
29-
import { usePullToRefresh } from '@object-ui/mobile';
3028
import {
31-
DropdownMenu,
32-
DropdownMenuContent,
33-
DropdownMenuItem,
34-
DropdownMenuTrigger,
29+
Button, NavigationOverlay,
30+
Popover, PopoverContent, PopoverTrigger,
31+
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
3532
} from '@object-ui/components';
36-
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown } from 'lucide-react';
33+
import { usePullToRefresh } from '@object-ui/mobile';
34+
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download } from 'lucide-react';
3735
import { useRowColor } from './useRowColor';
3836
import { useGroupedData } from './useGroupedData';
3937

@@ -125,6 +123,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
125123
const [objectSchema, setObjectSchema] = useState<any>(null);
126124
const [useCardView, setUseCardView] = useState(false);
127125
const [refreshKey, setRefreshKey] = useState(0);
126+
const [showExport, setShowExport] = useState(false);
128127

129128
// Column state persistence (order and widths)
130129
const columnStorageKey = React.useMemo(() => {
@@ -514,6 +513,47 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
514513
return generatedColumns;
515514
}, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction]);
516515

516+
const handleExport = useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
517+
const exportConfig = schema.exportOptions;
518+
const maxRecords = exportConfig?.maxRecords || 0;
519+
const includeHeaders = exportConfig?.includeHeaders !== false;
520+
const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export';
521+
const exportData = maxRecords > 0 ? data.slice(0, maxRecords) : data;
522+
523+
if (format === 'csv') {
524+
const cols = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
525+
const fields = cols.map((c: any) => c.accessorKey);
526+
const headers = cols.map((c: any) => c.header);
527+
const rows: string[] = [];
528+
if (includeHeaders) {
529+
rows.push(headers.join(','));
530+
}
531+
exportData.forEach(record => {
532+
rows.push(fields.map((f: string) => {
533+
const val = record[f];
534+
const str = val == null ? '' : String(val);
535+
return str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r') ? `"${str.replace(/"/g, '""')}"` : str;
536+
}).join(','));
537+
});
538+
const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
539+
const url = URL.createObjectURL(blob);
540+
const a = document.createElement('a');
541+
a.href = url;
542+
a.download = `${prefix}.csv`;
543+
a.click();
544+
URL.revokeObjectURL(url);
545+
} else if (format === 'json') {
546+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
547+
const url = URL.createObjectURL(blob);
548+
const a = document.createElement('a');
549+
a.href = url;
550+
a.download = `${prefix}.json`;
551+
a.click();
552+
URL.revokeObjectURL(url);
553+
}
554+
setShowExport(false);
555+
}, [data, schema.exportOptions, schema.objectName, generateColumns]);
556+
517557
if (error) {
518558
return (
519559
<div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
@@ -713,6 +753,40 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
713753
);
714754
}
715755

756+
// Export toolbar (shown when exportOptions is configured)
757+
const exportToolbar = schema.exportOptions ? (
758+
<div className="flex items-center justify-end px-2 py-1">
759+
<Popover open={showExport} onOpenChange={setShowExport}>
760+
<PopoverTrigger asChild>
761+
<Button
762+
variant="ghost"
763+
size="sm"
764+
className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
765+
>
766+
<Download className="h-3.5 w-3.5 mr-1.5" />
767+
<span className="hidden sm:inline">Export</span>
768+
</Button>
769+
</PopoverTrigger>
770+
<PopoverContent align="end" className="w-48 p-2">
771+
<div className="space-y-1">
772+
{(schema.exportOptions.formats || ['csv', 'json']).map(format => (
773+
<Button
774+
key={format}
775+
variant="ghost"
776+
size="sm"
777+
className="w-full justify-start h-8 text-xs"
778+
onClick={() => handleExport(format)}
779+
>
780+
<Download className="h-3.5 w-3.5 mr-2" />
781+
Export as {format.toUpperCase()}
782+
</Button>
783+
))}
784+
</div>
785+
</PopoverContent>
786+
</Popover>
787+
</div>
788+
) : null;
789+
716790
// Render grid content: grouped (multiple tables with headers) or flat (single table)
717791
const gridContent = isGrouped ? (
718792
<div className="space-y-2">
@@ -745,7 +819,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
745819
<NavigationOverlay
746820
{...navigation}
747821
title={detailTitle}
748-
mainContent={gridContent}
822+
mainContent={<>{exportToolbar}{gridContent}</>}
749823
>
750824
{(record) => (
751825
<div className="space-y-3">
@@ -773,6 +847,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
773847
{isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
774848
</div>
775849
)}
850+
{exportToolbar}
776851
{gridContent}
777852
{navigation.isOverlay && (
778853
<NavigationOverlay
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
/**
10+
* Phase 10 - Grid Export Tests
11+
*
12+
* Tests the CSV/JSON export functionality on ObjectGrid component.
13+
*/
14+
15+
import { describe, it, expect, vi, afterEach } from 'vitest';
16+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
17+
import '@testing-library/jest-dom';
18+
import React from 'react';
19+
import { ObjectGrid } from '../ObjectGrid';
20+
import { registerAllFields } from '@object-ui/fields';
21+
22+
registerAllFields();
23+
24+
afterEach(() => {
25+
vi.clearAllMocks();
26+
});
27+
28+
const sampleData = [
29+
{ _id: '1', name: 'Alice', email: 'alice@example.com', status: 'Active' },
30+
{ _id: '2', name: 'Bob', email: 'bob@example.com', status: 'Inactive' },
31+
{ _id: '3', name: 'Charlie', email: 'charlie@example.com', status: 'Active' },
32+
];
33+
34+
describe('Phase 10 - Grid Export', () => {
35+
it('does not render export button when exportOptions is not configured', () => {
36+
render(
37+
<ObjectGrid
38+
schema={{
39+
type: 'object-grid',
40+
objectName: 'contacts',
41+
data: sampleData,
42+
columns: ['name', 'email', 'status'],
43+
}}
44+
/>
45+
);
46+
47+
expect(screen.queryByText('Export')).not.toBeInTheDocument();
48+
});
49+
50+
it('renders export button when exportOptions is configured', async () => {
51+
render(
52+
<ObjectGrid
53+
schema={{
54+
type: 'object-grid',
55+
objectName: 'contacts',
56+
data: sampleData,
57+
columns: ['name', 'email', 'status'],
58+
exportOptions: {
59+
formats: ['csv', 'json'],
60+
},
61+
}}
62+
/>
63+
);
64+
65+
await waitFor(() => {
66+
expect(screen.getByText('Export')).toBeInTheDocument();
67+
});
68+
});
69+
70+
it('shows format options when export button is clicked', async () => {
71+
render(
72+
<ObjectGrid
73+
schema={{
74+
type: 'object-grid',
75+
objectName: 'contacts',
76+
data: sampleData,
77+
columns: ['name', 'email', 'status'],
78+
exportOptions: {
79+
formats: ['csv', 'json'],
80+
},
81+
}}
82+
/>
83+
);
84+
85+
await waitFor(() => {
86+
expect(screen.getByText('Export')).toBeInTheDocument();
87+
});
88+
89+
fireEvent.click(screen.getByText('Export'));
90+
91+
await waitFor(() => {
92+
expect(screen.getByText('Export as CSV')).toBeInTheDocument();
93+
expect(screen.getByText('Export as JSON')).toBeInTheDocument();
94+
});
95+
});
96+
97+
it('defaults to csv and json formats when formats not specified', async () => {
98+
render(
99+
<ObjectGrid
100+
schema={{
101+
type: 'object-grid',
102+
objectName: 'contacts',
103+
data: sampleData,
104+
columns: ['name', 'email', 'status'],
105+
exportOptions: {},
106+
}}
107+
/>
108+
);
109+
110+
await waitFor(() => {
111+
expect(screen.getByText('Export')).toBeInTheDocument();
112+
});
113+
114+
fireEvent.click(screen.getByText('Export'));
115+
116+
await waitFor(() => {
117+
expect(screen.getByText('Export as CSV')).toBeInTheDocument();
118+
expect(screen.getByText('Export as JSON')).toBeInTheDocument();
119+
});
120+
});
121+
});

packages/types/src/objectql.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,22 @@ export interface ObjectGridSchema extends BaseSchema {
415415
*/
416416
frozenColumns?: number;
417417

418+
/**
419+
* Export options configuration for exporting grid data.
420+
* Supports csv, xlsx, json, and pdf formats.
421+
* Aligned with @objectstack/spec ListViewSchema.exportOptions.
422+
*/
423+
exportOptions?: {
424+
/** Formats available for export */
425+
formats?: Array<'csv' | 'xlsx' | 'json' | 'pdf'>;
426+
/** Maximum number of records to export (0 = unlimited) */
427+
maxRecords?: number;
428+
/** Include column headers in export */
429+
includeHeaders?: boolean;
430+
/** Custom file name prefix */
431+
fileNamePrefix?: string;
432+
};
433+
418434
/**
419435
* Navigation configuration for row click behavior.
420436
* Controls how record detail is displayed when a row is clicked.

0 commit comments

Comments
 (0)