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
142 lines
3.9 KiB
TypeScript
142 lines
3.9 KiB
TypeScript
/**
|
|
* 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`;
|
|
}
|