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,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import ExperimentDayView from '../src/pages/ExperimentDayView';
|
||||
import { experimentsApi } from '../src/api/client';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,11 +12,16 @@ jest.mock('react-router-dom', () => ({
|
||||
Link: ({ to, children, ...props }) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
const { useParams, useSearchParams } = require('react-router-dom');
|
||||
const { experimentsApi, dailyStatusesApi } = require('../src/api/client');
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
experimentsApi: {
|
||||
get: jest.fn(),
|
||||
getDayStatuses: jest.fn(),
|
||||
getCalendar: jest.fn(),
|
||||
},
|
||||
dailyStatusesApi: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -98,6 +102,8 @@ beforeEach(() => {
|
||||
useSearchParams.mockReturnValue(makeSearchParams(FIELD_ID));
|
||||
experimentsApi.get.mockResolvedValue(EXPERIMENT);
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([]);
|
||||
experimentsApi.getCalendar.mockResolvedValue({ field: FIELD_ID, days: {} });
|
||||
dailyStatusesApi.update.mockResolvedValue({});
|
||||
});
|
||||
|
||||
// ── Loading & error states ────────────────────────────────────────────────────
|
||||
@@ -420,4 +426,214 @@ describe('API calls', () => {
|
||||
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