diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 4a1c6d739..8355dd3eb 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -22,6 +22,12 @@ export interface Animal { restrictions?: string[]; } +export interface Volunteer { + volunteerId: number; + firstName: string; + lastName: string; +} + export interface CreateMatchBody { volunteerId: number; chameleonAnimalId: number; @@ -79,6 +85,10 @@ export class ApiClient { return this.get('/api/animals') as Promise; } + public async getVolunteer(volunteerId: number): Promise { + return this.get(`/api/volunteers/${volunteerId}`) as Promise; + } + public async createMatch(body: CreateMatchBody): Promise { return this.post('/api/matches', body) as Promise; } diff --git a/apps/frontend/src/app.error.spec.tsx b/apps/frontend/src/app.error.spec.tsx new file mode 100644 index 000000000..ad9cd7a58 --- /dev/null +++ b/apps/frontend/src/app.error.spec.tsx @@ -0,0 +1,53 @@ +import { render } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + +import { routes } from './app'; +import { system } from './theme'; + +vi.mock('@api/apiClient', () => ({ + default: { + getHello: vi.fn().mockResolvedValue('hello'), + getVolunteer: vi.fn().mockResolvedValue({ + volunteerId: 1, + firstName: 'Matt', + lastName: 'Wong', + }), + }, +})); + +// A page that crashes while rendering. +vi.mock('@pages/AnimalAvailability', () => ({ + default: () => { + throw new Error('page exploded'); + }, +})); + +describe('App routes when a page crashes', () => { + let consoleError: ReturnType; + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('keeps the navbar above the error message', () => { + // React logs the thrown render error; keep the test output clean. + consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const view = render( + + + , + ); + + expect(view.getByText('page exploded')).toBeTruthy(); + expect( + view.getByAltText('MSPCA - Kindness and Care for Animals'), + ).toBeTruthy(); + }); +}); diff --git a/apps/frontend/src/app.spec.tsx b/apps/frontend/src/app.spec.tsx index 95caf44df..34a28b1a4 100644 --- a/apps/frontend/src/app.spec.tsx +++ b/apps/frontend/src/app.spec.tsx @@ -1,15 +1,48 @@ import { render } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; -import App from './app'; +import { routes } from './app'; +import { system } from './theme'; -describe('App', () => { - it('should render successfully', () => { - const { baseElement } = render(); - expect(baseElement).toBeTruthy(); +vi.mock('@api/apiClient', () => ({ + default: { + getHello: vi.fn().mockResolvedValue('hello'), + getAnimals: vi.fn().mockResolvedValue([]), + getVolunteer: vi.fn().mockResolvedValue({ + volunteerId: 1, + firstName: 'Matt', + lastName: 'Wong', + }), + }, +})); + +const LOGO_ALT = 'MSPCA - Kindness and Care for Animals'; + +const renderAt = (path: string) => + render( + + + , + ); + +describe('App routes', () => { + it('renders the navbar on the home page', () => { + const view = renderAt('/'); + expect(view.getByAltText(LOGO_ALT)).toBeTruthy(); + expect(view.getByText('Welcome to scaffolding!')).toBeTruthy(); + }); + + it('renders the navbar once on the animal availability page', () => { + const view = renderAt('/animal-availability'); + expect(view.getAllByAltText(LOGO_ALT)).toHaveLength(1); }); - it('should have a greeting as the title', () => { - const { getByText } = render(); - expect(getByText(/Welcome frontend/gi)).toBeTruthy(); + it('shows the navbar and a not-found message on an unknown route', () => { + const view = renderAt('/does-not-exist'); + expect(view.getByAltText(LOGO_ALT)).toBeTruthy(); + expect(view.getByText('Page not found')).toBeTruthy(); }); }); diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 3b1620328..27044a10c 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -1,28 +1,43 @@ import { useEffect } from 'react'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { + createBrowserRouter, + RouterProvider, + type RouteObject, +} from 'react-router-dom'; import apiClient from '@api/apiClient'; import Root from '@containers/root'; +import Layout from '@containers/Layout'; import NotFound from '@containers/404'; import Test from '@containers/test'; import AnimalAvailability from '@pages/AnimalAvailability'; import { ROUTES } from './routes'; -const router = createBrowserRouter([ +// Every page is a child of Layout so the navbar renders once, above all pages. +// Page errors are caught by the inner errorElement so the navbar stays put; +// the outer one only catches errors from the layout itself. +export const routes: RouteObject[] = [ { - path: '/', - element: , + element: , errorElement: , + children: [ + { + errorElement: , + children: [ + { path: '/', element: }, + { path: '/test', element: }, + { + path: ROUTES.ANIMAL_AVAILABILITY, + element: , + }, + { path: '*', element: }, + ], + }, + ], }, - { - path: '/test', - element: , - }, - { - path: ROUTES.ANIMAL_AVAILABILITY, - element: , - }, -]); +]; + +const router = createBrowserRouter(routes); export const App: React.FC = () => { useEffect(() => { diff --git a/apps/frontend/src/components/AnimalModal.spec.tsx b/apps/frontend/src/components/AnimalModal.spec.tsx new file mode 100644 index 000000000..0be7d5bda --- /dev/null +++ b/apps/frontend/src/components/AnimalModal.spec.tsx @@ -0,0 +1,62 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import type { Mock } from 'vitest'; + +import apiClient, { type Animal } from '@api/apiClient'; +import { renderWithProviders } from '../testUtils'; +import AnimalModal from './AnimalModal'; + +vi.mock('@api/apiClient', () => ({ + default: { createMatch: vi.fn() }, +})); + +const createMatch = apiClient.createMatch as Mock; + +const animal = { + animalId: 1, + chameleonAnimalId: 555, + name: 'Biscuit', + age: 2, + sex: 'Male', + breed: ['Beagle'], + weight: 20, + location: 'Boston, MA', + picture: null, + fosterBioMemo: null, +} as Animal; + +const renderModal = (route: string) => + renderWithProviders(, { + route, + }); + +describe('AnimalModal', () => { + beforeEach(() => { + createMatch.mockReset(); + createMatch.mockResolvedValue({}); + }); + + it('requests a match for the volunteer in the URL', async () => { + const view = renderModal('/?volunteerId=5'); + + fireEvent.click(await view.findByRole('button', { name: 'Apply' })); + + await waitFor(() => expect(createMatch).toHaveBeenCalledTimes(1)); + expect(createMatch).toHaveBeenCalledWith({ + volunteerId: 5, + chameleonAnimalId: 555, + additionalComments: undefined, + }); + }); + + it('disables the request without a volunteerId in the URL', async () => { + const view = renderModal('/'); + + const apply = (await view.findByRole('button', { + name: 'Apply', + })) as HTMLButtonElement; + fireEvent.click(apply); + + expect(apply.disabled).toBe(true); + expect(createMatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/components/AnimalModal.tsx b/apps/frontend/src/components/AnimalModal.tsx index 658989354..c5eb6be41 100644 --- a/apps/frontend/src/components/AnimalModal.tsx +++ b/apps/frontend/src/components/AnimalModal.tsx @@ -12,13 +12,10 @@ import { import apiClient, { type Animal } from '@api/apiClient'; import { formatAge } from '@utils/animals'; +import { useVolunteerId } from '@utils/useVolunteerId'; import { CalendarIcon, PLACEHOLDER_IMAGE } from './AnimalCard'; import RestrictionTag from './RestrictionTag'; -// TODO: replace with the logged-in volunteer's ID once the frontend has access -// to the current user's profile. -const CURRENT_VOLUNTEER_ID = 1; - interface AnimalModalProps { animal: Animal | null; onClose: () => void; @@ -31,13 +28,16 @@ const AnimalModalBody: React.FC<{ animal: Animal }> = ({ animal }) => { const [submitting, setSubmitting] = useState(false); const [requested, setRequested] = useState(false); const [error, setError] = useState(null); + // TEMPORARY: comes from the URL until sign-in is implemented. + const volunteerId = useVolunteerId(); const handleRequest = async () => { + if (volunteerId === null) return; setSubmitting(true); setError(null); try { await apiClient.createMatch({ - volunteerId: CURRENT_VOLUNTEER_ID, + volunteerId, chameleonAnimalId: animal.chameleonAnimalId, additionalComments: comments.trim() || undefined, }); @@ -262,6 +262,7 @@ const AnimalModalBody: React.FC<{ animal: Animal }> = ({ animal }) => { lineHeight="24px" onClick={handleRequest} loading={submitting} + disabled={volunteerId === null} > Apply diff --git a/apps/frontend/src/components/Navbar.spec.tsx b/apps/frontend/src/components/Navbar.spec.tsx new file mode 100644 index 000000000..2bd42ec02 --- /dev/null +++ b/apps/frontend/src/components/Navbar.spec.tsx @@ -0,0 +1,99 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import { useNavigate } from 'react-router-dom'; +import type { Mock } from 'vitest'; + +import apiClient, { type Volunteer } from '@api/apiClient'; +import { renderWithProviders } from '../testUtils'; +import Navbar from './Navbar'; + +vi.mock('@api/apiClient', () => ({ + default: { getVolunteer: vi.fn() }, +})); + +const getVolunteer = apiClient.getVolunteer as Mock; + +const volunteer = ( + volunteerId: number, + firstName: string, + lastName: string, +): Volunteer => ({ volunteerId, firstName, lastName }); + +const GoTo: React.FC<{ to: string }> = ({ to }) => { + const navigate = useNavigate(); + return ; +}; + +const avatarText = (container: ReturnType) => + container.getByTestId('navbar-avatar').textContent; + +describe('Navbar', () => { + beforeEach(() => { + getVolunteer.mockReset(); + }); + + it('renders the MSPCA logo', () => { + const view = renderWithProviders(); + expect( + view.getByAltText('MSPCA - Kindness and Care for Animals'), + ).toBeTruthy(); + }); + + it('shows the profile button', () => { + const view = renderWithProviders(); + expect(view.getByRole('button', { name: 'My Profile' })).toBeTruthy(); + }); + + it("shows the volunteer's initials", async () => { + getVolunteer.mockResolvedValue(volunteer(3, 'Matt', 'Wong')); + const view = renderWithProviders(, { route: '/?volunteerId=3' }); + + expect(await view.findByText('MW')).toBeTruthy(); + expect(getVolunteer).toHaveBeenCalledTimes(1); + expect(getVolunteer).toHaveBeenCalledWith(3); + }); + + it('shows a fallback avatar and makes no request without a volunteerId', () => { + const view = renderWithProviders(); + + expect(avatarText(view)).toBe(''); + expect(getVolunteer).not.toHaveBeenCalled(); + }); + + it('shows a fallback avatar and warns when the volunteer request fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + getVolunteer.mockRejectedValue(new Error('404')); + const view = renderWithProviders(, { route: '/?volunteerId=9' }); + + // Waiting for the warning means the rejection has been handled. + await waitFor(() => expect(warn).toHaveBeenCalledTimes(1)); + expect(avatarText(view)).toBe(''); + warn.mockRestore(); + }); + + it('ignores a stale response after the volunteerId changes', async () => { + let resolveFirst: (v: Volunteer) => void = () => undefined; + getVolunteer + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce(volunteer(2, 'Anna', 'Lee')); + + const view = renderWithProviders( + <> + + + , + { route: '/?volunteerId=1' }, + ); + + fireEvent.click(view.getByText('go')); + expect(await view.findByText('AL')).toBeTruthy(); + + resolveFirst(volunteer(1, 'Matt', 'Wong')); + await new Promise((r) => setTimeout(r, 0)); + + expect(avatarText(view)).toBe('AL'); + }); +}); diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx new file mode 100644 index 000000000..b7cac81d7 --- /dev/null +++ b/apps/frontend/src/components/Navbar.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { chakra, Flex, Image, Text } from '@chakra-ui/react'; + +import apiClient, { type Volunteer } from '@api/apiClient'; +import { useVolunteerId } from '@utils/useVolunteerId'; +import { getAvatarColor, getInitials } from '@utils/volunteer'; +import logo from '../assets/mspca-logo.png'; + +const VolunteerAvatar: React.FC<{ volunteer: Volunteer | null }> = ({ + volunteer, +}) => ( + + {volunteer ? getInitials(volunteer.firstName, volunteer.lastName) : ''} + +); + +/** Site-wide header: MSPCA logo and the current volunteer's profile button. */ +const Navbar: React.FC = () => { + const volunteerId = useVolunteerId(); + const [loaded, setLoaded] = useState(null); + // Only show a volunteer that matches the current URL, so a previous + // volunteer never lingers while the next one loads. + const volunteer = loaded?.volunteerId === volunteerId ? loaded : null; + + useEffect(() => { + if (volunteerId === null) return; + + let cancelled = false; + apiClient + .getVolunteer(volunteerId) + .then((result) => { + if (!cancelled) setLoaded(result); + }) + .catch((err: unknown) => { + // Unknown volunteer or network error: keep the fallback avatar. + console.warn(`Could not load volunteer ${volunteerId}`, err); + }); + return () => { + cancelled = true; + }; + }, [volunteerId]); + + return ( + + MSPCA - Kindness and Care for Animals + {/* TODO: link to the volunteer profile page once it exists. */} + + + + My Profile + + + + ); +}; + +export default Navbar; diff --git a/apps/frontend/src/containers/404.tsx b/apps/frontend/src/containers/404.tsx index d6a08187e..1b5246dd3 100644 --- a/apps/frontend/src/containers/404.tsx +++ b/apps/frontend/src/containers/404.tsx @@ -3,14 +3,24 @@ import { useRouteError } from 'react-router-dom'; import { getErrorMessage } from '@utils/router'; const NotFound: React.FC = () => { - const errorMessage = getErrorMessage(useRouteError()); + const error = useRouteError(); + + // Rendered by the catch-all route for unknown URLs, so there is no error. + if (error == null) { + return ( +
+

Page not found

+

Sorry, we couldn't find the page you're looking for.

+
+ ); + } return (

Oops!

Sorry, an unexpected error has occurred.

- {errorMessage} + {getErrorMessage(error)}

); diff --git a/apps/frontend/src/containers/Layout.tsx b/apps/frontend/src/containers/Layout.tsx new file mode 100644 index 000000000..37c84e7f0 --- /dev/null +++ b/apps/frontend/src/containers/Layout.tsx @@ -0,0 +1,17 @@ +import { Flex } from '@chakra-ui/react'; +import { Outlet } from 'react-router-dom'; + +import Navbar from '@components/Navbar'; + +/** + * Shared app shell: the navbar sits above every page. The column fills the + * viewport so pages can grow into the remaining height with `flex="1"`. + */ +const Layout: React.FC = () => ( + + + + +); + +export default Layout; diff --git a/apps/frontend/src/pages/AnimalAvailability.tsx b/apps/frontend/src/pages/AnimalAvailability.tsx index de0f7a22b..0b1ba5a01 100644 --- a/apps/frontend/src/pages/AnimalAvailability.tsx +++ b/apps/frontend/src/pages/AnimalAvailability.tsx @@ -4,7 +4,6 @@ import { Button, Flex, Heading, - Image, SimpleGrid, Spinner, Text, @@ -21,63 +20,6 @@ import { type AnimalFilterState, } from '@utils/animals'; import { MOCK_RESTRICTIONS } from '@utils/mockRestrictions'; -import logo from '../assets/mspca-logo.png'; - -const PageHeader: React.FC = () => ( - - MSPCA - Kindness and Care for Animals - - - {/* TODO: use the logged-in volunteer's initials */} - - MW - - - - My Profile - - - -); const AnimalAvailability: React.FC = () => { const [animals, setAnimals] = useState([]); @@ -133,8 +75,7 @@ const AnimalAvailability: React.FC = () => { ); return ( - - + + render( + + {ui} + , + ); diff --git a/apps/frontend/src/utils/useVolunteerId.spec.tsx b/apps/frontend/src/utils/useVolunteerId.spec.tsx new file mode 100644 index 000000000..8ffbdb5ff --- /dev/null +++ b/apps/frontend/src/utils/useVolunteerId.spec.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { useVolunteerId } from './useVolunteerId'; + +const Probe: React.FC = () => {String(useVolunteerId())}; + +const renderAt = (route: string) => + render( + + + , + ); + +describe('useVolunteerId', () => { + it('reads volunteerId from the query string', () => { + expect(renderAt('/x?volunteerId=7').getByText('7')).toBeTruthy(); + }); + + it('returns null when volunteerId is missing', () => { + expect(renderAt('/x').getByText('null')).toBeTruthy(); + }); + + it('returns null when volunteerId is not a valid ID', () => { + expect(renderAt('/x?volunteerId=abc').getByText('null')).toBeTruthy(); + }); +}); diff --git a/apps/frontend/src/utils/useVolunteerId.ts b/apps/frontend/src/utils/useVolunteerId.ts new file mode 100644 index 000000000..3f3a1a809 --- /dev/null +++ b/apps/frontend/src/utils/useVolunteerId.ts @@ -0,0 +1,12 @@ +import { useSearchParams } from 'react-router-dom'; + +import { parseVolunteerId } from './volunteer'; + +/** + * The current volunteer's ID, read from the `?volunteerId=` query param. + * TEMPORARY: replace with the signed-in volunteer once sign-in is implemented. + */ +export const useVolunteerId = (): number | null => { + const [searchParams] = useSearchParams(); + return parseVolunteerId(searchParams.get('volunteerId')); +}; diff --git a/apps/frontend/src/utils/volunteer.spec.ts b/apps/frontend/src/utils/volunteer.spec.ts new file mode 100644 index 000000000..1b33017db --- /dev/null +++ b/apps/frontend/src/utils/volunteer.spec.ts @@ -0,0 +1,53 @@ +import { getAvatarColor, getInitials, parseVolunteerId } from './volunteer'; + +describe('getInitials', () => { + it('uses the first letter of each name', () => { + expect(getInitials('Matt', 'Wong')).toBe('MW'); + }); + + it('trims whitespace and uppercases', () => { + expect(getInitials(' anna ', 'lee')).toBe('AL'); + }); + + it('returns an empty string when both names are empty', () => { + expect(getInitials('', '')).toBe(''); + }); + + it('returns a single initial when the last name is empty', () => { + expect(getInitials('Cher', '')).toBe('C'); + }); +}); + +describe('getAvatarColor', () => { + it('maps volunteer IDs onto the palette in order', () => { + expect(getAvatarColor(0)).toBe('purple.avatar'); + expect(getAvatarColor(1)).toBe('blue.blue2'); + }); + + it('wraps around the palette', () => { + expect(getAvatarColor(6)).toBe('purple.avatar'); + }); + + it('is stable for the same volunteer', () => { + expect(getAvatarColor(42)).toBe(getAvatarColor(42)); + }); +}); + +describe('parseVolunteerId', () => { + it('parses a positive integer', () => { + expect(parseVolunteerId('12')).toBe(12); + }); + + it.each([ + null, + '', + 'abc', + '0', + '-3', + '1.5', + '12abc', + '99999999999999999999999', + ])('returns null for %j', (raw) => { + expect(parseVolunteerId(raw)).toBeNull(); + }); +}); diff --git a/apps/frontend/src/utils/volunteer.ts b/apps/frontend/src/utils/volunteer.ts new file mode 100644 index 000000000..f827aabc0 --- /dev/null +++ b/apps/frontend/src/utils/volunteer.ts @@ -0,0 +1,25 @@ +// Avatar background colors, picked by volunteer ID so each volunteer keeps the +// same color. Placeholder palette pending design confirmation. +export const AVATAR_COLORS: readonly string[] = [ + 'purple.avatar', + 'blue.blue2', + 'teal.teal', + 'green.green2', + 'orange.orange3', + 'brown.brown', +]; + +const initial = (name: string): string => name.trim().charAt(0).toUpperCase(); + +export const getInitials = (firstName: string, lastName: string): string => + initial(firstName) + initial(lastName); + +export const getAvatarColor = (volunteerId: number): string => + AVATAR_COLORS[volunteerId % AVATAR_COLORS.length]; + +/** Parses a volunteer ID from a URL value; anything but a positive integer is null. */ +export const parseVolunteerId = (raw: string | null): number | null => { + if (raw === null || !/^\d+$/.test(raw)) return null; + const id = Number(raw); + return id > 0 && Number.isSafeInteger(id) ? id : null; +};