feat: inline cell editing and prev/next day navigation in day view
Clicking any table cell opens an inline input (text or textarea per field type). Enter or blur saves; Escape cancels. Unchanged values skip the API call. Custom fields merge with existing custom_fields; builtin fields use their key directly. Prev/next buttons at the bottom navigate to adjacent days that have data for the selected field, preserving the ?field= param. Day list is fetched via getCalendar and sorted chronologically. Tests: 41 total (added 15 new tests for cell editing and day nav). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
|
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
|
||||||
CartesianGrid, Tooltip, Legend,
|
CartesianGrid, Tooltip, Legend,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { experimentsApi } from '../api/client';
|
import { experimentsApi, dailyStatusesApi } from '../api/client';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import DailyStatusForm from '../components/DailyStatusForm';
|
import DailyStatusForm from '../components/DailyStatusForm';
|
||||||
@@ -23,8 +23,50 @@ function cellText(value) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inline cell editor ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CellEdit({ value, field, saving, onSave, onCancel }) {
|
||||||
|
const [val, setVal] = useState(value ?? '');
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.focus();
|
||||||
|
ref.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleKey(e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
||||||
|
if (e.key === 'Enter' && field.type !== 'textarea') { e.preventDefault(); onSave(val); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = 'w-full text-sm border border-blue-400 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white disabled:opacity-60';
|
||||||
|
|
||||||
|
if (field.type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={ref} value={val} rows={3} disabled={saving}
|
||||||
|
onChange={(e) => setVal(e.target.value)}
|
||||||
|
onBlur={() => onSave(val)}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
className={cls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref} type="text" value={val} disabled={saving}
|
||||||
|
onChange={(e) => setVal(e.target.value)}
|
||||||
|
onBlur={() => onSave(val)}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
className={cls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ExperimentDayView() {
|
export default function ExperimentDayView() {
|
||||||
const { id, date } = useParams(); // id = experimentId, date = YYYY-MM-DD
|
const { id, date } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -34,6 +76,16 @@ export default function ExperimentDayView() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [editStatus, setEditStatus] = useState(null);
|
const [editStatus, setEditStatus] = useState(null);
|
||||||
|
|
||||||
|
// Inline cell editing
|
||||||
|
const [editingCell, setEditingCell] = useState(null); // { statusId, fieldId }
|
||||||
|
const [cellSaving, setCellSaving] = useState(false);
|
||||||
|
const [cellError, setCellError] = useState(null);
|
||||||
|
|
||||||
|
// Day navigation
|
||||||
|
const [dayList, setDayList] = useState([]);
|
||||||
|
|
||||||
|
const selectedField = searchParams.get('field') ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -48,25 +100,27 @@ export default function ExperimentDayView() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id, date]);
|
}, [id, date]);
|
||||||
|
|
||||||
|
// Fetch ordered list of days with data for this field, for prev/next nav
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !selectedField) return;
|
||||||
|
experimentsApi.getCalendar(id, selectedField)
|
||||||
|
.then(({ days }) => setDayList(Object.keys(days).sort()))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [id, selectedField]);
|
||||||
|
|
||||||
const dailyTemplate = useMemo(() => {
|
const dailyTemplate = useMemo(() => {
|
||||||
if (!experiment) return [];
|
if (!experiment) return [];
|
||||||
const t = experiment.template ?? [];
|
return (experiment.template ?? []).filter((f) => f.active);
|
||||||
return t.filter((f) => f.active);
|
|
||||||
}, [experiment]);
|
}, [experiment]);
|
||||||
|
|
||||||
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
|
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
|
||||||
|
|
||||||
// Selected field from URL (passed by calendar). Used to filter which rows show.
|
|
||||||
const selectedField = searchParams.get('field') ?? null;
|
|
||||||
|
|
||||||
// Build map: animal_id → status for this day
|
|
||||||
const statusByAnimal = useMemo(() => {
|
const statusByAnimal = useMemo(() => {
|
||||||
const map = {};
|
const map = {};
|
||||||
statuses.forEach((s) => { map[s.animal_id] = s; });
|
statuses.forEach((s) => { map[s.animal_id] = s; });
|
||||||
return map;
|
return map;
|
||||||
}, [statuses]);
|
}, [statuses]);
|
||||||
|
|
||||||
// Rows: animals with a valid (non-empty) value for the selected field, or all if no field filter
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
return animals
|
return animals
|
||||||
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
|
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
|
||||||
@@ -79,7 +133,6 @@ export default function ExperimentDayView() {
|
|||||||
});
|
});
|
||||||
}, [animals, statusByAnimal, selectedField]);
|
}, [animals, statusByAnimal, selectedField]);
|
||||||
|
|
||||||
// Bar chart: analysis_summary from each row's status (same contract as calendar modal)
|
|
||||||
const barData = useMemo(() => {
|
const barData = useMemo(() => {
|
||||||
return rows
|
return rows
|
||||||
.map(({ animal, status }) => {
|
.map(({ animal, status }) => {
|
||||||
@@ -95,14 +148,48 @@ export default function ExperimentDayView() {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
|
// Prev / next days with data
|
||||||
|
const currentDayIdx = dayList.indexOf(date);
|
||||||
|
const prevDay = currentDayIdx > 0 ? dayList[currentDayIdx - 1] : null;
|
||||||
|
const nextDay = currentDayIdx < dayList.length - 1 ? dayList[currentDayIdx + 1] : null;
|
||||||
|
|
||||||
|
function dayNavUrl(d) {
|
||||||
|
return `/experiments/${id}/day/${d}${selectedField ? `?field=${selectedField}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline cell save
|
||||||
|
async function saveCell(status, field, rawValue) {
|
||||||
|
const value = rawValue?.trim() || null;
|
||||||
|
// Skip save if unchanged
|
||||||
|
const current = getFieldValue(status, field);
|
||||||
|
const currentNorm = current == null ? null : String(current).trim() || null;
|
||||||
|
if (value === currentNorm) { setEditingCell(null); return; }
|
||||||
|
|
||||||
|
setCellSaving(true);
|
||||||
|
setCellError(null);
|
||||||
|
try {
|
||||||
|
let payload;
|
||||||
|
if (field.builtin) {
|
||||||
|
payload = { [field.key]: value };
|
||||||
|
} else {
|
||||||
|
payload = { custom_fields: { ...status.custom_fields, [field.fieldId]: value } };
|
||||||
|
}
|
||||||
|
const updated = await dailyStatusesApi.update(status.id, payload);
|
||||||
|
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
|
||||||
|
setEditingCell(null);
|
||||||
|
} catch (err) {
|
||||||
|
setCellError(err.message ?? 'Save failed');
|
||||||
|
} finally {
|
||||||
|
setCellSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const displayDate = date
|
const displayDate = date
|
||||||
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
|
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
|
||||||
: date;
|
: date;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading…</div>;
|
||||||
<div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading…</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -132,6 +219,10 @@ export default function ExperimentDayView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{cellError && (
|
||||||
|
<Alert type="error" message={cellError} onDismiss={() => setCellError(null)} className="mb-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status table */}
|
{/* Status table */}
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
|
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
|
||||||
@@ -185,13 +276,34 @@ export default function ExperimentDayView() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
{dailyTemplate.map((f) => (
|
|
||||||
|
{/* Field cells — click to edit inline */}
|
||||||
|
{dailyTemplate.map((f) => {
|
||||||
|
const isEditing = editingCell?.statusId === status?.id && editingCell?.fieldId === f.fieldId;
|
||||||
|
const text = status ? cellText(getFieldValue(status, f)) : null;
|
||||||
|
return (
|
||||||
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
|
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
|
||||||
<span className="whitespace-pre-wrap break-words">
|
{isEditing ? (
|
||||||
{status ? (cellText(getFieldValue(status, f)) ?? <span className="text-gray-300">—</span>) : <span className="text-gray-300">—</span>}
|
<CellEdit
|
||||||
|
value={text}
|
||||||
|
field={f}
|
||||||
|
saving={cellSaving}
|
||||||
|
onSave={(val) => saveCell(status, f, val)}
|
||||||
|
onCancel={() => setEditingCell(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="whitespace-pre-wrap break-words block rounded px-0.5 -mx-0.5 cursor-pointer hover:bg-blue-50 transition-colors"
|
||||||
|
title="Click to edit"
|
||||||
|
onClick={() => status && setEditingCell({ statusId: status.id, fieldId: f.fieldId })}
|
||||||
|
>
|
||||||
|
{text ?? <span className="text-gray-300">—</span>}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
|
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
|
||||||
{status && (
|
{status && (
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
|
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
|
||||||
@@ -210,7 +322,7 @@ export default function ExperimentDayView() {
|
|||||||
|
|
||||||
{/* Session metrics bar chart */}
|
{/* Session metrics bar chart */}
|
||||||
{barData.length > 0 && (
|
{barData.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 mb-8">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
@@ -227,7 +339,35 @@ export default function ExperimentDayView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit modal */}
|
{/* Prev / next day navigation */}
|
||||||
|
{(prevDay || nextDay) && (
|
||||||
|
<div className="flex justify-between items-center mt-2 mb-4">
|
||||||
|
<div>
|
||||||
|
{prevDay && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(dayNavUrl(prevDay))}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
>
|
||||||
|
← {format(new Date(prevDay + 'T12:00:00'), 'MMMM d, yyyy')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{nextDay && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(dayNavUrl(nextDay))}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
>
|
||||||
|
{format(new Date(nextDay + 'T12:00:00'), 'MMMM d, yyyy')} →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit modal (full row) */}
|
||||||
{editStatus && (
|
{editStatus && (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={!!editStatus}
|
isOpen={!!editStatus}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||||
import ExperimentDayView from '../src/pages/ExperimentDayView';
|
import ExperimentDayView from '../src/pages/ExperimentDayView';
|
||||||
import { experimentsApi } from '../src/api/client';
|
|
||||||
|
|
||||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -13,11 +12,16 @@ jest.mock('react-router-dom', () => ({
|
|||||||
Link: ({ to, children, ...props }) => <a href={to} {...props}>{children}</a>,
|
Link: ({ to, children, ...props }) => <a href={to} {...props}>{children}</a>,
|
||||||
}));
|
}));
|
||||||
const { useParams, useSearchParams } = require('react-router-dom');
|
const { useParams, useSearchParams } = require('react-router-dom');
|
||||||
|
const { experimentsApi, dailyStatusesApi } = require('../src/api/client');
|
||||||
|
|
||||||
jest.mock('../src/api/client', () => ({
|
jest.mock('../src/api/client', () => ({
|
||||||
experimentsApi: {
|
experimentsApi: {
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
getDayStatuses: jest.fn(),
|
getDayStatuses: jest.fn(),
|
||||||
|
getCalendar: jest.fn(),
|
||||||
|
},
|
||||||
|
dailyStatusesApi: {
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -98,6 +102,8 @@ beforeEach(() => {
|
|||||||
useSearchParams.mockReturnValue(makeSearchParams(FIELD_ID));
|
useSearchParams.mockReturnValue(makeSearchParams(FIELD_ID));
|
||||||
experimentsApi.get.mockResolvedValue(EXPERIMENT);
|
experimentsApi.get.mockResolvedValue(EXPERIMENT);
|
||||||
experimentsApi.getDayStatuses.mockResolvedValue([]);
|
experimentsApi.getDayStatuses.mockResolvedValue([]);
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({ field: FIELD_ID, days: {} });
|
||||||
|
dailyStatusesApi.update.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Loading & error states ────────────────────────────────────────────────────
|
// ── Loading & error states ────────────────────────────────────────────────────
|
||||||
@@ -420,4 +426,214 @@ describe('API calls', () => {
|
|||||||
expect(experimentsApi.get).toHaveBeenCalledWith(EXP_ID)
|
expect(experimentsApi.get).toHaveBeenCalledWith(EXP_ID)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls getCalendar with experiment id and selected field for day navigation', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, FIELD_ID)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Inline cell editing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('inline cell editing', () => {
|
||||||
|
const STATUS_ID = 'status-a1000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||||
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||||
|
custom_fields: { [FIELD_ID]: '325' },
|
||||||
|
vitals: 'HR 72',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
dailyStatusesApi.update.mockResolvedValue(
|
||||||
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||||
|
custom_fields: { [FIELD_ID]: '330' },
|
||||||
|
vitals: 'HR 72',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an input when a cell value is clicked', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText('325'));
|
||||||
|
fireEvent.click(screen.getByText('325'));
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an input when a dash (empty cell) is clicked', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText('Rat A'));
|
||||||
|
// Vitals is shown as "HR 72" but weight is selected, click on a dash cell in vitals column
|
||||||
|
// After the status above vitals = 'HR 72' (not empty), let's use a status without vitals
|
||||||
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||||
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||||
|
custom_fields: { [FIELD_ID]: '325' },
|
||||||
|
vitals: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// Re-render
|
||||||
|
const { unmount } = render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getAllByText('Rat A'));
|
||||||
|
const dashCell = screen.getAllByText('—')[0];
|
||||||
|
fireEvent.click(dashCell);
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a custom field value on Enter key', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText('325'));
|
||||||
|
fireEvent.click(screen.getByText('325'));
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(input, { target: { value: '330' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
||||||
|
STATUS_ID,
|
||||||
|
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '330' }) })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves on blur', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText('325'));
|
||||||
|
fireEvent.click(screen.getByText('325'));
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(input, { target: { value: '328' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
||||||
|
STATUS_ID,
|
||||||
|
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '328' }) })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels edit on Escape without saving', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText('325'));
|
||||||
|
fireEvent.click(screen.getByText('325'));
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(input, { target: { value: '999' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Escape' });
|
||||||
|
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the API call when the value is unchanged', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText('325'));
|
||||||
|
fireEvent.click(screen.getByText('325'));
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
// value unchanged — blur without editing
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a builtin field (vitals) with the field key as payload key', async () => {
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText('HR 72'));
|
||||||
|
fireEvent.click(screen.getByText('HR 72'));
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(input, { target: { value: 'HR 80' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
||||||
|
STATUS_ID,
|
||||||
|
expect.objectContaining({ vitals: 'HR 80' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// custom_fields must NOT be in the payload for a builtin field
|
||||||
|
const payload = dailyStatusesApi.update.mock.calls[0][1];
|
||||||
|
expect(payload).not.toHaveProperty('custom_fields');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Prev / next day navigation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('prev/next day navigation', () => {
|
||||||
|
it('shows no navigation buttons when there are no other days', async () => {
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: FIELD_ID,
|
||||||
|
days: { [DATE_STR]: 2 },
|
||||||
|
});
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText(/← April/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/April.*→/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a previous day button when an earlier day has data', async () => {
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: FIELD_ID,
|
||||||
|
days: { '2026-04-22': 1, [DATE_STR]: 2 },
|
||||||
|
});
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText(/April 22, 2026/));
|
||||||
|
expect(screen.getByText(/← April 22, 2026/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a next day button when a later day has data', async () => {
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: FIELD_ID,
|
||||||
|
days: { [DATE_STR]: 2, '2026-04-24': 1 },
|
||||||
|
});
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText(/April 24, 2026/));
|
||||||
|
expect(screen.getByText(/April 24, 2026 →/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows both prev and next buttons when surrounded by days with data', async () => {
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: FIELD_ID,
|
||||||
|
days: { '2026-04-21': 1, [DATE_STR]: 2, '2026-04-25': 3 },
|
||||||
|
});
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText(/April 21, 2026/));
|
||||||
|
expect(screen.getByText(/← April 21, 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/April 25, 2026 →/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the previous day with the field param preserved', async () => {
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: FIELD_ID,
|
||||||
|
days: { '2026-04-22': 1, [DATE_STR]: 2 },
|
||||||
|
});
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText(/← April 22, 2026/));
|
||||||
|
fireEvent.click(screen.getByText(/← April 22, 2026/));
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
|
`/experiments/${EXP_ID}/day/2026-04-22?field=${FIELD_ID}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the next day with the field param preserved', async () => {
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: FIELD_ID,
|
||||||
|
days: { [DATE_STR]: 2, '2026-04-24': 1 },
|
||||||
|
});
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText(/April 24, 2026 →/));
|
||||||
|
fireEvent.click(screen.getByText(/April 24, 2026 →/));
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
|
`/experiments/${EXP_ID}/day/2026-04-24?field=${FIELD_ID}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only shows adjacent days, not all days (prev is immediately before in sorted list)', async () => {
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: FIELD_ID,
|
||||||
|
days: { '2026-04-10': 1, '2026-04-20': 2, [DATE_STR]: 3, '2026-04-30': 1 },
|
||||||
|
});
|
||||||
|
render(<ExperimentDayView />);
|
||||||
|
await waitFor(() => screen.getByText(/April 20, 2026/));
|
||||||
|
// Prev should be April 20 (immediately before), not April 10
|
||||||
|
expect(screen.getByText(/← April 20, 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/April 10/)).not.toBeInTheDocument();
|
||||||
|
// Next should be April 30 (immediately after)
|
||||||
|
expect(screen.getByText(/April 30, 2026 →/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user