diff --git a/web/src/pages/UserPage.tsx b/web/src/pages/UserPage.tsx index 89348c35..9a550607 100644 --- a/web/src/pages/UserPage.tsx +++ b/web/src/pages/UserPage.tsx @@ -1,9 +1,70 @@ -/** - * Placeholder for the ported `UserComponent`, implemented in Phase 2d. - * The user id comes from the `/user/:id` route via `useParams`. - */ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { fetchUser } from '../api/hackerNews'; +import { ErrorMessage } from '../components/ErrorMessage'; +import { Loader } from '../components/Loader'; +import { User } from '../models/user'; +import '../user/user.scss'; + export function UserPage() { - return null; + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + if (!id) { + return; + } + + let cancelled = false; + setUser(null); + setErrorMessage(''); + + fetchUser(id) + .then((data) => { + if (!cancelled) { + setUser(data); + } + }) + .catch(() => { + if (!cancelled) { + setErrorMessage(`Could not load user ${id}.`); + } + }); + + return () => { + cancelled = true; + }; + }, [id]); + + const goBack = () => navigate(-1); + + if (!user) { + return errorMessage !== '' ? : ; + } + + return ( +
+
+

+ + Profile: {user.id} +

+
+
+ {user.id} + {user.karma} ★ +

Created {user.created}

+
+ {user.about && ( +
+

+
+ )} +
+ ); } export default UserPage; diff --git a/web/src/user/UserPage.test.tsx b/web/src/user/UserPage.test.tsx new file mode 100644 index 00000000..8856fe54 --- /dev/null +++ b/web/src/user/UserPage.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fetchUser } from '../api/hackerNews'; +import { SettingsProvider } from '../context/SettingsContext'; +import { User } from '../models/user'; +import { stubMatchMedia } from '../testUtils/matchMedia'; +import { UserPage } from '../pages/UserPage'; + +vi.mock('../api/hackerNews', () => ({ + fetchUser: vi.fn(), +})); + +const fetchUserMock = vi.mocked(fetchUser); + +const user: User = { + id: 'pg', + created: '4230 days ago', + karma: 155000, + about: '

Y Combinator

indented
', +}; + +function renderUserPage(entries: string[] = ['/user/pg'], initialIndex = 0) { + return render( + + + + news feed

} /> + } /> +
+
+
+ ); +} + +describe('UserPage', () => { + beforeEach(() => { + stubMatchMedia(false); + fetchUserMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('shows the loader until the user has been fetched', async () => { + fetchUserMock.mockResolvedValue(user); + + const { container } = renderUserPage(); + + expect(container.querySelector('.loading-section .loader')).not.toBeNull(); + expect(await screen.findByText('Created 4230 days ago')).toBeInTheDocument(); + expect(container.querySelector('.loading-section')).toBeNull(); + }); + + it('renders the profile of the fetched user', async () => { + fetchUserMock.mockResolvedValue(user); + + const { container } = renderUserPage(); + + await screen.findByText('Created 4230 days ago'); + + expect(fetchUserMock).toHaveBeenCalledWith('pg'); + expect(screen.getByText('Profile: pg')).toHaveClass('title-block'); + expect(container.querySelector('.mobile.item-header .back-button')).not.toBeNull(); + expect(container.querySelector('.main-details .name')).toHaveTextContent('pg'); + expect(container.querySelector('.main-details .right')).toHaveTextContent('155000 ★'); + expect(container.querySelector('.other-details p')?.innerHTML).toBe('

Y Combinator

indented
'); + }); + + it('omits the about section for a user without an about text', async () => { + fetchUserMock.mockResolvedValue({ id: 'lurker', created: '2 days ago', karma: 1 }); + + const { container } = renderUserPage(['/user/lurker']); + + await screen.findByText('Created 2 days ago'); + + expect(container.querySelector('.other-details')).toBeNull(); + }); + + it('shows an error message when the user could not be loaded', async () => { + fetchUserMock.mockRejectedValue(new Error('offline')); + + const { container } = renderUserPage(['/user/ghost']); + + expect(await screen.findByText('Could not load user ghost.')).toBeInTheDocument(); + expect(container.querySelector('.profile')).toBeNull(); + expect(container.querySelector('.loading-section')).toBeNull(); + }); + + it('goes back in the history when the back button is clicked', async () => { + fetchUserMock.mockResolvedValue(user); + + const { container } = renderUserPage(['/news/1', '/user/pg'], 1); + + await screen.findByText('Created 4230 days ago'); + + await userEvent.click(container.querySelector('.back-button') as HTMLElement); + + await waitFor(() => expect(screen.getByText('news feed')).toBeInTheDocument()); + }); +}); diff --git a/web/src/user/user.scss b/web/src/user/user.scss new file mode 100644 index 00000000..0288468e --- /dev/null +++ b/web/src/user/user.scss @@ -0,0 +1,89 @@ +@import '../styles/media'; +@import '../styles/theme_variables'; + +.profile pre { + white-space: pre-wrap; +} + +.profile { + padding: 30px; +} + +@media #{$mobile-only} { + .profile { + padding: 110px 15px 0 15px; + } + .title-block { + font-size: 15px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0 75px; + } + .back-button { + position: absolute; + top: 52%; + width: 0.6rem; + height: 0.6rem; + background: transparent; + box-shadow: 0 0 0 lightgray; + transition: all 200ms ease; + left: 4%; + transform: translate3d(0, -50%, 0) rotate(-135deg); + } + .item-header { + padding-bottom: 10px; + background-color: #fff; + padding: 10px 0 10px 0; + position: fixed; + width: 100%; + left: 0; + top: 62px; + height: 20px; + } +} + +@media #{$laptop-only} { + .mobile { + display: none; + } +} + +.main-details { + .name { + font-weight: bold; + font-size: 32px; + letter-spacing: 2px; + } + .age { + font-weight: bold; + color: #696969; + padding-bottom: 0; + } + .right { + float: right; + font-weight: bold; + font-size: 32px; + letter-spacing: 2px; + } +} + +@media #{$mobile-only} { + .main-details { + margin-top: 20px; + .name { + font-size: 18px; + } + } +} + +@media #{$mobile-only} { + .main-details .right { + font-size: 18px; + } +} + +.other-details { + word-wrap: break-word; +}