feat: sprint 6 — engagement export (md/csv/pdf) #9

Merged
knacky merged 20 commits from sprint/6-export into main 2026-06-09 16:19:02 +00:00
3 changed files with 163 additions and 3 deletions
Showing only changes of commit 100441bdeb - Show all commits

View 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);
}
}

View 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>
);
}

View File

@@ -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 ? (
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline"> <div className="flex items-center gap-sm">
Edit <ExportEngagementButton engagementId={eng.id} />
</Link> <Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
Edit
</Link>
</div>
) : null} ) : null}
</header> </header>