Persist schedules view and month in the URL
- /schedules?year=2026&month=4 for the shifts view - /schedules/templates for the templates view - Month navigation updates search params via useNavigate - Reloading or sharing a URL restores the exact view Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ function ProtectedLayout() {
|
|||||||
<main>
|
<main>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/schedules/templates" element={<Schedules />} />
|
||||||
<Route path="/schedules" element={<Schedules />} />
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
<Route path="/timeoff" element={<TimeOff />} />
|
<Route path="/timeoff" element={<TimeOff />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import Schedules from './Schedules';
|
import Schedules from './Schedules';
|
||||||
import { api, ShiftInstance, ShiftTemplate } from '../api';
|
import { api, ShiftInstance, ShiftTemplate } from '../api';
|
||||||
|
|
||||||
@@ -62,6 +63,14 @@ const mockTemplate: ShiftTemplate = {
|
|||||||
updated_at: '2026-04-01T00:00:00Z',
|
updated_at: '2026-04-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<Schedules />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe('Schedules (volunteer view)', () => {
|
describe('Schedules (volunteer view)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 });
|
useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 });
|
||||||
@@ -69,7 +78,7 @@ describe('Schedules (volunteer view)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders published shifts for a volunteer', async () => {
|
it('renders published shifts for a volunteer', async () => {
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
|
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -77,7 +86,7 @@ describe('Schedules (volunteer view)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not show admin controls', async () => {
|
it('does not show admin controls', async () => {
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
|
||||||
expect(screen.queryByText('Generate')).not.toBeInTheDocument();
|
expect(screen.queryByText('Generate')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Publish')).not.toBeInTheDocument();
|
expect(screen.queryByText('Publish')).not.toBeInTheDocument();
|
||||||
@@ -85,7 +94,7 @@ describe('Schedules (volunteer view)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Schedules (admin view)', () => {
|
describe('Schedules (admin shifts view)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 });
|
useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 });
|
||||||
(api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
|
(api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
|
||||||
@@ -93,7 +102,7 @@ describe('Schedules (admin view)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows Generate and Publish buttons when drafts exist', async () => {
|
it('shows Generate and Publish buttons when drafts exist', async () => {
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
|
||||||
expect(screen.getByText('Generate')).toBeInTheDocument();
|
expect(screen.getByText('Generate')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Publish')).toBeInTheDocument();
|
expect(screen.getByText('Publish')).toBeInTheDocument();
|
||||||
@@ -101,7 +110,7 @@ describe('Schedules (admin view)', () => {
|
|||||||
|
|
||||||
it('calls generateShifts on Generate click', async () => {
|
it('calls generateShifts on Generate click', async () => {
|
||||||
(api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
|
(api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByText('Generate'));
|
fireEvent.click(screen.getByText('Generate'));
|
||||||
expect(api.generateShifts).toHaveBeenCalled();
|
expect(api.generateShifts).toHaveBeenCalled();
|
||||||
@@ -113,7 +122,7 @@ describe('Schedules (admin view)', () => {
|
|||||||
.mockResolvedValueOnce([mockDraftInstance])
|
.mockResolvedValueOnce([mockDraftInstance])
|
||||||
.mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]);
|
.mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]);
|
||||||
|
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByText('Publish'));
|
fireEvent.click(screen.getByText('Publish'));
|
||||||
expect(api.publishShifts).toHaveBeenCalled();
|
expect(api.publishShifts).toHaveBeenCalled();
|
||||||
@@ -121,35 +130,43 @@ describe('Schedules (admin view)', () => {
|
|||||||
|
|
||||||
it('shows Unpublish button when all shifts are published', async () => {
|
it('shows Unpublish button when all shifts are published', async () => {
|
||||||
(api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]);
|
(api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]);
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Edit button on each shift row', async () => {
|
it('shows Edit button on each shift row', async () => {
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
|
||||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens edit form when Edit is clicked', async () => {
|
it('opens edit form when Edit is clicked', async () => {
|
||||||
render(<Schedules />);
|
renderAt('/schedules');
|
||||||
await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByText('Edit'));
|
fireEvent.click(screen.getByText('Edit'));
|
||||||
expect(screen.getByText(/Edit Shift/)).toBeInTheDocument();
|
expect(screen.getByText(/Edit Shift/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches to templates view', async () => {
|
it('reads year and month from search params', async () => {
|
||||||
render(<Schedules />);
|
renderAt('/schedules?year=2025&month=12');
|
||||||
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
|
await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12));
|
||||||
fireEvent.click(screen.getByText('Manage Templates'));
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Schedules (admin templates view)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 });
|
||||||
|
(api.listShiftTemplates as jest.Mock).mockResolvedValue([mockTemplate]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders templates at /schedules/templates', async () => {
|
||||||
|
renderAt('/schedules/templates');
|
||||||
await waitFor(() => expect(screen.getByText('Shift Templates')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Shift Templates')).toBeInTheDocument());
|
||||||
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
|
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows template form when New Template is clicked', async () => {
|
it('shows template form when New Template is clicked', async () => {
|
||||||
render(<Schedules />);
|
renderAt('/schedules/templates');
|
||||||
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
|
|
||||||
fireEvent.click(screen.getByText('Manage Templates'));
|
|
||||||
await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByText('+ New Template'));
|
fireEvent.click(screen.getByText('+ New Template'));
|
||||||
expect(screen.getByText('New Template')).toBeInTheDocument();
|
expect(screen.getByText('New Template')).toBeInTheDocument();
|
||||||
@@ -158,9 +175,7 @@ describe('Schedules (admin view)', () => {
|
|||||||
it('deletes a template', async () => {
|
it('deletes a template', async () => {
|
||||||
(api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined);
|
(api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined);
|
||||||
window.confirm = jest.fn().mockReturnValue(true);
|
window.confirm = jest.fn().mockReturnValue(true);
|
||||||
render(<Schedules />);
|
renderAt('/schedules/templates');
|
||||||
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
|
|
||||||
fireEvent.click(screen.getByText('Manage Templates'));
|
|
||||||
await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument());
|
await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getAllByText('Delete')[0]);
|
fireEvent.click(screen.getAllByText('Delete')[0]);
|
||||||
expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1);
|
expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState, FormEvent } from 'react';
|
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ShiftTemplate,
|
ShiftTemplate,
|
||||||
@@ -145,16 +146,19 @@ function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) {
|
|||||||
// Main page
|
// Main page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type View = 'shifts' | 'templates';
|
|
||||||
|
|
||||||
export default function Schedules() {
|
export default function Schedules() {
|
||||||
const { role } = useAuth();
|
const { role } = useAuth();
|
||||||
const [view, setView] = useState<View>('shifts');
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// Month navigation
|
// Derive view from URL path
|
||||||
|
const isTemplatesView = location.pathname === '/schedules/templates';
|
||||||
|
|
||||||
|
// Derive year/month from search params (shifts view only)
|
||||||
const init = currentYearMonth();
|
const init = currentYearMonth();
|
||||||
const [year, setYear] = useState(init.year);
|
const year = Number(searchParams.get('year')) || init.year;
|
||||||
const [month, setMonth] = useState(init.month);
|
const month = Number(searchParams.get('month')) || init.month;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const [instances, setInstances] = useState<ShiftInstance[]>([]);
|
const [instances, setInstances] = useState<ShiftInstance[]>([]);
|
||||||
@@ -169,26 +173,37 @@ export default function Schedules() {
|
|||||||
const [editMinCap, setEditMinCap] = useState('');
|
const [editMinCap, setEditMinCap] = useState('');
|
||||||
const [editMaxCap, setEditMaxCap] = useState('');
|
const [editMaxCap, setEditMaxCap] = useState('');
|
||||||
|
|
||||||
|
// Navigation helpers
|
||||||
|
const goToShifts = useCallback((y: number, m: number) => {
|
||||||
|
navigate(`/schedules?year=${y}&month=${m}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const goToTemplates = useCallback(() => {
|
||||||
|
navigate('/schedules/templates');
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === 'shifts') {
|
if (isTemplatesView) {
|
||||||
api.listShifts(year, month)
|
|
||||||
.then(setInstances)
|
|
||||||
.catch(() => setError('Could not load shifts.'));
|
|
||||||
} else {
|
|
||||||
api.listShiftTemplates()
|
api.listShiftTemplates()
|
||||||
.then(setTemplates)
|
.then(setTemplates)
|
||||||
.catch(() => setError('Could not load templates.'));
|
.catch(() => setError('Could not load templates.'));
|
||||||
|
} else {
|
||||||
|
api.listShifts(year, month)
|
||||||
|
.then(setInstances)
|
||||||
|
.catch(() => setError('Could not load shifts.'));
|
||||||
}
|
}
|
||||||
}, [view, year, month]);
|
}, [isTemplatesView, year, month]);
|
||||||
|
|
||||||
function prevMonth() {
|
function prevMonth() {
|
||||||
if (month === 1) { setYear(y => y - 1); setMonth(12); }
|
const m = month === 1 ? 12 : month - 1;
|
||||||
else setMonth(m => m - 1);
|
const y = month === 1 ? year - 1 : year;
|
||||||
|
goToShifts(y, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextMonth() {
|
function nextMonth() {
|
||||||
if (month === 12) { setYear(y => y + 1); setMonth(1); }
|
const m = month === 12 ? 1 : month + 1;
|
||||||
else setMonth(m => m + 1);
|
const y = month === 12 ? year + 1 : year;
|
||||||
|
goToShifts(y, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenerate() {
|
async function handleGenerate() {
|
||||||
@@ -295,8 +310,8 @@ export default function Schedules() {
|
|||||||
<h2>Schedules</h2>
|
<h2>Schedules</h2>
|
||||||
{role === 'admin' && (
|
{role === 'admin' && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<button onClick={() => setView(v => v === 'shifts' ? 'templates' : 'shifts')}>
|
<button onClick={() => isTemplatesView ? goToShifts(year, month) : goToTemplates()}>
|
||||||
{view === 'shifts' ? 'Manage Templates' : 'View Shifts'}
|
{isTemplatesView ? 'View Shifts' : 'Manage Templates'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -305,7 +320,7 @@ export default function Schedules() {
|
|||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
{/* ---- Templates view ---- */}
|
{/* ---- Templates view ---- */}
|
||||||
{view === 'templates' && role === 'admin' && (
|
{isTemplatesView && role === 'admin' && (
|
||||||
<>
|
<>
|
||||||
<div className="page-header" style={{ marginBottom: '1rem' }}>
|
<div className="page-header" style={{ marginBottom: '1rem' }}>
|
||||||
<h3>Shift Templates</h3>
|
<h3>Shift Templates</h3>
|
||||||
@@ -367,7 +382,7 @@ export default function Schedules() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- Shifts view ---- */}
|
{/* ---- Shifts view ---- */}
|
||||||
{view === 'shifts' && (
|
{!isTemplatesView && (
|
||||||
<>
|
<>
|
||||||
{/* Month navigation */}
|
{/* Month navigation */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
@@ -428,7 +443,6 @@ export default function Schedules() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{instances.map(inst => {
|
{instances.map(inst => {
|
||||||
const myAssignment = inst.volunteers.find(() => true); // for volunteers, API already filters
|
|
||||||
const confirmed = inst.volunteers.some(v => v.confirmed);
|
const confirmed = inst.volunteers.some(v => v.confirmed);
|
||||||
return (
|
return (
|
||||||
<tr key={inst.id}>
|
<tr key={inst.id}>
|
||||||
@@ -454,7 +468,7 @@ export default function Schedules() {
|
|||||||
<button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button>
|
<button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{role !== 'admin' && inst.status === 'published' && !confirmed && myAssignment && (
|
{role !== 'admin' && inst.status === 'published' && !confirmed && (
|
||||||
<td>
|
<td>
|
||||||
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
|
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user