Skip to content

Commit 40e003b

Browse files
feature: Phase 2C user profile feature
Co-Authored-By: Vibha Seshadri <vibha.seshadri@cognition.ai>
1 parent c37984f commit 40e003b

4 files changed

Lines changed: 358 additions & 0 deletions

File tree

react/src/features/user/User.scss

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
@import '../../styles/media';
2+
@import '../../styles/theme_variables';
3+
4+
.user pre {
5+
white-space: pre-wrap;
6+
}
7+
8+
.profile {
9+
padding: 30px;
10+
}
11+
12+
@media #{$mobile-only} {
13+
.profile {
14+
padding: 110px 15px 0 15px;
15+
}
16+
.title-block {
17+
font-size: 15px;
18+
text-align: center;
19+
text-overflow: ellipsis;
20+
white-space: nowrap;
21+
overflow: hidden;
22+
margin: 0 75px;
23+
}
24+
.back-button {
25+
position: absolute;
26+
top: 52%;
27+
width: 0.6rem;
28+
height: 0.6rem;
29+
background: transparent;
30+
box-shadow: 0 0 0 lightgray;
31+
transition: all 200ms ease;
32+
left: 4%;
33+
transform: translate3d(0, -50%, 0) rotate(-135deg);
34+
}
35+
.item-header {
36+
padding-bottom: 10px;
37+
background-color: #fff;
38+
padding: 10px 0 10px 0;
39+
position: fixed;
40+
width: 100%;
41+
left: 0;
42+
top: 62px;
43+
height: 20px;
44+
}
45+
}
46+
47+
@media #{$laptop-only} {
48+
.mobile {
49+
display: none;
50+
}
51+
}
52+
53+
.main-details {
54+
.name {
55+
font-weight: bold;
56+
font-size: 32px;
57+
letter-spacing: 2px;
58+
}
59+
.age {
60+
font-weight: bold;
61+
color: #696969;
62+
padding-bottom: 0;
63+
}
64+
.right {
65+
float: right;
66+
font-weight: bold;
67+
font-size: 32px;
68+
letter-spacing: 2px;
69+
}
70+
}
71+
72+
@media #{$mobile-only} {
73+
.main-details {
74+
margin-top: 20px;
75+
.name {
76+
font-size: 18px;
77+
}
78+
}
79+
}
80+
81+
@media #{$mobile-only} {
82+
.main-details .right {
83+
font-size: 18px;
84+
}
85+
}
86+
87+
.other-details {
88+
word-wrap: break-word;
89+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { act, render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
4+
5+
import UserProfile from './index';
6+
import { User } from '../../shared/models';
7+
import { hackerNewsApi } from '../../shared/services/hackernews-api';
8+
9+
const mockNavigate = vi.hoisted(() => vi.fn());
10+
11+
vi.mock('react-router-dom', async () => ({
12+
...(await vi.importActual<typeof import('react-router-dom')>('react-router-dom')),
13+
useNavigate: () => mockNavigate,
14+
}));
15+
16+
vi.mock('../../shared/services/hackernews-api', () => ({
17+
hackerNewsApi: {
18+
fetchUser: vi.fn(),
19+
},
20+
}));
21+
22+
function makeUser(id = 'a', about = ''): User {
23+
return {
24+
id,
25+
crated_time: 1672531200,
26+
created: '2 years ago',
27+
karma: 123,
28+
avg: 4.5,
29+
about,
30+
};
31+
}
32+
33+
function deferred<T>() {
34+
let resolve!: (value: T | PromiseLike<T>) => void;
35+
let reject!: (reason?: unknown) => void;
36+
const promise = new Promise<T>((promiseResolve, promiseReject) => {
37+
resolve = promiseResolve;
38+
reject = promiseReject;
39+
});
40+
41+
return { promise, resolve, reject };
42+
}
43+
44+
function renderUser(initialEntries = ['/user/a'], initialIndex = 0, withNavigationLink = false) {
45+
return render(
46+
<MemoryRouter initialEntries={initialEntries} initialIndex={initialIndex}>
47+
<Routes>
48+
<Route
49+
path="/user/:id"
50+
element={
51+
<>
52+
<UserProfile />
53+
{withNavigationLink && <Link to="/user/b">go</Link>}
54+
</>
55+
}
56+
/>
57+
</Routes>
58+
</MemoryRouter>
59+
);
60+
}
61+
62+
describe('UserProfile', () => {
63+
beforeEach(() => {
64+
vi.mocked(hackerNewsApi.fetchUser).mockReset();
65+
mockNavigate.mockReset();
66+
});
67+
68+
it('shows loading while the user request is pending', () => {
69+
vi.mocked(hackerNewsApi.fetchUser).mockReturnValue(new Promise<User>(() => undefined));
70+
71+
renderUser();
72+
73+
expect(screen.getByText('Loading...')).toBeInTheDocument();
74+
expect(document.querySelector('.profile')).not.toBeInTheDocument();
75+
});
76+
77+
it('shows an error when the user request rejects', async () => {
78+
vi.mocked(hackerNewsApi.fetchUser).mockRejectedValueOnce(new Error('request failed'));
79+
80+
renderUser();
81+
82+
expect(await screen.findByText('Could not load user a.')).toBeInTheDocument();
83+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
84+
expect(document.querySelector('.profile')).not.toBeInTheDocument();
85+
});
86+
87+
it('renders the user profile', async () => {
88+
vi.mocked(hackerNewsApi.fetchUser).mockResolvedValueOnce(makeUser());
89+
90+
renderUser();
91+
92+
expect(await screen.findByText('a', { selector: '.name' })).toBeInTheDocument();
93+
expect(screen.getByText('123 ★')).toHaveClass('right');
94+
expect(screen.getByText('Created 2 years ago')).toHaveClass('age');
95+
expect(screen.getByText('Profile: a')).toHaveClass('title-block');
96+
});
97+
98+
it('renders HTML in the about section', async () => {
99+
vi.mocked(hackerNewsApi.fetchUser).mockResolvedValueOnce(makeUser('a', '<b>hello</b> <pre>x</pre>'));
100+
101+
renderUser();
102+
103+
await screen.findByText('a', { selector: '.name' });
104+
105+
const about = document.querySelector('.other-details p');
106+
expect(about).not.toBeNull();
107+
expect(about!.innerHTML).toBe('<b>hello</b> <pre>x</pre>');
108+
expect(about!.querySelector('b')).toBeInTheDocument();
109+
});
110+
111+
it('does not render an about section for an empty string', async () => {
112+
vi.mocked(hackerNewsApi.fetchUser).mockResolvedValueOnce(makeUser('a', ''));
113+
114+
renderUser();
115+
116+
await screen.findByText('a', { selector: '.name' });
117+
118+
expect(document.querySelector('.other-details')).not.toBeInTheDocument();
119+
});
120+
121+
it('does not render an about section when about is undefined', async () => {
122+
vi.mocked(hackerNewsApi.fetchUser).mockResolvedValueOnce({
123+
...makeUser(),
124+
about: undefined,
125+
} as unknown as User);
126+
127+
renderUser();
128+
129+
await screen.findByText('a', { selector: '.name' });
130+
131+
expect(document.querySelector('.other-details')).not.toBeInTheDocument();
132+
});
133+
134+
it('navigates back when the back button is clicked', async () => {
135+
vi.mocked(hackerNewsApi.fetchUser).mockResolvedValueOnce(makeUser());
136+
const user = userEvent.setup();
137+
138+
renderUser();
139+
140+
await screen.findByText('a', { selector: '.name' });
141+
await user.click(document.querySelector('.back-button')!);
142+
143+
expect(mockNavigate).toHaveBeenCalledWith(-1);
144+
});
145+
146+
it('fetches the new user when the route ID changes', async () => {
147+
const userA = deferred<User>();
148+
const userB = deferred<User>();
149+
vi.mocked(hackerNewsApi.fetchUser).mockImplementation((id) => (id === 'a' ? userA.promise : userB.promise));
150+
const user = userEvent.setup();
151+
152+
renderUser(['/user/a', '/user/b'], 0, true);
153+
154+
await waitFor(() => expect(hackerNewsApi.fetchUser).toHaveBeenCalledWith('a'));
155+
await user.click(screen.getByRole('link', { name: 'go' }));
156+
await waitFor(() => expect(hackerNewsApi.fetchUser).toHaveBeenCalledWith('b'));
157+
expect(screen.getByText('Loading...')).toBeInTheDocument();
158+
159+
await act(async () => userB.resolve(makeUser('b')));
160+
161+
expect(await screen.findByText('b', { selector: '.name' })).toBeInTheDocument();
162+
});
163+
164+
it('fetches the exact route ID once', async () => {
165+
vi.mocked(hackerNewsApi.fetchUser).mockResolvedValueOnce(makeUser());
166+
167+
renderUser();
168+
169+
await screen.findByText('a', { selector: '.name' });
170+
171+
expect(hackerNewsApi.fetchUser).toHaveBeenCalledTimes(1);
172+
expect(hackerNewsApi.fetchUser).toHaveBeenCalledWith('a');
173+
});
174+
175+
it('ignores a stale response after the route ID changes', async () => {
176+
const userA = deferred<User>();
177+
const userB = deferred<User>();
178+
vi.mocked(hackerNewsApi.fetchUser).mockImplementation((id) => (id === 'a' ? userA.promise : userB.promise));
179+
const user = userEvent.setup();
180+
181+
renderUser(['/user/a', '/user/b'], 0, true);
182+
183+
await waitFor(() => expect(hackerNewsApi.fetchUser).toHaveBeenCalledWith('a'));
184+
await user.click(screen.getByRole('link', { name: 'go' }));
185+
await waitFor(() => expect(hackerNewsApi.fetchUser).toHaveBeenCalledWith('b'));
186+
187+
await act(async () => userA.resolve(makeUser('a')));
188+
expect(document.querySelector('.profile')).not.toBeInTheDocument();
189+
190+
await act(async () => userB.resolve(makeUser('b')));
191+
192+
expect(await screen.findByText('b', { selector: '.name' })).toBeInTheDocument();
193+
expect(document.querySelector('.name')).toHaveTextContent('b');
194+
expect(document.querySelector('.name')).not.toHaveTextContent('a');
195+
});
196+
});

react/src/features/user/User.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useEffect, useState } from 'react';
2+
import { useNavigate, useParams } from 'react-router-dom';
3+
4+
import { User } from '../../shared/models';
5+
import { hackerNewsApi } from '../../shared/services/hackernews-api';
6+
import { ErrorMessage, Loader } from '../../shared/components';
7+
8+
import './User.scss';
9+
10+
export function UserProfile() {
11+
const { id } = useParams();
12+
const navigate = useNavigate();
13+
const userId = id ?? '';
14+
const [user, setUser] = useState<User | null>(null);
15+
const [errorMessage, setErrorMessage] = useState('');
16+
17+
useEffect(() => {
18+
let cancelled = false;
19+
setUser(null);
20+
setErrorMessage('');
21+
22+
hackerNewsApi.fetchUser(userId).then(
23+
(data) => {
24+
if (!cancelled) {
25+
setUser(data);
26+
}
27+
},
28+
() => {
29+
if (!cancelled) {
30+
setErrorMessage('Could not load user ' + userId + '.');
31+
}
32+
}
33+
);
34+
35+
return () => {
36+
cancelled = true;
37+
};
38+
}, [userId]);
39+
40+
const goBack = () => navigate(-1);
41+
42+
return (
43+
<div className="user">
44+
{!user && !errorMessage && <Loader />}
45+
{!user && errorMessage !== '' && <ErrorMessage message={errorMessage} />}
46+
47+
{user && (
48+
<div className="profile">
49+
<div className="mobile item-header">
50+
<p className="title-block">
51+
<span className="back-button" onClick={goBack}></span>
52+
Profile: {user.id}
53+
</p>
54+
</div>
55+
<div className="main-details">
56+
<span className="name">{user.id}</span>
57+
<span className="right">{user.karma}</span>
58+
<p className="age">Created {user.created}</p>
59+
</div>
60+
{user.about && (
61+
<div className="other-details">
62+
<p dangerouslySetInnerHTML={{ __html: user.about }}></p>
63+
</div>
64+
)}
65+
</div>
66+
)}
67+
</div>
68+
);
69+
}
70+
71+
export default UserProfile;

react/src/features/user/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { UserProfile } from './User';
2+
export { default } from './User';

0 commit comments

Comments
 (0)