Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions web/src/pages/UserPage.tsx
Original file line number Diff line number Diff line change
@@ -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<User | null>(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 !== '' ? <ErrorMessage message={errorMessage} /> : <Loader />;
}

return (
<div className="profile">
<div className="mobile item-header">
<p className="title-block">
<span className="back-button" onClick={goBack}></span>
Profile: {user.id}
</p>
</div>
<div className="main-details">
<span className="name">{user.id}</span>
<span className="right">{user.karma} ★</span>
<p className="age">Created {user.created}</p>
</div>
{user.about && (
<div className="other-details">
<p dangerouslySetInnerHTML={{ __html: user.about }}></p>
</div>
)}
</div>
);
}

export default UserPage;
105 changes: 105 additions & 0 deletions web/src/user/UserPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '<p>Y Combinator</p><pre>indented</pre>',
};

function renderUserPage(entries: string[] = ['/user/pg'], initialIndex = 0) {
return render(
<SettingsProvider>
<MemoryRouter initialEntries={entries} initialIndex={initialIndex}>
<Routes>
<Route path="/news/:page" element={<p>news feed</p>} />
<Route path="/user/:id" element={<UserPage />} />
</Routes>
</MemoryRouter>
</SettingsProvider>
);
}

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('<p>Y Combinator</p><pre>indented</pre>');
});

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());
});
});
89 changes: 89 additions & 0 deletions web/src/user/user.scss
Original file line number Diff line number Diff line change
@@ -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;
}
Loading