feat(frontend): ExportDataModal — pick a parameter and download CSV
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { experimentsApi } from '../api/client';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { listExportableParams, buildMatrix, toCSV, csvFilename } from '../lib/dataExport';
|
||||||
|
|
||||||
|
const GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
|
||||||
|
|
||||||
|
function triggerDownload(filename, text) {
|
||||||
|
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportDataModal({ experimentTitle, experimentId, dailyTemplate, animals, onClose }) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [statuses, setStatuses] = useState([]);
|
||||||
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
experimentsApi.getDailyStatuses(experimentId, {})
|
||||||
|
.then((data) => { if (alive) setStatuses(data); })
|
||||||
|
.catch((err) => { if (alive) setError(err.message); })
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [experimentId]);
|
||||||
|
|
||||||
|
const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
|
||||||
|
|
||||||
|
// Default the selection to the first parameter once params are available.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId === null && params.length > 0) setSelectedId(params[0].id);
|
||||||
|
}, [params, selectedId]);
|
||||||
|
|
||||||
|
const selectedParam = params.find((p) => p.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
const matrix = useMemo(
|
||||||
|
() => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }),
|
||||||
|
[statuses, animals, selectedParam],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasData = matrix.rows.length > 0;
|
||||||
|
|
||||||
|
// Group params for <optgroup> rendering, preserving order.
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const out = [];
|
||||||
|
for (const p of params) {
|
||||||
|
let g = out.find((x) => x.group === p.group);
|
||||||
|
if (!g) { g = { group: p.group, items: [] }; out.push(g); }
|
||||||
|
g.items.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
if (!hasData || !selectedParam) return;
|
||||||
|
triggerDownload(csvFilename(experimentTitle, selectedParam.label), toCSV(matrix));
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data…</p>;
|
||||||
|
if (error) return <Alert type="error" message={error} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Export one parameter as a CSV file — each row is a date, each column is a subject.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="export-param" className="block text-xs font-medium text-gray-500 mb-1">Parameter</label>
|
||||||
|
<select
|
||||||
|
id="export-param"
|
||||||
|
value={selectedId ?? ''}
|
||||||
|
onChange={(e) => setSelectedId(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400"
|
||||||
|
>
|
||||||
|
{grouped.map((g) => (
|
||||||
|
<optgroup key={g.group} label={GROUP_LABELS[g.group] ?? g.group}>
|
||||||
|
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{hasData
|
||||||
|
? `${matrix.rows.length} date${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
|
||||||
|
: 'No data for this parameter yet.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user