Files
experiments-database/frontend/src/components/ExportDataModal.jsx
T

141 lines
5.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
import { listExportableParams, buildSubjectSeriesMatrix, toCSV, csvFilename } from '../lib/dataExport';
const PARAM_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);
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export default function ExportDataModal({
experimentTitle, experimentId, dailyTemplate, animals, subjectTemplate = [], groupField = '__none__', onClose,
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statuses, setStatuses] = useState([]);
const [xField, setXField] = useState('__date__');
const [dataId, setDataId] = useState(null);
const [groupBy, setGroupBy] = useState(() => {
const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__'
|| subjectTemplate.some((f) => f.active && f.fieldId === groupField);
return valid ? groupField : '__none__';
});
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]);
const effDataId = dataId ?? params[0]?.id ?? null;
const dataParam = params.find((p) => p.id === effDataId) ?? params[0] ?? null;
const activeDaily = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
const xOptions = useMemo(() => [
{ id: '__date__', label: 'Date' },
{ id: '__days__', label: '# Days Reach' },
...activeDaily.map((f) => ({ id: f.fieldId, label: f.label })),
], [activeDaily]);
const matrix = useMemo(
() => (dataParam
? buildSubjectSeriesMatrix({ xField, dataParam, groupField: groupBy }, { statuses, animals, dailyTemplate })
: { columns: [], rows: [], context: { label: 'Data', value: '' }, corner: '', groupAxis: null }),
[xField, dataParam, groupBy, statuses, animals, dailyTemplate],
);
const hasData = matrix.rows.length > 0;
const groupedParams = 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 || !dataParam) return;
triggerDownload(csvFilename(experimentTitle, dataParam.label), toCSV(matrix));
onClose();
}
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data</p>;
if (error) return (
<div className="space-y-4">
<Alert type="error" message={error} />
<div className="flex justify-end"><Button variant="secondary" onClick={onClose}>Close</Button></div>
</div>
);
const selectCls = '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';
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
One value per subject over an X axis each column is a subject, each row an X value. Blank where a subject has no value.
</p>
<div>
<label htmlFor="export-x" className="block text-xs font-medium text-gray-500 mb-1">X axis (rows)</label>
<select id="export-x" value={xField} onChange={(e) => setXField(e.target.value)} className={selectCls}>
{xOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</div>
<div>
<label htmlFor="export-data" className="block text-xs font-medium text-gray-500 mb-1">Data (cells)</label>
<select id="export-data" value={effDataId ?? ''} onChange={(e) => setDataId(e.target.value)} className={selectCls}>
{groupedParams.map((g) => (
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</optgroup>
))}
</select>
</div>
<div>
<label htmlFor="export-group" className="block text-xs font-medium text-gray-500 mb-1">Group by</label>
<select id="export-group" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={selectCls}>
<option value="__none__"> None </option>
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
</div>
<p className="text-xs text-gray-400">
{hasData
? `${matrix.rows.length} row${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
: 'No data for this selection 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>
);
}