feat(policy-explorer-v2): implement Phase 5 - Bulk CSV Export
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:
Ahmed Darrazi 2025-12-10 00:30:33 +01:00
parent c59400cd48
commit 4288bc7884
4 changed files with 333 additions and 1 deletions

View File

@ -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 */}

View 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>
);
}

View File

@ -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
View 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`;
}