feat(policy-explorer-v2): implement Phase 5 - Bulk CSV Export
All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
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
This commit is contained in:
parent
c59400cd48
commit
4288bc7884
@ -151,6 +151,11 @@ export function PolicyExplorerV2Client() {
|
||||
onSelectedPolicyTypesChange={urlState.updatePolicyTypes}
|
||||
searchQuery={urlState.searchQuery}
|
||||
onSearchQueryChange={urlState.updateSearchQuery}
|
||||
selectedRows={selectedRows}
|
||||
selectedCount={selectedCount}
|
||||
totalCount={totalCount}
|
||||
sortBy={urlState.sortBy}
|
||||
sortDir={urlState.sortDir}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
|
||||
165
components/policy-explorer/ExportButton.tsx
Normal file
165
components/policy-explorer/ExportButton.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ColumnVisibilityMenu } from './ColumnVisibilityMenu';
|
||||
import { PolicyTypeFilter } from './PolicyTypeFilter';
|
||||
import { ExportButton } from './ExportButton';
|
||||
import { LayoutList, LayoutGrid, X } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
@ -27,6 +28,12 @@ interface PolicyTableToolbarProps {
|
||||
onSelectedPolicyTypesChange: (types: string[]) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
// Export props
|
||||
selectedRows: PolicySettingRow[];
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export function PolicyTableToolbar({
|
||||
@ -37,6 +44,11 @@ export function PolicyTableToolbar({
|
||||
onSelectedPolicyTypesChange,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
selectedRows,
|
||||
selectedCount,
|
||||
totalCount,
|
||||
sortBy,
|
||||
sortDir,
|
||||
}: PolicyTableToolbarProps) {
|
||||
const hasActiveFilters = selectedPolicyTypes.length > 0 || searchQuery.length > 0;
|
||||
|
||||
@ -121,7 +133,16 @@ export function PolicyTableToolbar({
|
||||
{/* Column Visibility Menu */}
|
||||
<ColumnVisibilityMenu table={table} />
|
||||
|
||||
{/* Export button will be added here in Phase 5 */}
|
||||
{/* Export Button */}
|
||||
<ExportButton
|
||||
selectedRows={selectedRows}
|
||||
selectedCount={selectedCount}
|
||||
totalCount={totalCount}
|
||||
policyTypes={selectedPolicyTypes}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
sortDir={sortDir}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
141
lib/utils/csv-export.ts
Normal file
141
lib/utils/csv-export.ts
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* CSV Export Utilities
|
||||
*
|
||||
* Client-side CSV generation for policy settings export.
|
||||
*
|
||||
* Features:
|
||||
* - RFC 4180 compliant CSV formatting
|
||||
* - Proper escaping (commas, quotes, newlines)
|
||||
* - UTF-8 BOM for Excel compatibility
|
||||
* - Efficient string building
|
||||
*/
|
||||
|
||||
import type { PolicySettingRow } from '@/lib/types/policy-table';
|
||||
|
||||
/**
|
||||
* Escape a CSV field value according to RFC 4180
|
||||
* - Wrap in quotes if contains comma, quote, or newline
|
||||
* - Double any quotes inside the value
|
||||
*/
|
||||
function escapeCsvField(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
|
||||
// Check if escaping is needed
|
||||
const needsEscaping = stringValue.includes(',') ||
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes('\n') ||
|
||||
stringValue.includes('\r');
|
||||
|
||||
if (!needsEscaping) {
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
// Double any quotes and wrap in quotes
|
||||
const escaped = stringValue.replace(/"/g, '""');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV content from policy settings
|
||||
* @param rows Array of policy settings to export
|
||||
* @param columns Optional array of column keys to include (default: all)
|
||||
* @returns CSV content as string with UTF-8 BOM
|
||||
*/
|
||||
export function generatePolicySettingsCsv(
|
||||
rows: PolicySettingRow[],
|
||||
columns?: Array<keyof PolicySettingRow>
|
||||
): string {
|
||||
// Default columns in preferred order
|
||||
const defaultColumns: Array<keyof PolicySettingRow> = [
|
||||
'settingName',
|
||||
'settingValue',
|
||||
'policyName',
|
||||
'policyType',
|
||||
'graphPolicyId',
|
||||
'lastSyncedAt',
|
||||
];
|
||||
|
||||
const columnsToExport = columns || defaultColumns;
|
||||
|
||||
// Column headers (human-readable)
|
||||
const headers: Record<keyof PolicySettingRow, string> = {
|
||||
id: 'ID',
|
||||
tenantId: 'Tenant ID',
|
||||
settingName: 'Setting Name',
|
||||
settingValue: 'Setting Value',
|
||||
policyName: 'Policy Name',
|
||||
policyType: 'Policy Type',
|
||||
graphPolicyId: 'Graph Policy ID',
|
||||
lastSyncedAt: 'Last Synced',
|
||||
createdAt: 'Created At',
|
||||
};
|
||||
|
||||
// Build CSV rows
|
||||
const csvLines: string[] = [];
|
||||
|
||||
// Header row
|
||||
const headerRow = columnsToExport.map(col => escapeCsvField(headers[col])).join(',');
|
||||
csvLines.push(headerRow);
|
||||
|
||||
// Data rows
|
||||
for (const row of rows) {
|
||||
const dataRow = columnsToExport.map(col => {
|
||||
const value = row[col];
|
||||
|
||||
// Format dates
|
||||
if (value instanceof Date) {
|
||||
return escapeCsvField(value.toISOString());
|
||||
}
|
||||
|
||||
// Format other values
|
||||
return escapeCsvField(String(value));
|
||||
}).join(',');
|
||||
|
||||
csvLines.push(dataRow);
|
||||
}
|
||||
|
||||
// Join with newlines and add UTF-8 BOM for Excel compatibility
|
||||
const csvContent = csvLines.join('\n');
|
||||
const utf8Bom = '\uFEFF';
|
||||
return utf8Bom + csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger browser download of CSV content
|
||||
* @param csvContent CSV content string
|
||||
* @param filename Suggested filename (e.g., "policy-settings.csv")
|
||||
*/
|
||||
export function downloadCsv(csvContent: string, filename: string): void {
|
||||
// Create blob with proper MIME type
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate filename with timestamp
|
||||
* @param prefix Filename prefix (e.g., "policy-settings")
|
||||
* @param selectedCount Optional count of selected rows
|
||||
* @returns Filename with timestamp (e.g., "policy-settings-2025-12-10.csv")
|
||||
*/
|
||||
export function generateCsvFilename(prefix: string, selectedCount?: number): string {
|
||||
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const suffix = selectedCount ? `-selected-${selectedCount}` : '';
|
||||
return `${prefix}${suffix}-${date}.csv`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user