feat: session file registry with drag-and-drop on day view
Adds a FileDropZone at the bottom of the day view where users can drop all session files at once. Filenames are regex-matched to subjects using their animal_id_string (word-boundary aware, so "2852" won't match "28520"). Only metadata is recorded — files are never uploaded. Backend: new session_files table (id, daily_status_id, filename, file_size BIGINT, file_type, last_modified, notes). Routes: POST /api/daily-statuses/:id/files (batch create) GET /api/daily-statuses/:id/files DELETE /api/session-files/:id Frontend: FileDropZone shows per-subject match groups, unmatched files, and a warning for subjects with no match. After saving, registered files are displayed with delete capability. test-files/: generate_test_files.sh creates 4 dummy files per subject (2×bin, 2×csv) with correct naming for drag-and-drop testing. Pre-generated for 2026-04-23 with IDs 2852/3076/3077/3078. Tests: 19 tests covering drag state, regex matching, save payload, partial-overlap prevention, multi-subject batching, and delete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,13 @@ export const systemApi = {
|
||||
info: () => api.get('/info').then((r) => r.data),
|
||||
};
|
||||
|
||||
// ── Session Files ─────────────────────────────────────────────────────────────
|
||||
export const sessionFilesApi = {
|
||||
list: (statusId) => api.get(`/daily-statuses/${statusId}/files`).then((r) => r.data),
|
||||
create: (statusId, files) => api.post(`/daily-statuses/${statusId}/files`, files).then((r) => r.data),
|
||||
delete: (id) => api.delete(`/session-files/${id}`),
|
||||
};
|
||||
|
||||
// ── Analyses ──────────────────────────────────────────────────────────────────
|
||||
export const analysesApi = {
|
||||
listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { sessionFilesApi } from '../api/client';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a regex that matches an animal's identifier in a filename.
|
||||
* Matches only when the ID is not surrounded by other alphanumeric chars,
|
||||
* so "2852" matches "rat_2852_session" but not "28520" or "X2852Y".
|
||||
*/
|
||||
function buildAnimalRegex(animal) {
|
||||
const ids = [];
|
||||
if (animal.animal_id_string) ids.push(escapeRegex(animal.animal_id_string));
|
||||
if (animal.animal_name) ids.push(escapeRegex(animal.animal_name));
|
||||
if (ids.length === 0) return null;
|
||||
return new RegExp(`(?<![a-zA-Z0-9])(${ids.join('|')})(?![a-zA-Z0-9])`, 'i');
|
||||
}
|
||||
|
||||
function classifyFile(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const MAP = { csv: 'CSV', tsv: 'TSV', h5: 'HDF5', hdf5: 'HDF5', nwb: 'NWB', bin: 'Binary', dat: 'Binary', raw: 'Binary', npy: 'NumPy', npz: 'NumPy', mat: 'MATLAB', pkl: 'Pickle' };
|
||||
return MAP[ext] ?? 'Binary';
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
const num = typeof n === 'bigint' ? Number(n) : Number(n);
|
||||
if (num >= 1e9) return `${(num / 1e9).toFixed(1)} GB`;
|
||||
if (num >= 1e6) return `${(num / 1e6).toFixed(1)} MB`;
|
||||
if (num >= 1e3) return `${(num / 1e3).toFixed(1)} KB`;
|
||||
return `${num} B`;
|
||||
}
|
||||
|
||||
/** Match an array of File objects against animals. Returns { matched: Map<animalId, File[]>, unmatched: File[] } */
|
||||
function matchFilesToAnimals(files, animals) {
|
||||
const patterns = animals.map((a) => ({ animal: a, re: buildAnimalRegex(a) })).filter((x) => x.re);
|
||||
const matched = new Map(animals.map((a) => [a.id, []]));
|
||||
const unmatched = [];
|
||||
|
||||
for (const file of files) {
|
||||
const hit = patterns.find(({ re }) => re.test(file.name));
|
||||
if (hit) {
|
||||
matched.get(hit.animal.id).push(file);
|
||||
} else {
|
||||
unmatched.push(file);
|
||||
}
|
||||
}
|
||||
return { matched, unmatched };
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function FileRow({ file, onRemove }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
||||
<span className="font-mono text-gray-700 truncate min-w-0 flex-1">{file.name}</span>
|
||||
<span className="text-gray-400 shrink-0">{fmtBytes(file.size)}</span>
|
||||
<span className="text-gray-400 shrink-0 w-12 text-right">{classifyFile(file.name)}</span>
|
||||
{onRemove && (
|
||||
<button type="button" onClick={onRemove}
|
||||
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1">✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedFileRow({ file, onDelete, deleting }) {
|
||||
const size = typeof file.file_size === 'string' ? BigInt(file.file_size) : file.file_size;
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
||||
<span className="font-mono text-gray-600 truncate min-w-0 flex-1">{file.filename}</span>
|
||||
<span className="text-gray-400 shrink-0">{fmtBytes(size)}</span>
|
||||
<span className="text-gray-400 shrink-0 w-12 text-right">{file.file_type ?? '?'}</span>
|
||||
<button type="button" onClick={onDelete} disabled={deleting}
|
||||
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1 disabled:opacity-40">✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* FileDropZone
|
||||
*
|
||||
* Props:
|
||||
* rows — array of { animal, status } from ExperimentDayView
|
||||
* (only rows with a status are shown; status.id is required to save files)
|
||||
* date — YYYY-MM-DD string, shown in naming hint
|
||||
*/
|
||||
export default function FileDropZone({ rows, date }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dropped, setDropped] = useState(null); // { matched: Map, unmatched: File[] } | null
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const [savedByStatus, setSavedByStatus] = useState({}); // statusId → SavedFile[]
|
||||
const [loadingIds, setLoadingIds] = useState(new Set());
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const statusRows = rows.filter((r) => r.status);
|
||||
const animals = statusRows.map((r) => r.animal);
|
||||
|
||||
// Load existing saved files for all statuses in this day view
|
||||
useEffect(() => {
|
||||
if (statusRows.length === 0) return;
|
||||
const ids = statusRows.map((r) => r.status.id);
|
||||
setLoadingIds(new Set(ids));
|
||||
Promise.all(
|
||||
ids.map((sid) => sessionFilesApi.list(sid).then((files) => ({ sid, files })))
|
||||
).then((results) => {
|
||||
const map = {};
|
||||
results.forEach(({ sid, files }) => { map[sid] = files; });
|
||||
setSavedByStatus(map);
|
||||
setLoadingIds(new Set());
|
||||
}).catch(() => setLoadingIds(new Set()));
|
||||
}, [statusRows.map((r) => r.status?.id).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Drag handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
const onDragEnter = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current += 1;
|
||||
if (dragCounter.current === 1) setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const onDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current === 0) setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const onDragOver = useCallback((e) => { e.preventDefault(); }, []);
|
||||
|
||||
const onDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
setDropped(matchFilesToAnimals(files, animals));
|
||||
setSaveError(null);
|
||||
}, [animals]);
|
||||
|
||||
function removeMatchedFile(animalId, fileIdx) {
|
||||
setDropped((prev) => {
|
||||
const next = new Map(prev.matched);
|
||||
const arr = [...next.get(animalId)];
|
||||
arr.splice(fileIdx, 1);
|
||||
next.set(animalId, arr);
|
||||
return { matched: next, unmatched: prev.unmatched };
|
||||
});
|
||||
}
|
||||
|
||||
function removeUnmatched(idx) {
|
||||
setDropped((prev) => {
|
||||
const u = [...prev.unmatched];
|
||||
u.splice(idx, 1);
|
||||
return { matched: prev.matched, unmatched: u };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
if (!dropped) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
statusRows.map(async ({ animal, status }) => {
|
||||
const files = dropped.matched.get(animal.id) ?? [];
|
||||
if (files.length === 0) return { statusId: status.id, saved: [] };
|
||||
const payload = files.map((f) => ({
|
||||
filename: f.name,
|
||||
file_size: f.size,
|
||||
file_type: classifyFile(f.name),
|
||||
last_modified: f.lastModified ?? null,
|
||||
}));
|
||||
const saved = await sessionFilesApi.create(status.id, payload);
|
||||
return { statusId: status.id, saved };
|
||||
})
|
||||
);
|
||||
setSavedByStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
results.forEach(({ statusId, saved }) => {
|
||||
next[statusId] = [...(next[statusId] ?? []), ...saved];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setDropped(null);
|
||||
} catch (err) {
|
||||
setSaveError(err.message ?? 'Failed to save file metadata');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteFile(fileId, statusId) {
|
||||
setDeletingId(fileId);
|
||||
try {
|
||||
await sessionFilesApi.delete(fileId);
|
||||
setSavedByStatus((prev) => ({
|
||||
...prev,
|
||||
[statusId]: prev[statusId].filter((f) => f.id !== fileId),
|
||||
}));
|
||||
} catch {
|
||||
// silently ignore — UI stays consistent, user can retry
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived counts ─────────────────────────────────────────────────────────
|
||||
|
||||
const totalDropped = dropped ? [...dropped.matched.values()].reduce((n, a) => n + a.length, 0) : 0;
|
||||
const totalUnmatched = dropped?.unmatched.length ?? 0;
|
||||
const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0);
|
||||
const hasSaved = totalSaved > 0;
|
||||
const hasMatchedDropped = totalDropped > 0;
|
||||
|
||||
// Hint string for naming convention
|
||||
const sampleId = animals[0]?.animal_id_string || animals[0]?.animal_name || '<id>';
|
||||
const namingHint = `${date}_${sampleId}_session1.csv`;
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session File Registry</h3>
|
||||
<p className="text-xs text-gray-400 mb-4">
|
||||
Drop all session files here. Filenames are matched to subjects by ID —
|
||||
only metadata (name, size) is recorded; files are never uploaded.
|
||||
<br />
|
||||
Naming convention: <span className="font-mono text-gray-500">{namingHint}</span>
|
||||
</p>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
data-testid="drop-zone"
|
||||
className={[
|
||||
'border-2 border-dashed rounded-xl px-6 py-10 text-center transition-all cursor-default select-none',
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{isDragging ? (
|
||||
<p className="text-blue-600 font-medium text-sm">Release to register files</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-400 text-sm">Drag & drop session files here</p>
|
||||
<p className="text-gray-300 text-xs mt-1">All subjects at once · files are not uploaded</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropped files preview */}
|
||||
{dropped && (
|
||||
<div className="mt-4 space-y-4" data-testid="drop-preview">
|
||||
|
||||
{/* Per-subject matched files */}
|
||||
{statusRows.map(({ animal, status }) => {
|
||||
const files = dropped.matched.get(animal.id) ?? [];
|
||||
if (files.length === 0) return null;
|
||||
return (
|
||||
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{animal.animal_name}
|
||||
{animal.animal_id_string && (
|
||||
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{files.length} file{files.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{files.map((f, i) => (
|
||||
<FileRow key={f.name + i} file={f} onRemove={() => removeMatchedFile(animal.id, i)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Subjects with no matched files */}
|
||||
{statusRows.some(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) && (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-amber-700 mb-1">No files matched for:</p>
|
||||
{statusRows
|
||||
.filter(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0)
|
||||
.map(({ animal }) => (
|
||||
<span key={animal.id} className="inline-block text-xs text-amber-600 bg-amber-100 rounded px-2 py-0.5 mr-1 mb-1 font-mono">
|
||||
{animal.animal_id_string || animal.animal_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unmatched files */}
|
||||
{dropped.unmatched.length > 0 && (
|
||||
<div className="bg-gray-50 border border-dashed border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-gray-500 mb-2">
|
||||
Unmatched ({dropped.unmatched.length}) — no subject ID found in filename
|
||||
</p>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{dropped.unmatched.map((f, i) => (
|
||||
<FileRow key={f.name + i} file={f} onRemove={() => removeUnmatched(i)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<p className="text-xs text-red-500 px-1">{saveError}</p>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
{hasMatchedDropped && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
data-testid="save-btn"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : `Register ${totalDropped} file${totalDropped !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDropped(null); setSaveError(null); }}
|
||||
className="text-sm text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved files */}
|
||||
{hasSaved && (
|
||||
<div className="mt-6 space-y-3" data-testid="saved-files">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Registered files ({totalSaved})
|
||||
</p>
|
||||
{statusRows.map(({ animal, status }) => {
|
||||
const files = savedByStatus[status.id] ?? [];
|
||||
if (files.length === 0) return null;
|
||||
return (
|
||||
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{animal.animal_name}
|
||||
{animal.animal_id_string && (
|
||||
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{files.length} file{files.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{files.map((f) => (
|
||||
<SavedFileRow
|
||||
key={f.id}
|
||||
file={f}
|
||||
onDelete={() => deleteFile(f.id, status.id)}
|
||||
deleting={deletingId === f.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Button from '../components/ui/Button';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import DailyStatusForm from '../components/DailyStatusForm';
|
||||
import Alert from '../components/ui/Alert';
|
||||
import FileDropZone from '../components/FileDropZone';
|
||||
|
||||
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||
|
||||
@@ -367,6 +368,9 @@ export default function ExperimentDayView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session file drop zone */}
|
||||
<FileDropZone rows={rows} date={date} />
|
||||
|
||||
{/* Edit modal (full row) */}
|
||||
{editStatus && (
|
||||
<Modal
|
||||
|
||||
Reference in New Issue
Block a user