diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 00000000..2b421f0d --- /dev/null +++ b/src/App.scss @@ -0,0 +1,24 @@ +@import "./scss/media"; +@import "./scss/theme_variables"; + +.body-cover { + width: 100%; + z-index: 0; + position: fixed; + height: 100%; +} + +.wrapper { + position: relative; + width: 85%; + min-height: 80px; + margin: 0 auto; + font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + font-size: 15px; + height: 100%; + line-height: 1.3; + + @media #{$mobile-only} { + width: 100%; + } +} diff --git a/src/components/Footer.scss b/src/components/Footer.scss new file mode 100644 index 00000000..ec664bb1 --- /dev/null +++ b/src/components/Footer.scss @@ -0,0 +1,23 @@ +@import "../scss/media"; +@import "../scss/theme_variables"; + +#footer { + position: relative; + padding: 10px; + height: 60px; + letter-spacing: 0.7px; + text-align: center; + + a { + font-weight: bold; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + @media #{$mobile-only} { + display: none; + } +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 00000000..dcd1c0f6 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,14 @@ +import './Footer.scss'; + +export default function Footer() { + return ( + + ); +} diff --git a/src/components/Header.scss b/src/components/Header.scss new file mode 100644 index 00000000..b97bc434 --- /dev/null +++ b/src/components/Header.scss @@ -0,0 +1,149 @@ +@import "../scss/media"; +@import "../scss/theme_variables"; + +#header { + color: #fff; + padding: 6px 0; + line-height: 18px; + vertical-align: middle; + position: relative; + z-index: 1; + width: 100%; + + @media #{$mobile-only} { + height: 50px; + position: fixed; + top: 0; + } + + a { + display: inline; + } +} + +.home-link { + width: 50px; + height: 66px; +} + +.logo-inner { + width: 32px; + position: absolute; + left: 17px; + top: 18px; + z-index: -1; + height: 32px; + border-radius: 50%; + + @media #{$mobile-only} { + left: 16px; + top: 12px; + } +} + +.logo { + width: 50px; + padding: 3px 8px 0; + + @media #{$mobile-only} { + width: 45px; + padding: 0 0 0 10px; + } +} + +h1 { + font-weight: normal; + display: inline-block; + vertical-align:middle; + margin: 0; + font-size: 16px; + + a { + color: #fff; + text-decoration: none; + } +} + +.name { + margin-right: 30px; + margin-bottom: 2px; + + @media #{$mobile-only} { + display: none; + } +} + +.header-text { + position: absolute; + width: inherit; + height: 20px; + left: 10px; + top: 27px; + z-index: -1; + + @media #{$mobile-only} { + top: 22px; + } +} + +.left { + position: absolute; + left: 60px; + font-size: 16px; + + @media #{$mobile-only} { + width: 100%; + left: 0; + } +} + +.header-nav { + display: inline-block; + margin-left: 20px; + + @media #{$mobile-only} { + margin-left: 60px; + } + + a { + color: hsla(0,0%,100%,.9); + text-decoration: none; + margin: 0 5px; + letter-spacing: 1.8px; + + &:hover { + color: #fff; + } + } + + .active { + color: #fff; + } +} + +.info { + position: absolute; + top: 0; + right: 20px; + height: 100%; + + @media #{$mobile-only} { + right: 10px; + } + + img { + opacity: 0.8; + width: 25px; + margin-top: 21.5px; + display: block; + + &:hover { + opacity: 1; + cursor: pointer; + } + + @media #{$mobile-only} { + margin-top: 15px; + } + } +} diff --git a/src/components/Header.test.tsx b/src/components/Header.test.tsx new file mode 100644 index 00000000..6cedce5d --- /dev/null +++ b/src/components/Header.test.tsx @@ -0,0 +1,52 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SettingsProvider } from '../context/SettingsContext'; +import Header from './Header'; + +function renderHeader(path = '/news/1') { + return render( + + +
+ + + ); +} + +beforeEach(() => localStorage.clear()); +afterEach(cleanup); + +describe('Header', () => { + it('links to every feed and marks the active one', () => { + renderHeader('/ask/1'); + + expect(screen.getByText('new').getAttribute('href')).toBe('/newest/1'); + expect(screen.getByText('show').getAttribute('href')).toBe('/show/1'); + expect(screen.getByText('jobs').getAttribute('href')).toBe('/jobs/1'); + expect(screen.getByText('ask').className).toBe('active'); + expect(screen.getByText('new').className).toBe(''); + }); + + it('scrolls to the top when a feed link is used', () => { + const scrollTo = vi.fn(); + vi.stubGlobal('scrollTo', scrollTo); + + renderHeader(); + fireEvent.click(screen.getByText('show')); + + expect(scrollTo).toHaveBeenCalledWith(0, 0); + vi.unstubAllGlobals(); + }); + + it('renders the settings panel only once toggled', () => { + renderHeader(); + + expect(screen.queryByText('Settings', { selector: 'h1' })).toBeNull(); + + fireEvent.click(screen.getByAltText('Settings')); + + expect(screen.getByText('Settings', { selector: 'h1' })).toBeTruthy(); + }); +}); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 00000000..254b5e0d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,58 @@ +import { NavLink } from 'react-router-dom'; + +import { useSettings } from '../context/SettingsContext'; +import Settings from './Settings'; + +import './Header.scss'; + +const FEEDS: { path: string; label: string }[] = [ + { path: '/newest/1', label: 'new' }, + { path: '/show/1', label: 'show' }, + { path: '/ask/1', label: 'ask' }, + { path: '/jobs/1', label: 'jobs' }, +]; + +function scrollTop() { + window.scrollTo(0, 0); +} + +export default function Header() { + const { settings, toggleSettings } = useSettings(); + + return ( +
+ + {settings.showSettings && } +
+ ); +} diff --git a/src/pages/Feed.scss b/src/pages/Feed.scss new file mode 100644 index 00000000..c369fe4d --- /dev/null +++ b/src/pages/Feed.scss @@ -0,0 +1,105 @@ +@import "../scss/media"; +@import "../scss/theme_variables"; + +a { + text-decoration: none; + font-weight: bold; + + &:hover { + text-decoration: underline; + }; +} + +ol { + padding: 0 40px; + margin: 0; + + @media #{$mobile-only} { + box-sizing: border-box; + list-style: none; + padding: 0 10px; + } + + li { + position: relative; + -webkit-transition: background-color .2s ease; + transition: background-color .2s ease; + } +} + +.list-margin { + @media #{$mobile-only} { + margin-top: 55px; + } +} + +.main-content { + position: relative; + width: 100%; + min-height: 100vh; + -webkit-transition: opacity .2s ease; + transition: opacity .2s ease; + box-sizing: border-box; + padding: 8px 0; + z-index: 0; +} + +.post { + padding: 10px 0 10px 5px; + transition: background-color 0.2s ease; + border-bottom: 1px solid #CECECB; + + .itemNum { + color: #696969; + position: absolute; + width: 30px; + text-align: right; + left: 0; + top: 4px; + } +} + + + +.nav { + padding: 10px 40px; + margin-top: 10px; + font-size: 17px; + + a { + @media #{$mobile-only} { + text-decoration: none; + } + } + + @media #{$mobile-only} { + margin: 20px 0; + text-align: center; + padding: 10px 80px; + height: 20px; + } + + .prev { + padding-right: 20px; + + @media #{$mobile-only} { + float: left; + padding-right: 0; + } + } + + .more { + @media #{$mobile-only} { + float: right; + } + } +} + +.job-header { + font-size: 15px; + padding: 0 40px 10px; + + @media #{$mobile-only} { + padding: 60px 15px 25px 15px; + } +} diff --git a/src/pages/Feed.test.tsx b/src/pages/Feed.test.tsx new file mode 100644 index 00000000..5af6773f --- /dev/null +++ b/src/pages/Feed.test.tsx @@ -0,0 +1,106 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SettingsProvider } from '../context/SettingsContext'; +import type { Story } from '../models/story'; +import Feed from './Feed'; + +function makeStory(id: number): Story { + return { + id, + title: `Story ${id}`, + points: 1, + user: 'pg', + time: 0, + time_ago: '1 hour ago', + type: 'story', + url: `https://example.com/${id}`, + domain: 'example.com', + content: '', + text: '', + comments: [], + comments_count: 0, + poll: [], + poll_votes_count: 0, + deleted: false, + dead: false, + }; +} + +function stubFetch(response: Story[] | Error) { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => { + if (response instanceof Error) { + throw response; + } + return { ok: true, status: 200, json: async () => response } as Response; + }); + + vi.stubGlobal('fetch', fetchMock); + + return fetchMock; +} + +function renderFeed(path: string) { + return render( + + + + } /> + + + + ); +} + +beforeEach(() => localStorage.clear()); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Feed', () => { + it('shows the loader, then the fetched stories for the route params', async () => { + const fetchMock = stubFetch([makeStory(1), makeStory(2)]); + const { container } = renderFeed('/news/2'); + + expect(container.querySelector('.loader')).toBeTruthy(); + expect(fetchMock.mock.calls[0][0]).toBe('https://node-hnapi.herokuapp.com/news?page=2'); + + await waitFor(() => expect(screen.getByText('Story 1')).toBeTruthy()); + expect(container.querySelector('ol')?.getAttribute('start')).toBe('31'); + }); + + it('renders a Prev link only past the first page and More only on a full page', async () => { + stubFetch(Array.from({ length: 30 }, (_, index) => makeStory(index + 1))); + renderFeed('/news/2'); + + await waitFor(() => expect(screen.getByText('‹ Prev').getAttribute('href')).toBe('/news/1')); + expect(screen.getByText('More ›').getAttribute('href')).toBe('/news/3'); + }); + + it('hides pagination links on a short first page', async () => { + stubFetch([makeStory(1)]); + renderFeed('/news/1'); + + await waitFor(() => expect(screen.getByText('Story 1')).toBeTruthy()); + expect(screen.queryByText('‹ Prev')).toBeNull(); + expect(screen.queryByText('More ›')).toBeNull(); + }); + + it('renders the jobs header and drops the list margin for the jobs feed', async () => { + stubFetch([makeStory(1)]); + const { container } = renderFeed('/jobs/1'); + + await waitFor(() => expect(screen.getByText(/These are jobs at startups/)).toBeTruthy()); + expect(container.querySelector('ol')?.className).toBe(''); + }); + + it('shows a feed specific error message when the request fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + stubFetch(new Error('offline')); + renderFeed('/show/1'); + + await waitFor(() => expect(screen.getByText('Could not load show stories.')).toBeTruthy()); + }); +}); diff --git a/src/pages/Feed.tsx b/src/pages/Feed.tsx new file mode 100644 index 00000000..e4c401ca --- /dev/null +++ b/src/pages/Feed.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; + +import { fetchFeed } from '../api/hackerNews'; +import ErrorMessage from '../components/ErrorMessage'; +import Item from '../components/Item'; +import Loader from '../components/Loader'; +import type { Story } from '../models/story'; + +import './Feed.scss'; + +const PAGE_SIZE = 30; + +export default function Feed() { + const { feedType = 'news', page } = useParams<{ feedType: string; page: string }>(); + const pageNum = page ? +page : 1; + + const [items, setItems] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const controller = new AbortController(); + + setItems(null); + setErrorMessage(''); + + fetchFeed(feedType, pageNum, controller.signal) + .then((stories) => { + setItems(stories); + window.scrollTo(0, 0); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + console.error(error); + setErrorMessage(`Could not load ${feedType} stories.`); + }); + + return () => controller.abort(); + }, [feedType, pageNum]); + + const listStart = (pageNum - 1) * PAGE_SIZE + 1; + + if (!items) { + return
{errorMessage ? : }
; + } + + return ( +
+ {feedType === 'jobs' && ( +

+ These are jobs at startups that were funded by Y Combinator. You can also get a job at a YC startup + through Triplebyte. +

+ )} +
    + {items.map((item) => ( +
  1. + +
  2. + ))} +
+
+ {listStart !== 1 && ( + + ‹ Prev + + )} + {items.length === PAGE_SIZE && ( + + More › + + )} +
+
+ ); +} diff --git a/src/pages/ItemDetails.scss b/src/pages/ItemDetails.scss new file mode 100644 index 00000000..c0133f4c --- /dev/null +++ b/src/pages/ItemDetails.scss @@ -0,0 +1,151 @@ +@import "../scss/media"; +@import "../scss/theme_variables"; + +.main-content { + position: relative; + width: 100%; + min-height: 100vh; + -webkit-transition: opacity .2s ease; + transition: opacity .2s ease; + box-sizing: border-box; + padding: 8px 0; + z-index: 0; +} + +.item { + box-sizing: border-box; + padding: 10px 40px 0 40px; + z-index: 0; +} + +@media #{$tablet-only} { + .item { + padding: 10px 20px 0 40px; + } +} + +@media #{$mobile-only} { + .item { + box-sizing: border-box; + padding: 110px 15px 0 15px; + } +} + +.head-margin { + margin-bottom: 15px; +} + +p { + margin: 2px 0; +} + +.subject { + word-wrap: break-word; + margin-top: 20px; +} + +a { + cursor: pointer; + text-decoration: none; +} + +@media #{$mobile-only} { + .laptop { + display: none; + } +} + +@media #{$laptop-only} { + .mobile { + display: none; + } +} + +.title { + font-size: 16px; + font-family: Verdana, Geneva, sans-serif; +} + +.title-block { + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0 75px; +} + +@media #{$mobile-only} { + .title { + font-size: 15px; + } + .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); + } +} + +.subtext { + font-size: 12px; + font-weight: bold; + letter-spacing: 0.5px; +} + +.domain { + letter-spacing: 0.5px; +} + +.subtext a { + &:hover { + text-decoration: underline; + } +} + +.item-details { + padding: 10px; +} + +.item-header { + padding-bottom: 10px; +} + +@media #{$mobile-only} { + .item-header { + padding: 10px 0 10px 0; + position: fixed; + width: 100%; + left: 0; + top: 62px; + } +} + +.pollResults { + margin-bottom: 1em; +} + +.pollContent { + * { + padding-bottom: 0; + margin-bottom: -1em; + margin-top: 1em; + } + .pollBar { + height: 10px; + margin-bottom: 1em; + } +} + +ul { + list-style-type: none; + padding: 10px 0; +} + +li { + display: list-item; +} diff --git a/src/pages/ItemDetails.test.tsx b/src/pages/ItemDetails.test.tsx new file mode 100644 index 00000000..2760d8b9 --- /dev/null +++ b/src/pages/ItemDetails.test.tsx @@ -0,0 +1,117 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SettingsProvider } from '../context/SettingsContext'; +import type { Comment } from '../models/comment'; +import type { Story } from '../models/story'; +import ItemDetails from './ItemDetails'; + +function makeItem(overrides: Partial = {}): Story { + return { + id: 10, + title: 'Ask HN: anything?', + points: 5, + user: 'pg', + time: 0, + time_ago: '3 hours ago', + type: 'story', + url: 'item?id=10', + domain: '', + content: '

Body copy

', + text: '', + comments: [], + comments_count: 1, + poll: [], + poll_votes_count: 0, + deleted: false, + dead: false, + ...overrides, + }; +} + +function makeComment(overrides: Partial = {}): Comment { + return { + id: 11, + level: 0, + user: 'kid', + time: 0, + time_ago: '1 hour ago', + content: 'Nested content', + deleted: false, + comments: [], + ...overrides, + }; +} + +function stubFetch(payloads: unknown[]) { + const fetchMock = vi.fn(async () => { + const payload = payloads.shift(); + return { ok: true, status: 200, json: async () => payload } as Response; + }); + + vi.stubGlobal('fetch', fetchMock); + + return fetchMock; +} + +function renderItemDetails() { + return render( + + + + } /> + elsewhere} /> + + + + ); +} + +beforeEach(() => localStorage.clear()); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('ItemDetails', () => { + it('renders the item body and its comments', async () => { + stubFetch([makeItem({ comments: [makeComment()] })]); + const { container } = renderItemDetails(); + + await waitFor(() => expect(container.querySelector('.subject')?.innerHTML).toBe('

Body copy

')); + expect(screen.getByText('Nested content')).toBeTruthy(); + expect(screen.getAllByText('1 comment').length).toBe(1); + }); + + it('renders poll results sized by their share of the votes', async () => { + stubFetch([ + makeItem({ type: 'poll', poll: [{ points: 0, content: 'A' }, { points: 0, content: 'B' }] }), + { points: 30, content: 'Option A' }, + { points: 10, content: 'Option B' }, + ]); + const { container } = renderItemDetails(); + + await waitFor(() => expect(screen.getByText('Option A')).toBeTruthy()); + expect(screen.getByText('30 points')).toBeTruthy(); + expect(container.querySelectorAll('.pollBar')[0].style.width).toBe('75%'); + }); + + it('navigates back from the mobile back button', async () => { + stubFetch([makeItem()]); + const { container } = renderItemDetails(); + + await waitFor(() => expect(container.querySelector('.back-button')).toBeTruthy()); + fireEvent.click(container.querySelector('.back-button') as HTMLElement); + + await waitFor(() => expect(screen.getByText('elsewhere')).toBeTruthy()); + }); + + it('shows an error message when the item cannot be loaded', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as Response)); + renderItemDetails(); + + await waitFor(() => expect(screen.getByText('Could not load item comments.')).toBeTruthy()); + }); +}); diff --git a/src/pages/ItemDetails.tsx b/src/pages/ItemDetails.tsx new file mode 100644 index 00000000..1d8e47d5 --- /dev/null +++ b/src/pages/ItemDetails.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; + +import { fetchItemContent } from '../api/hackerNews'; +import Comment from '../components/Comment'; +import ErrorMessage from '../components/ErrorMessage'; +import { hasUrl } from '../components/Item'; +import Loader from '../components/Loader'; +import { useSettings } from '../context/SettingsContext'; +import type { Story } from '../models/story'; +import { formatCommentCount } from '../utils/formatCommentCount'; + +import './ItemDetails.scss'; + +export default function ItemDetails() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { settings } = useSettings(); + + const [item, setItem] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const controller = new AbortController(); + + setItem(null); + setErrorMessage(''); + window.scrollTo(0, 0); + + fetchItemContent(Number(id), controller.signal) + .then(setItem) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + console.error(error); + setErrorMessage('Could not load item comments.'); + }); + + return () => controller.abort(); + }, [id]); + + if (!item) { + return
{errorMessage ? : }
; + } + + const isJob = item.type === 'job'; + const external = hasUrl(item); + const linkAttributes = { + target: settings.openLinkInNewTab ? '_blank' : undefined, + rel: settings.openLinkInNewTab ? 'noopener' : undefined, + }; + const laptopClasses = ['laptop']; + + if (item.comments_count > 0 || isJob) { + laptopClasses.push('item-header'); + } + if (item.text) { + laptopClasses.push('head-margin'); + } + + return ( +
+
+
+

+ navigate(-1)}> + {external ? ( + + {item.title} + + ) : ( + + {item.title} + + )} +

+
+
+ {external ? ( +

+ + {item.title} + + {item.domain && ({item.domain})} +

+ ) : ( +

+ + {item.title} + +

+ )} +
+ {!isJob && ( + + {item.points} points by {item.user} + + )} + + {item.time_ago} + {!isJob && ( + + {' '} + | {formatCommentCount(item.comments_count)} + + )} + +
+
+ {item.type === 'poll' && ( +
+ {item.poll.map((pollResult, index) => ( +
+
+
{pollResult.points} points
+
+
+ ))} +
+ )} +

+
    + {item.comments?.map((comment) => ( +
  • + +
  • + ))} +
+
+
+ ); +} diff --git a/src/pages/User.scss b/src/pages/User.scss new file mode 100644 index 00000000..1faca738 --- /dev/null +++ b/src/pages/User.scss @@ -0,0 +1,91 @@ +@import "../scss/media"; +@import "../scss/theme_variables"; + +/* Replaces Angular's `:host >>> pre`: targets the `
` blocks inside the
+   about text injected from the API. */
+.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;
+}
diff --git a/src/pages/User.test.tsx b/src/pages/User.test.tsx
new file mode 100644
index 00000000..d1a62455
--- /dev/null
+++ b/src/pages/User.test.tsx
@@ -0,0 +1,70 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { User as UserModel } from '../models/user';
+import User from './User';
+
+function stubFetch(user: UserModel | null) {
+    const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
+        if (!user) {
+            return { ok: false, status: 404 } as Response;
+        }
+        return { ok: true, status: 200, json: async () => user } as Response;
+    });
+
+    vi.stubGlobal('fetch', fetchMock);
+
+    return fetchMock;
+}
+
+function renderUser() {
+    return render(
+        
+            
+                } />
+            
+        
+    );
+}
+
+afterEach(() => {
+    cleanup();
+    vi.unstubAllGlobals();
+});
+
+describe('User', () => {
+    it('renders the profile of the routed user', async () => {
+        const fetchMock = stubFetch({
+            id: 'pg',
+            crated_time: 0,
+            created: 'January 1, 2007',
+            karma: 155000,
+            avg: 0,
+            about: '
Bio
', + }); + const { container } = renderUser(); + + expect(fetchMock.mock.calls[0][0]).toBe('https://node-hnapi.herokuapp.com/user/pg'); + + await waitFor(() => expect(screen.getByText('155000 ★')).toBeTruthy()); + expect(screen.getByText('Created January 1, 2007')).toBeTruthy(); + expect(container.querySelector('.other-details')?.innerHTML).toContain('
Bio
'); + }); + + it('omits the about block when the user has no bio', async () => { + stubFetch({ id: 'pg', crated_time: 0, created: 'January 1, 2007', karma: 1, avg: 0, about: '' }); + const { container } = renderUser(); + + await waitFor(() => expect(screen.getByText('1 ★')).toBeTruthy()); + expect(container.querySelector('.other-details')).toBeNull(); + }); + + it('names the user in the error message', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + stubFetch(null); + renderUser(); + + await waitFor(() => expect(screen.getByText('Could not load user pg.')).toBeTruthy()); + }); +}); diff --git a/src/pages/User.tsx b/src/pages/User.tsx new file mode 100644 index 00000000..f64e2f15 --- /dev/null +++ b/src/pages/User.tsx @@ -0,0 +1,61 @@ +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 type { User as UserModel } from '../models/user'; + +import './User.scss'; + +export default function User() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [user, setUser] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const controller = new AbortController(); + + setUser(null); + setErrorMessage(''); + + fetchUser(id ?? '', controller.signal) + .then(setUser) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + console.error(error); + setErrorMessage(`Could not load user ${id}.`); + }); + + return () => controller.abort(); + }, [id]); + + if (!user) { + return errorMessage ? : ; + } + + return ( +
+
+

+ navigate(-1)}> + Profile: {user.id} +

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

Created {user.created}

+
+ {user.about && ( +
+

+
+ )} +
+ ); +}