diff --git a/web/src/item-details/Comment.test.tsx b/web/src/item-details/Comment.test.tsx new file mode 100644 index 00000000..b6369bcb --- /dev/null +++ b/web/src/item-details/Comment.test.tsx @@ -0,0 +1,140 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; + +import { Comment as CommentModel } from '../models/comment'; +import { Comment } from './Comment'; + +function buildComment(overrides: Partial = {}): CommentModel { + return { + id: 1, + level: 0, + user: 'kate', + time: 1500000000, + time_ago: '2 hours ago', + content: '

Top level comment

', + comments: [], + ...overrides, + }; +} + +function renderComment(comment: CommentModel) { + return render( + + + + ); +} + +describe('Comment', () => { + it('renders the meta line, the user link and the html content', () => { + const { container } = renderComment(buildComment()); + + expect(screen.getByText('[-]')).toHaveClass('collapse'); + expect(screen.getByRole('link', { name: 'kate' })).toHaveAttribute('href', '/user/kate'); + expect(screen.getByText('2 hours ago')).toHaveClass('time'); + + const commentText = container.querySelector('.comment-tree .comment-text'); + expect(commentText).not.toBeNull(); + expect(commentText?.innerHTML).toBe('

Top level comment

'); + expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse'); + }); + + it('renders nested comments recursively', () => { + const comment = buildComment({ + content: 'level one', + comments: [ + buildComment({ + id: 2, + level: 1, + user: 'bob', + content: 'level two', + comments: [buildComment({ id: 3, level: 2, user: 'carol', content: 'level three' })], + }), + ], + }); + + const { container } = renderComment(comment); + + expect(screen.getByText('level one')).toBeInTheDocument(); + expect(screen.getByText('level two')).toBeInTheDocument(); + expect(screen.getByText('level three')).toBeInTheDocument(); + expect(container.querySelectorAll('.subtree')).toHaveLength(3); + + const firstSubtree = container.querySelector('.subtree'); + expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'bob' })).toBeInTheDocument(); + expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'carol' })).toBeInTheDocument(); + }); + + it('collapses and expands the comment content and its children', async () => { + const user = userEvent.setup(); + const comment = buildComment({ + content: 'parent', + comments: [buildComment({ id: 2, user: 'bob', content: 'child' })], + }); + + const { container } = renderComment(comment); + + expect(screen.getByText('parent')).toBeVisible(); + expect(screen.getByText('child')).toBeVisible(); + + await user.click(screen.getAllByText('[-]')[0]); + + expect(screen.getByText('[+]')).toBeInTheDocument(); + expect(container.querySelector('.meta')).toHaveClass('meta-collapse'); + expect(screen.getByText('parent')).not.toBeVisible(); + expect(screen.getByText('child')).not.toBeVisible(); + expect(screen.getByRole('link', { name: 'kate' })).toBeVisible(); + + await user.click(screen.getByText('[+]')); + + expect(screen.getAllByText('[-]')[0]).toBeInTheDocument(); + expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse'); + expect(screen.getByText('parent')).toBeVisible(); + }); + + it('collapses a child independently of its parent', async () => { + const user = userEvent.setup(); + const comment = buildComment({ + content: 'parent', + comments: [buildComment({ id: 2, user: 'bob', content: 'child' })], + }); + + renderComment(comment); + + await user.click(screen.getAllByText('[-]')[1]); + + expect(screen.getByText('parent')).toBeVisible(); + expect(screen.getByText('child')).not.toBeVisible(); + }); + + it('renders the deleted state instead of the comment body', () => { + const { container } = renderComment( + buildComment({ deleted: true, content: 'should not be rendered', user: 'ghost' }) + ); + + expect(screen.getByText('[deleted]')).toHaveClass('collapse'); + expect(container.querySelector('.deleted-meta')?.textContent).toBe('[deleted] | Comment Deleted'); + expect(screen.queryByText('should not be rendered')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'ghost' })).not.toBeInTheDocument(); + expect(container.querySelector('.comment-tree')).toBeNull(); + }); + + it('renders deleted children inside a live comment tree', () => { + const comment = buildComment({ + content: 'parent', + comments: [buildComment({ id: 2, user: 'ghost', deleted: true, content: 'hidden' })], + }); + + renderComment(comment); + + expect(screen.getByText('parent')).toBeInTheDocument(); + expect(screen.getByText('[deleted]')).toBeInTheDocument(); + expect(screen.queryByText('hidden')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/item-details/Comment.tsx b/web/src/item-details/Comment.tsx new file mode 100644 index 00000000..77e6e03d --- /dev/null +++ b/web/src/item-details/Comment.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { NavLink } from 'react-router-dom'; + +import { Comment as CommentModel } from '../models/comment'; +import './comment.scss'; + +export interface CommentProps { + comment: CommentModel; +} + +export function Comment({ comment }: CommentProps) { + const [collapse, setCollapse] = useState(false); + + if (comment.deleted) { + return ( +
+
+ [deleted] | Comment Deleted +
+
+ ); + } + + return ( +
+
+ setCollapse(!collapse)}> + [{collapse ? '+' : '-'}] + {' '} + {comment.user} + {comment.time_ago} +
+
+ +
+
+ ); +} + +export default Comment; diff --git a/web/src/item-details/comment.scss b/web/src/item-details/comment.scss new file mode 100644 index 00000000..05174846 --- /dev/null +++ b/web/src/item-details/comment.scss @@ -0,0 +1,81 @@ +@import '../styles/media'; +@import '../styles/theme_variables'; + +.comment-list { + a { + font-weight: bold; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + + .meta { + font-size: 13px; + color: #696969; + font-weight: bold; + letter-spacing: 0.5px; + margin-bottom: 8px; + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + .time { + padding-left: 5px; + } + + @media #{$mobile-only} { + font-size: 14px; + margin-bottom: 10px; + .time { + padding: 0; + float: right; + } + } + } + + .meta-collapse { + margin-bottom: 20px; + } + + .deleted-meta { + font-size: 12px; + font-weight: bold; + letter-spacing: 0.5px; + margin: 30px 0; + a { + text-decoration: none; + } + } + + .collapse { + font-size: 13px; + letter-spacing: 2px; + cursor: pointer; + } + + .comment-tree { + margin-left: 24px; + + @media #{$tablet-only} { + margin-left: 8px; + } + } + + .comment-text { + font-size: 15px; + margin-top: 0; + margin-bottom: 20px; + word-wrap: break-word; + line-height: 1.5em; + } + + .subtree { + margin-left: 0; + padding: 0; + list-style-type: none; + } +} diff --git a/web/src/item-details/itemDetails.scss b/web/src/item-details/itemDetails.scss new file mode 100644 index 00000000..8748353d --- /dev/null +++ b/web/src/item-details/itemDetails.scss @@ -0,0 +1,145 @@ +@import '../styles/media'; +@import '../styles/theme_variables'; + +.main-content { + position: relative; + width: 100%; + min-height: 100vh; + -webkit-transition: opacity 0.2s ease; + transition: opacity 0.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} { + padding: 10px 20px 0 40px; + } + + @media #{$mobile-only} { + 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; + + @media #{$mobile-only} { + font-size: 15px; + } + } + + .title-block { + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0 75px; + } + + @media #{$mobile-only} { + .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; + + a { + &:hover { + text-decoration: underline; + } + } + } + + .domain { + letter-spacing: 0.5px; + } + + .item-details { + padding: 10px; + } + + .item-header { + padding-bottom: 10px; + + @media #{$mobile-only} { + 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/web/src/pages/ItemDetailsPage.test.tsx b/web/src/pages/ItemDetailsPage.test.tsx new file mode 100644 index 00000000..272f7be8 --- /dev/null +++ b/web/src/pages/ItemDetailsPage.test.tsx @@ -0,0 +1,267 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fetchItemContent } from '../api/hackerNews'; +import { SettingsProvider } from '../context/SettingsContext'; +import { Comment } from '../models/comment'; +import { Story } from '../models/story'; +import { stubMatchMedia } from '../testUtils/matchMedia'; +import { ItemDetailsPage } from './ItemDetailsPage'; + +vi.mock('../api/hackerNews', () => ({ + fetchItemContent: vi.fn(), +})); + +const fetchItemContentMock = vi.mocked(fetchItemContent); + +function buildComment(overrides: Partial = {}): Comment { + return { + id: 100, + level: 0, + user: 'kate', + time: 1500000000, + time_ago: '1 hour ago', + content: 'a comment', + comments: [], + ...overrides, + }; +} + +function buildStory(overrides: Partial = {}): Story { + return { + id: 42, + title: 'A React story', + points: 120, + user: 'alice', + time: 1500000000, + time_ago: '3 hours ago', + type: 'story', + url: 'https://example.com/story', + domain: 'example.com', + comments: [], + comments_count: 2, + ...overrides, + }; +} + +function GoToItem({ id }: { id: number }) { + const navigate = useNavigate(); + return ; +} + +function renderPage(initialEntries: string[] = ['/item/42'], initialIndex?: number) { + return render( + + + + news feed

} /> + } /> +
+
+
+ ); +} + +describe('ItemDetailsPage', () => { + beforeEach(() => { + stubMatchMedia(false); + vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); + fetchItemContentMock.mockReset(); + }); + + it('shows the loader while the item is being fetched', () => { + fetchItemContentMock.mockReturnValue(new Promise(() => undefined)); + + const { container } = renderPage(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(container.querySelector('.main-content .loading-section')).not.toBeNull(); + expect(container.querySelector('.item')).toBeNull(); + }); + + it('fetches the item id taken from the route and scrolls to the top', async () => { + fetchItemContentMock.mockResolvedValue(buildStory()); + + renderPage(['/item/42']); + + await screen.findAllByText('A React story'); + expect(fetchItemContentMock).toHaveBeenCalledWith(42); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('shows the error message when the item cannot be loaded', async () => { + fetchItemContentMock.mockRejectedValue(new Error('offline')); + + const { container } = renderPage(); + + expect(await screen.findByText('Could not load item comments.')).toBeInTheDocument(); + expect(container.querySelector('.loading-section')).toBeNull(); + expect(container.querySelector('.item')).toBeNull(); + }); + + it('renders the mobile and laptop headers for a story with an external url', async () => { + fetchItemContentMock.mockResolvedValue(buildStory({ content: '

story body

' })); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + const mobileHeader = container.querySelector('.mobile.item-header') as HTMLElement; + + const mobileTitle = mobileHeader.querySelector('a.title'); + expect(mobileTitle).toHaveAttribute('href', 'https://example.com/story'); + expect(mobileTitle).not.toHaveAttribute('target'); + expect(mobileTitle).not.toHaveAttribute('rel'); + expect(mobileHeader.querySelector('.title-block .back-button')).not.toBeNull(); + + const laptopHeader = container.querySelector('.laptop') as HTMLElement; + expect(laptopHeader).toHaveClass('item-header'); + expect(laptopHeader).toHaveClass('head-margin'); + expect(laptopHeader.querySelector('a.title')).toHaveAttribute('href', 'https://example.com/story'); + expect(laptopHeader.querySelector('.domain')?.textContent).toBe('(example.com)'); + + const subtext = laptopHeader.querySelector('.subtext') as HTMLElement; + expect(subtext.textContent).toContain('120 points by'); + expect(subtext.querySelector('a[href="/user/alice"]')).not.toBeNull(); + expect(subtext.querySelector('.item-details')?.textContent).toContain('3 hours ago'); + expect(subtext.querySelector('a[href="/item/42"]')?.textContent).toBe('2 comments'); + + expect(container.querySelector('.subject')?.innerHTML).toBe('

story body

'); + }); + + it('opens the story link in a new tab when the setting is enabled', async () => { + localStorage.setItem('openLinkInNewTab', 'true'); + fetchItemContentMock.mockResolvedValue(buildStory()); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + container.querySelectorAll('a.title').forEach((title) => { + expect(title).toHaveAttribute('target', '_blank'); + expect(title).toHaveAttribute('rel', 'noopener'); + }); + }); + + it('links the title to the item itself when the story has no external url', async () => { + fetchItemContentMock.mockResolvedValue( + buildStory({ url: 'item?id=42', domain: undefined, comments_count: 0, content: undefined }) + ); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + container.querySelectorAll('a.title').forEach((title) => { + expect(title).toHaveAttribute('href', '/item/42'); + }); + expect(container.querySelector('.domain')).toBeNull(); + expect(container.querySelector('.laptop')).not.toHaveClass('item-header'); + expect(container.querySelector('.laptop')).not.toHaveClass('head-margin'); + expect(container.querySelector('.subtext a[href="/item/42"]')?.textContent).toBe('discuss'); + expect(container.querySelector('.subject')?.innerHTML).toBe(''); + }); + + it('hides the points and comment count for job postings', async () => { + fetchItemContentMock.mockResolvedValue(buildStory({ type: 'job', comments_count: 0 })); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + const subtext = container.querySelector('.laptop .subtext') as HTMLElement; + expect(subtext.textContent?.trim()).toBe('3 hours ago'); + expect(subtext.querySelector('.item-details')).toBeNull(); + expect(subtext.querySelector('a')).toBeNull(); + expect(container.querySelector('.laptop')).toHaveClass('item-header'); + }); + + it('renders poll results with bars sized from the vote share', async () => { + fetchItemContentMock.mockResolvedValue( + buildStory({ + type: 'poll', + poll: [ + { points: 30, content: '

Option A

' }, + { points: 10, content: '

Option B

' }, + ], + poll_votes_count: 40, + }) + ); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + const pollContents = container.querySelectorAll('.pollResults .pollContent'); + expect(pollContents).toHaveLength(2); + expect(pollContents[0].textContent).toContain('Option A'); + expect(pollContents[0].querySelector('.subtext')?.textContent).toBe('30 points'); + expect(pollContents[0].querySelector('.pollBar')?.style.width).toBe('75%'); + expect(pollContents[1].querySelector('.subtext')?.textContent).toBe('10 points'); + expect(pollContents[1].querySelector('.pollBar')?.style.width).toBe('25%'); + }); + + it('does not render poll results for a regular story', async () => { + fetchItemContentMock.mockResolvedValue(buildStory()); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + expect(container.querySelector('.pollResults')).toBeNull(); + }); + + it('renders the comment list, including nested comments', async () => { + fetchItemContentMock.mockResolvedValue( + buildStory({ + comments: [ + buildComment({ + id: 100, + content: 'first comment', + comments: [buildComment({ id: 101, user: 'bob', content: 'nested reply' })], + }), + buildComment({ id: 102, user: 'carol', content: 'second comment' }), + ], + }) + ); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + expect(container.querySelectorAll('.comment-list > li')).toHaveLength(2); + expect(screen.getByText('first comment')).toBeInTheDocument(); + expect(screen.getByText('nested reply')).toBeInTheDocument(); + expect(screen.getByText('second comment')).toBeInTheDocument(); + }); + + it('goes back in history when the back button is clicked', async () => { + const user = userEvent.setup(); + fetchItemContentMock.mockResolvedValue(buildStory()); + + const { container } = renderPage(['/', '/item/42'], 1); + await screen.findAllByText('A React story'); + + await user.click(container.querySelector('.back-button') as HTMLElement); + + expect(await screen.findByText('news feed')).toBeInTheDocument(); + }); + + it('refetches when the route id changes', async () => { + const user = userEvent.setup(); + fetchItemContentMock.mockResolvedValue(buildStory()); + + render( + + + + + } /> + + + + ); + await screen.findAllByText('A React story'); + + fetchItemContentMock.mockResolvedValue(buildStory({ id: 7, title: 'Another story' })); + await user.click(screen.getByRole('button', { name: 'go' })); + + await screen.findAllByText('Another story'); + expect(fetchItemContentMock).toHaveBeenLastCalledWith(7); + }); +}); diff --git a/web/src/pages/ItemDetailsPage.tsx b/web/src/pages/ItemDetailsPage.tsx index dcde57c0..a0ce996b 100644 --- a/web/src/pages/ItemDetailsPage.tsx +++ b/web/src/pages/ItemDetailsPage.tsx @@ -1,9 +1,141 @@ -/** - * Placeholder for the ported `ItemDetailsComponent`, implemented in Phase 2c. - * The item id comes from the `/item/:id` route via `useParams`. - */ +import { useEffect, useState } from 'react'; +import { NavLink, useNavigate, useParams } from 'react-router-dom'; + +import { fetchItemContent } from '../api/hackerNews'; +import { ErrorMessage } from '../components/ErrorMessage'; +import { Loader } from '../components/Loader'; +import { useSettings } from '../context/SettingsContext'; +import { Comment } from '../item-details/Comment'; +import { Story } from '../models/story'; +import { formatCommentCount } from '../utils/formatCommentCount'; +import '../item-details/itemDetails.scss'; + export function ItemDetailsPage() { - return null; + const { id } = useParams(); + const navigate = useNavigate(); + const { settings } = useSettings(); + const [item, setItem] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + let cancelled = false; + setItem(null); + setErrorMessage(''); + + fetchItemContent(Number(id)).then( + (story) => { + if (!cancelled) { + setItem(story); + } + }, + () => { + if (!cancelled) { + setErrorMessage('Could not load item comments.'); + } + } + ); + + window.scrollTo(0, 0); + + return () => { + cancelled = true; + }; + }, [id]); + + if (!item) { + return ( +
+ {errorMessage === '' ? : } +
+ ); + } + + const hasUrl = item.url !== undefined && item.url.indexOf('http') === 0; + const isJob = item.type === 'job'; + const target = settings.openLinkInNewTab ? '_blank' : undefined; + const rel = settings.openLinkInNewTab ? 'noopener' : undefined; + const laptopClassName = [ + 'laptop', + item.comments_count > 0 || isJob ? 'item-header' : '', + item.content ? 'head-margin' : '', + ] + .filter(Boolean) + .join(' '); + + return ( +
+
+
+

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

+
+
+ {hasUrl ? ( +

+ + {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) => ( +
  • + +
  • + ))} +
+
+
+ ); } export default ItemDetailsPage;