From ced9c8555a4d925a25de1104d9194b97fe44aba8 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:45:17 +0000
Subject: [PATCH] feature: port Angular user profile page to React UserPage
Co-Authored-By: Vibha Seshadri
---
web/src/pages/UserPage.tsx | 71 ++++++++++++++++++++--
web/src/user/UserPage.test.tsx | 105 +++++++++++++++++++++++++++++++++
web/src/user/user.scss | 89 ++++++++++++++++++++++++++++
3 files changed, 260 insertions(+), 5 deletions(-)
create mode 100644 web/src/user/UserPage.test.tsx
create mode 100644 web/src/user/user.scss
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;
+}