diff --git a/app/(app)/search/PolicyExplorerV2Client.tsx b/app/(app)/search/PolicyExplorerV2Client.tsx index 701ee44..3c05af5 100644 --- a/app/(app)/search/PolicyExplorerV2Client.tsx +++ b/app/(app)/search/PolicyExplorerV2Client.tsx @@ -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 */} diff --git a/components/policy-explorer/ExportButton.tsx b/components/policy-explorer/ExportButton.tsx new file mode 100644 index 0000000..3aaa1d4 --- /dev/null +++ b/components/policy-explorer/ExportButton.tsx @@ -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 ( + + + + + + Export to CSV + + + +
+ Export Selected + + {hasSelection ? `${selectedCount} rows` : 'No rows selected'} + +
+
+ + +
+ Export All Filtered + + {exceedsLimit + ? `${totalCount} rows (limited to 5000)` + : `${totalCount} rows`} + +
+
+ + {exceedsLimit && ( + <> + +
+ ⚠️ Results exceed 5000 rows. Export will be limited. +
+ + )} +
+
+ ); +} diff --git a/components/policy-explorer/PolicyTableToolbar.tsx b/components/policy-explorer/PolicyTableToolbar.tsx index 84a1a78..7d11a1b 100644 --- a/components/policy-explorer/PolicyTableToolbar.tsx +++ b/components/policy-explorer/PolicyTableToolbar.tsx @@ -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 */} - {/* Export button will be added here in Phase 5 */} + {/* Export Button */} + ); diff --git a/lib/utils/csv-export.ts b/lib/utils/csv-export.ts new file mode 100644 index 0000000..dc7cee0 --- /dev/null +++ b/lib/utils/csv-export.ts @@ -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 +): string { + // Default columns in preferred order + const defaultColumns: Array = [ + 'settingName', + 'settingValue', + 'policyName', + 'policyType', + 'graphPolicyId', + 'lastSyncedAt', + ]; + + const columnsToExport = columns || defaultColumns; + + // Column headers (human-readable) + const headers: Record = { + 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`; +}