All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
✨ New Features (Tasks T029-T037) - Client-side CSV export for selected rows - Server-side CSV export for all filtered results (max 5000) - RFC 4180 compliant CSV formatting with proper escaping - UTF-8 BOM for Excel compatibility - ExportButton dropdown with two export modes - Warning UI when results exceed 5000 rows - Loading state with spinner during server export 📦 New Files - lib/utils/csv-export.ts - CSV generation utilities - components/policy-explorer/ExportButton.tsx - Export dropdown 🔧 Updates - PolicyTableToolbar now includes ExportButton - PolicyExplorerV2Client passes export props - Filename generation with timestamp and row count ✅ Zero TypeScript compilation errors ✅ All Phase 5 tasks complete (T029-T037) ✅ Ready for Phase 6 (Enhanced Detail View) Refs: specs/004-policy-explorer-v2/tasks.md Phase 5
166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
/**
|
||
* ExportButton Component
|
||
*
|
||
* CSV export dropdown with two options:
|
||
* 1. Export Selected Rows (client-side, immediate)
|
||
* 2. Export All Filtered Results (server-side via Server Action, max 5000)
|
||
*
|
||
* Features:
|
||
* - Dropdown menu with export options
|
||
* - Disabled state when no data/selection
|
||
* - Loading state for server-side export
|
||
* - Warning when result set > 5000 rows
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import { Download, Loader2 } from 'lucide-react';
|
||
import { generatePolicySettingsCsv, downloadCsv, generateCsvFilename } from '@/lib/utils/csv-export';
|
||
import { exportPolicySettingsCSV } from '@/lib/actions/policySettings';
|
||
import type { PolicySettingRow } from '@/lib/types/policy-table';
|
||
import { toast } from 'sonner';
|
||
|
||
interface ExportButtonProps {
|
||
selectedRows: PolicySettingRow[];
|
||
selectedCount: number;
|
||
totalCount: number;
|
||
// Filter state for server-side export
|
||
policyTypes?: string[];
|
||
searchQuery?: string;
|
||
sortBy?: string;
|
||
sortDir?: 'asc' | 'desc';
|
||
}
|
||
|
||
export function ExportButton({
|
||
selectedRows,
|
||
selectedCount,
|
||
totalCount,
|
||
policyTypes,
|
||
searchQuery,
|
||
sortBy,
|
||
sortDir,
|
||
}: ExportButtonProps) {
|
||
const [isExporting, setIsExporting] = useState(false);
|
||
|
||
const hasSelection = selectedCount > 0;
|
||
const hasData = totalCount > 0;
|
||
const exceedsLimit = totalCount > 5000;
|
||
|
||
// Client-side export: Export selected rows
|
||
const handleExportSelected = () => {
|
||
if (!hasSelection) return;
|
||
|
||
try {
|
||
const csvContent = generatePolicySettingsCsv(selectedRows);
|
||
const filename = generateCsvFilename('policy-settings', selectedCount);
|
||
downloadCsv(csvContent, filename);
|
||
|
||
toast.success(`Exported ${selectedCount} rows to ${filename}`);
|
||
} catch (error) {
|
||
console.error('Export error:', error);
|
||
toast.error('Failed to export selected rows');
|
||
}
|
||
};
|
||
|
||
// Server-side export: Export all filtered results
|
||
const handleExportAll = async () => {
|
||
setIsExporting(true);
|
||
|
||
try {
|
||
const result = await exportPolicySettingsCSV({
|
||
policyTypes: policyTypes && policyTypes.length > 0 ? policyTypes : undefined,
|
||
searchQuery: searchQuery || undefined,
|
||
sortBy: sortBy as 'settingName' | 'policyName' | 'policyType' | 'lastSyncedAt' | undefined,
|
||
sortDir,
|
||
maxRows: 5000,
|
||
});
|
||
|
||
if (result.success && result.csv && result.filename) {
|
||
downloadCsv(result.csv, result.filename);
|
||
toast.success(`Exported ${result.rowCount} rows to ${result.filename}`);
|
||
} else {
|
||
toast.error(result.error || 'Failed to export data');
|
||
}
|
||
} catch (error) {
|
||
console.error('Export error:', error);
|
||
toast.error('An unexpected error occurred during export');
|
||
} finally {
|
||
setIsExporting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={!hasData || isExporting}
|
||
>
|
||
{isExporting ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Exporting...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
Export
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[220px]">
|
||
<DropdownMenuLabel>Export to CSV</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
|
||
<DropdownMenuItem
|
||
onClick={handleExportSelected}
|
||
disabled={!hasSelection}
|
||
>
|
||
<div className="flex flex-col gap-1">
|
||
<span className="font-medium">Export Selected</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{hasSelection ? `${selectedCount} rows` : 'No rows selected'}
|
||
</span>
|
||
</div>
|
||
</DropdownMenuItem>
|
||
|
||
<DropdownMenuItem
|
||
onClick={handleExportAll}
|
||
disabled={!hasData || isExporting}
|
||
>
|
||
<div className="flex flex-col gap-1">
|
||
<span className="font-medium">Export All Filtered</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{exceedsLimit
|
||
? `${totalCount} rows (limited to 5000)`
|
||
: `${totalCount} rows`}
|
||
</span>
|
||
</div>
|
||
</DropdownMenuItem>
|
||
|
||
{exceedsLimit && (
|
||
<>
|
||
<DropdownMenuSeparator />
|
||
<div className="px-2 py-1.5 text-xs text-amber-600">
|
||
⚠️ Results exceed 5000 rows. Export will be limited.
|
||
</div>
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|