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}
|
onSelectedPolicyTypesChange={urlState.updatePolicyTypes}
|
||||||
searchQuery={urlState.searchQuery}
|
searchQuery={urlState.searchQuery}
|
||||||
onSearchQueryChange={urlState.updateSearchQuery}
|
onSearchQueryChange={urlState.updateSearchQuery}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
totalCount={totalCount}
|
||||||
|
sortBy={urlState.sortBy}
|
||||||
|
sortDir={urlState.sortDir}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* 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 { Button } from '@/components/ui/button';
|
||||||
import { ColumnVisibilityMenu } from './ColumnVisibilityMenu';
|
import { ColumnVisibilityMenu } from './ColumnVisibilityMenu';
|
||||||
import { PolicyTypeFilter } from './PolicyTypeFilter';
|
import { PolicyTypeFilter } from './PolicyTypeFilter';
|
||||||
|
import { ExportButton } from './ExportButton';
|
||||||
import { LayoutList, LayoutGrid, X } from 'lucide-react';
|
import { LayoutList, LayoutGrid, X } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { Table } from '@tanstack/react-table';
|
import type { Table } from '@tanstack/react-table';
|
||||||
@ -27,6 +28,12 @@ interface PolicyTableToolbarProps {
|
|||||||
onSelectedPolicyTypesChange: (types: string[]) => void;
|
onSelectedPolicyTypesChange: (types: string[]) => void;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onSearchQueryChange: (query: string) => void;
|
onSearchQueryChange: (query: string) => void;
|
||||||
|
// Export props
|
||||||
|
selectedRows: PolicySettingRow[];
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDir?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PolicyTableToolbar({
|
export function PolicyTableToolbar({
|
||||||
@ -37,6 +44,11 @@ export function PolicyTableToolbar({
|
|||||||
onSelectedPolicyTypesChange,
|
onSelectedPolicyTypesChange,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
|
selectedRows,
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
}: PolicyTableToolbarProps) {
|
}: PolicyTableToolbarProps) {
|
||||||
const hasActiveFilters = selectedPolicyTypes.length > 0 || searchQuery.length > 0;
|
const hasActiveFilters = selectedPolicyTypes.length > 0 || searchQuery.length > 0;
|
||||||
|
|
||||||
@ -121,7 +133,16 @@ export function PolicyTableToolbar({
|
|||||||
{/* Column Visibility Menu */}
|
{/* Column Visibility Menu */}
|
||||||
<ColumnVisibilityMenu table={table} />
|
<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>
|
||||||
</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