feat: add experiment calendar view with per-day subject counts
New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.
Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
subjects with a non-null/non-empty value for the chosen field on that date.
Supports both builtin fields (vitals, treatment, notes, experiment_description)
and custom fields addressed by fieldId UUID.
Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
(defaults to first field with "weight" in label, else first active field),
blue badges showing subject count per day, click to open modal with all
subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.
Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
count, custom field count, whitespace ignored, multi-month, field echo,
missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
rendering, default field selection, count badges, month navigation,
field-change re-fetch, day click modal, and multi-subject display.
All 47 backend + 13 calendar frontend tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,52 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { experimentsApi, animalsApi } from '../api/client';
|
||||
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
|
||||
import Button from '../components/ui/Button';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import AnimalForm from '../components/AnimalForm';
|
||||
import TemplateEditor from '../components/TemplateEditor';
|
||||
import Alert from '../components/ui/Alert';
|
||||
import AuditLogSection from '../components/AuditLogSection';
|
||||
import ExperimentCalendar from '../components/ExperimentCalendar';
|
||||
|
||||
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{animals.map((animal) => {
|
||||
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
|
||||
return (
|
||||
<div
|
||||
key={animal.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
||||
onClick={() => onNavigate(animal)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||
{animal.animal_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
||||
</div>
|
||||
{infoFields.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
|
||||
{infoFields.map((f) => (
|
||||
<span key={f.fieldId} className="text-xs text-gray-400">
|
||||
<span className="font-medium text-gray-500">{f.label}:</span> {animal.subject_info[f.fieldId]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="sm" variant="secondary" onClick={() => onEdit(animal)}>Edit</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExperimentDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -21,20 +60,48 @@ export default function ExperimentDetail() {
|
||||
|
||||
const [showAddAnimal, setShowAddAnimal] = useState(false);
|
||||
const [editAnimal, setEditAnimal] = useState(null);
|
||||
const [deleteAnimal, setDeleteAnimal] = useState(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [showTemplate, setShowTemplate] = useState(false);
|
||||
const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);
|
||||
const [subjectTemplate, setSubjectTemplate] = useState([]);
|
||||
const [dailyTemplate, setDailyTemplate] = useState([]);
|
||||
const [experimentStatuses, setExperimentStatuses] = useState([]);
|
||||
|
||||
const [displayMode, setDisplayMode] = useState(() => localStorage.getItem(`exp-display-mode-${id}`) ?? 'list');
|
||||
const [sortField, setSortField] = useState(() => localStorage.getItem(`exp-subject-sort-${id}`) ?? '__name__');
|
||||
const [sortDir, setSortDir] = useState(() => localStorage.getItem(`exp-subject-sort-dir-${id}`) ?? 'asc');
|
||||
const [groupField, setGroupField] = useState(() => localStorage.getItem(`exp-subject-group-${id}`) ?? '__name__');
|
||||
|
||||
function updateDisplayMode(mode) {
|
||||
setDisplayMode(mode);
|
||||
localStorage.setItem(`exp-display-mode-${id}`, mode);
|
||||
}
|
||||
function updateSort(field, dir) {
|
||||
setSortField(field);
|
||||
setSortDir(dir);
|
||||
localStorage.setItem(`exp-subject-sort-${id}`, field);
|
||||
localStorage.setItem(`exp-subject-sort-dir-${id}`, dir);
|
||||
}
|
||||
function updateGroupField(field) {
|
||||
setGroupField(field);
|
||||
localStorage.setItem(`exp-subject-group-${id}`, field);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [exp, anims] = await Promise.all([
|
||||
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
|
||||
experimentsApi.get(id),
|
||||
animalsApi.list(id),
|
||||
experimentsApi.getSubjectTemplate(id),
|
||||
experimentsApi.getTemplate(id),
|
||||
experimentsApi.getDailyStatuses(id),
|
||||
]);
|
||||
setExperiment(exp);
|
||||
setAnimals(anims);
|
||||
setSubjectTemplate(subjTmpl);
|
||||
setDailyTemplate(dailyTmpl);
|
||||
setExperimentStatuses(expStatuses);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -56,21 +123,6 @@ export default function ExperimentDetail() {
|
||||
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
|
||||
}
|
||||
|
||||
async function handleDeleteAnimal() {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await animalsApi.delete(deleteAnimal.id);
|
||||
setDeleteAnimal(null);
|
||||
load();
|
||||
flash('Animal removed.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDeleteAnimal(null);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemplateSaved(updatedTemplate) {
|
||||
setShowTemplate(false);
|
||||
if (updatedTemplate) {
|
||||
@@ -80,6 +132,49 @@ export default function ExperimentDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const sortedAnimals = useMemo(() => {
|
||||
const sorted = [...animals].sort((a, b) => {
|
||||
let av, bv;
|
||||
if (sortField === '__name__') {
|
||||
av = a.animal_name ?? '';
|
||||
bv = b.animal_name ?? '';
|
||||
} else if (sortField === '__id__') {
|
||||
av = a.animal_id_string ?? '';
|
||||
bv = b.animal_id_string ?? '';
|
||||
} else {
|
||||
av = a.subject_info?.[sortField] ?? '';
|
||||
bv = b.subject_info?.[sortField] ?? '';
|
||||
}
|
||||
// numeric-aware comparison
|
||||
const an = parseFloat(av), bn = parseFloat(bv);
|
||||
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : String(av).localeCompare(String(bv));
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
}, [animals, sortField, sortDir, subjectTemplate]);
|
||||
|
||||
// Group mode: bucket sortedAnimals by the groupField value
|
||||
const groupedAnimals = useMemo(() => {
|
||||
if (displayMode !== 'group') return null;
|
||||
const getVal = (animal) => {
|
||||
if (groupField === '__name__') return animal.animal_name ?? '—';
|
||||
if (groupField === '__id__') return animal.animal_id_string ?? '—';
|
||||
const v = animal.subject_info?.[groupField];
|
||||
return v != null && v !== '' ? String(v) : '—';
|
||||
};
|
||||
const buckets = new Map();
|
||||
for (const animal of sortedAnimals) {
|
||||
const key = getVal(animal);
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(animal);
|
||||
}
|
||||
// Sort bucket keys numerically-aware
|
||||
return [...buckets.entries()].sort(([a], [b]) => {
|
||||
const an = parseFloat(a), bn = parseFloat(b);
|
||||
return (!isNaN(an) && !isNaN(bn)) ? an - bn : a.localeCompare(b);
|
||||
});
|
||||
}, [sortedAnimals, displayMode, groupField]);
|
||||
|
||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||
if (error) return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
@@ -104,25 +199,18 @@ export default function ExperimentDetail() {
|
||||
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowTemplate(true)}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
Record Template
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
</div>
|
||||
|
||||
{successMsg && <Alert type="success" message={successMsg} />}
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
{/* Template summary strip */}
|
||||
{experiment.template && experiment.template.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap gap-1.5">
|
||||
{experiment.template.map((f) => (
|
||||
{/* Template strips */}
|
||||
<div className="mb-5 space-y-2">
|
||||
{/* Daily record template */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Daily record</span>
|
||||
{(experiment.template ?? []).map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
@@ -135,12 +223,40 @@ export default function ExperimentDetail() {
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowTemplate(true)}
|
||||
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors"
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
edit
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||
</svg>
|
||||
{(experiment.template ?? []).length === 0 ? 'Set up' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subject info template */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Subject info</span>
|
||||
{subjectTemplate.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
f.active ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{f.active ? null : <span title="hidden">◌</span>}
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowSubjectTemplate(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||
</svg>
|
||||
{subjectTemplate.length === 0 ? 'Set up' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{animals.length === 0 ? (
|
||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||
@@ -148,37 +264,136 @@ export default function ExperimentDetail() {
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{animals.map((animal) => (
|
||||
<div
|
||||
key={animal.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
||||
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||
{animal.animal_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
|
||||
</div>
|
||||
<>
|
||||
{/* Display controls */}
|
||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||
{[['list', 'List'], ['group', 'Group'], ['calendar', 'Calendar']].map(([mode, label]) => (
|
||||
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
|
||||
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
|
||||
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{displayMode === 'list' && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">Sort by</span>
|
||||
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<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>
|
||||
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
||||
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayMode === 'group' && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">Group by</span>
|
||||
<select value={groupField} onChange={(e) => updateGroupField(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<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>
|
||||
<span className="text-xs text-gray-400 ml-1">· sorted within by</span>
|
||||
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<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>
|
||||
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
||||
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayMode === 'list' && (
|
||||
<AnimalCardList
|
||||
animals={sortedAnimals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||
onEdit={setEditAnimal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayMode === 'group' && groupedAnimals && groupedAnimals.map(([groupValue, groupAnimals]) => {
|
||||
const groupLabel = (() => {
|
||||
if (groupField === '__name__' || groupField === '__id__') return groupValue;
|
||||
return subjectTemplate.find((f) => f.fieldId === groupField)?.label
|
||||
? `${subjectTemplate.find((f) => f.fieldId === groupField).label}: ${groupValue}`
|
||||
: groupValue;
|
||||
})();
|
||||
return (
|
||||
<div key={groupValue} className="mb-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{groupLabel}</span>
|
||||
<span className="text-xs text-gray-400">({groupAnimals.length})</span>
|
||||
<div className="flex-1 border-t border-gray-100" />
|
||||
</div>
|
||||
<AnimalCardList
|
||||
animals={groupAnimals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||
onEdit={setEditAnimal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{displayMode === 'calendar' && (
|
||||
<ExperimentCalendar
|
||||
experimentId={id}
|
||||
template={dailyTemplate}
|
||||
allStatuses={experimentStatuses}
|
||||
animals={animals}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AuditLogSection tableName="animals" />
|
||||
<ExperimentAnalysisCharts
|
||||
animals={animals}
|
||||
experimentStatuses={experimentStatuses}
|
||||
subjectTemplate={subjectTemplate}
|
||||
dailyTemplate={dailyTemplate}
|
||||
/>
|
||||
|
||||
<AuditLogSection tableName="animals" limit={5} />
|
||||
|
||||
{/* Template editor modal */}
|
||||
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg">
|
||||
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
|
||||
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showSubjectTemplate} onClose={() => setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
|
||||
<TemplateEditor
|
||||
experimentId={id}
|
||||
loadFn={() => experimentsApi.getSubjectTemplate(id)}
|
||||
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
|
||||
onClose={(updated) => {
|
||||
setShowSubjectTemplate(false);
|
||||
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
||||
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
||||
</Modal>
|
||||
@@ -187,14 +402,6 @@ export default function ExperimentDetail() {
|
||||
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteAnimal}
|
||||
onClose={() => setDeleteAnimal(null)}
|
||||
onConfirm={handleDeleteAnimal}
|
||||
loading={deleteLoading}
|
||||
title="Remove Animal"
|
||||
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user