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:
56
frontend/src/api/exports.ts
Normal file
56
frontend/src/api/exports.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export type ExportFormat = 'md' | 'csv' | 'pdf';
|
||||||
|
|
||||||
|
export function parseContentDispositionFilename(header: string | undefined): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const match = header.match(/filename="([^"]+)"/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseBlobError(err: unknown): Promise<string> {
|
||||||
|
if (axios.isAxiosError(err) && err.response?.data instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const text = await (err.response.data as Blob).text();
|
||||||
|
const parsed = JSON.parse(text) as { error?: string };
|
||||||
|
if (parsed.error) return parsed.error;
|
||||||
|
} catch {
|
||||||
|
// fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return 'Export failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadEngagementExport(
|
||||||
|
engagementId: number,
|
||||||
|
format: ExportFormat,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/engagements/${engagementId}/export`, {
|
||||||
|
params: { format },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
let filename = parseContentDispositionFilename(
|
||||||
|
response.headers['content-disposition'] as string | undefined,
|
||||||
|
);
|
||||||
|
if (!filename) {
|
||||||
|
const ext = format === 'md' ? 'md' : format === 'csv' ? 'csv' : 'pdf';
|
||||||
|
filename = `engagement-${engagementId}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(response.data as Blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
const message = await parseBlobError(err);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { LoadingState } from '@/components/LoadingState';
|
|||||||
import { ErrorState } from '@/components/ErrorState';
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
import { StatusBadge } from '@/components/StatusBadge';
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
import { SimulationList } from '@/components/SimulationList';
|
import { SimulationList } from '@/components/SimulationList';
|
||||||
|
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
|
||||||
|
|
||||||
export function EngagementDetailPage(): JSX.Element {
|
export function EngagementDetailPage(): JSX.Element {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -43,9 +44,12 @@ export function EngagementDetailPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canEditEngagements ? (
|
{canEditEngagements ? (
|
||||||
|
<div className="flex items-center gap-sm">
|
||||||
|
<ExportEngagementButton engagementId={eng.id} />
|
||||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
|
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user