feat: ExportEngagementButton + exports API client
Add split-button dropdown [Export ▼] on EngagementDetailPage that downloads engagement as Markdown, CSV, or PDF via GET /api/engagements/<id>/export?format=md|csv|pdf. Both halves open the dropdown (no default left-click action). RBAC-gated with canEditEngagements (admin + redteam only). Loading state per item, toast on error, click-outside + Escape close. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
100
frontend/src/components/ExportEngagementButton.tsx
Normal file
100
frontend/src/components/ExportEngagementButton.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||
import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface ExportEngagementButtonProps {
|
||||
engagementId: number;
|
||||
}
|
||||
|
||||
const FORMATS: { label: string; value: ExportFormat }[] = [
|
||||
{ label: 'Markdown', value: 'md' },
|
||||
{ label: 'CSV', value: 'csv' },
|
||||
{ label: 'PDF', value: 'pdf' },
|
||||
];
|
||||
|
||||
export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element {
|
||||
const { push } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState<ExportFormat | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleDownload = async (format: ExportFormat) => {
|
||||
setLoading(format);
|
||||
try {
|
||||
await downloadEngagementExport(engagementId, format);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
push(err instanceof Error ? err.message : 'Export failed', 'error');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref} data-testid="export-dropdown">
|
||||
<div className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline rounded-r-none border-r-0"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
data-testid="export-btn"
|
||||
>
|
||||
<Download size={14} aria-hidden /> Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Export options"
|
||||
aria-expanded={open}
|
||||
className="btn-outline rounded-l-none px-sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
data-testid="export-dropdown-toggle"
|
||||
>
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[160px]"
|
||||
role="menu"
|
||||
>
|
||||
{FORMATS.map(({ label, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={loading !== null}
|
||||
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog flex items-center gap-sm disabled:opacity-50"
|
||||
onClick={() => handleDownload(value)}
|
||||
data-testid={`export-format-${value}`}
|
||||
>
|
||||
{loading === value ? (
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user