From 89cce5342713aa7d8bb638ac40ca24b7bbe2df07 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:31:43 +0000 Subject: [PATCH] feature: port item details (recursive collapsible comments, polls) and user profile to React Co-Authored-By: Ishita Gupta --- .../src/features/item-details/Comment.scss | 90 +++++++ .../features/item-details/Comment.test.tsx | 131 +++++++++- .../src/features/item-details/Comment.tsx | 53 +++- .../features/item-details/ItemDetails.scss | 169 +++++++++++++ .../item-details/ItemDetails.test.tsx | 230 +++++++++++++++++- .../src/features/item-details/ItemDetails.tsx | 123 +++++++++- .../src/features/item-details/buttonKeys.ts | 11 + react-app/src/features/user/UserProfile.scss | 101 ++++++++ .../src/features/user/UserProfile.test.tsx | 118 ++++++++- react-app/src/features/user/UserProfile.tsx | 54 +++- 10 files changed, 1050 insertions(+), 30 deletions(-) create mode 100644 react-app/src/features/item-details/Comment.scss create mode 100644 react-app/src/features/item-details/ItemDetails.scss create mode 100644 react-app/src/features/item-details/buttonKeys.ts create mode 100644 react-app/src/features/user/UserProfile.scss diff --git a/react-app/src/features/item-details/Comment.scss b/react-app/src/features/item-details/Comment.scss new file mode 100644 index 000000000..68d7f599e --- /dev/null +++ b/react-app/src/features/item-details/Comment.scss @@ -0,0 +1,90 @@ +// Port of src/app/item-details/comment/comment.component.scss, scoped under `.app-comment` (Angular's +// `` host). `:where()` keeps Angular's specificity so `_themes.scss` rules still win. +@use '../../shared/scss/media' as *; + +:where(.app-comment) { + .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} { + .meta { + 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} { + .comment-tree { + 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; + } +} + +// Angular's `:host >>> a { font-weight: bold; text-decoration: none }` never reached the comment HTML (measured on +// :4300: normal-weight, underlined links). Pin those browser defaults against leaked rules. +.app-comment .comment-text { + p { + margin: 1em 0; + } + a { + font-weight: normal; + text-decoration: underline; + } +} diff --git a/react-app/src/features/item-details/Comment.test.tsx b/react-app/src/features/item-details/Comment.test.tsx index 5135e5363..47b070d37 100644 --- a/react-app/src/features/item-details/Comment.test.tsx +++ b/react-app/src/features/item-details/Comment.test.tsx @@ -1,14 +1,133 @@ -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { Comment as CommentModel } from '../../shared/models'; import { renderWithProviders } from '../../shared/test-utils'; import { Comment } from './Comment'; -// Placeholder test — owned by the Item-details child; replace with real Comment tests. -describe('Comment (placeholder)', () => { - it('takes a comment', () => { - renderWithProviders(); - expect(screen.getByTestId('comment')).toHaveTextContent('pg'); +function comment(overrides: Partial = {}): CommentModel { + return { + id: 1, + level: 0, + user: 'pg', + time: 0, + time_ago: '2 hours ago', + content: '

Top level

', + comments: [], + ...overrides, + }; +} + +const tree = comment({ + comments: [ + comment({ + id: 2, + level: 1, + user: 'dang', + content: 'Reply', + comments: [comment({ id: 3, level: 2, user: 'tptacek', content: 'Nested reply' })], + }), + ], +}); + +describe('Comment', () => { + it('renders the meta line ([-], user link, time) and the sanitized content', () => { + const { container } = renderWithProviders( + alert(1)

there x' })} /> + ); + const meta = container.querySelector('.meta')!; + expect(meta).not.toHaveClass('meta-collapse'); + expect(meta.querySelector('span.collapse')).toHaveTextContent('[-]'); + expect(within(meta as HTMLElement).getByRole('link', { name: 'pg' })).toHaveAttribute('href', '/user/pg'); + expect(meta.querySelector('.time')).toHaveTextContent('2 hours ago'); + // Angular strips the template whitespace between `[-]`, the user link and `.time`. + expect(meta.textContent).toBe('[-]pg2 hours ago'); + const text = container.querySelector('p.comment-text')!; + expect(text.querySelector('script')).toBeNull(); + expect(text.querySelector('a')).toHaveAttribute('href', 'https://news.ycombinator.com/item?id=5'); + expect(text.querySelector('a')).toHaveAttribute('rel', 'nofollow noopener noreferrer'); + }); + + it('renders the reply tree recursively', () => { + const { container } = renderWithProviders(); + expect(container.querySelectorAll('.app-comment')).toHaveLength(3); + const nested = container.querySelectorAll('ul.subtree > li > .app-comment'); + expect(nested).toHaveLength(2); + expect(screen.getByRole('link', { name: 'tptacek' })).toBeInTheDocument(); + expect(screen.getByText('Nested reply')).toBeVisible(); + }); + + it('collapses and expands its subtree with a click on [-]/[+]', async () => { + const { container, user } = renderWithProviders(); + const toggle = screen.getByRole('button', { name: 'Collapse comment by pg' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + await user.click(toggle); + expect(toggle).toHaveTextContent('[+]'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveAccessibleName('Expand comment by pg'); + expect(container.querySelector('.meta')).toHaveClass('meta-collapse'); + expect(container.querySelector('.comment-tree > div')).toHaveAttribute('hidden'); + expect(screen.getByText('Top level')).not.toBeVisible(); + expect(screen.getByText('Nested reply')).not.toBeVisible(); + // The collapsed comment's own meta line stays visible. + expect(screen.getByRole('link', { name: 'pg' })).toBeVisible(); + + await user.click(toggle); + expect(toggle).toHaveTextContent('[-]'); + expect(screen.getByText('Nested reply')).toBeVisible(); + }); + + it('collapses a nested comment without touching its parent', async () => { + const { user } = renderWithProviders(); + await user.click(screen.getByRole('button', { name: 'Collapse comment by dang' })); + expect(screen.getByText('Top level')).toBeVisible(); + expect(screen.getByRole('link', { name: 'dang' })).toBeVisible(); + expect(screen.getByText('Reply')).not.toBeVisible(); + expect(screen.getByText('Nested reply')).not.toBeVisible(); + }); + + it('toggles from the keyboard with Enter and Space', async () => { + const { user } = renderWithProviders(); + const toggle = screen.getByRole('button', { name: 'Collapse comment by pg' }); + expect(toggle.tagName).toBe('SPAN'); + expect(toggle).toHaveAttribute('tabindex', '0'); + + await user.tab(); + expect(toggle).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await user.keyboard(' '); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await user.keyboard('a'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders deleted comments as "[deleted] | Comment Deleted" without their tree or toggle', () => { + const { container } = renderWithProviders( + + ); + expect(container.querySelector('.deleted-meta')).toHaveTextContent('[deleted] | Comment Deleted'); + expect(container.querySelector('.deleted-meta span.collapse')).toHaveTextContent('[deleted]'); + expect(container.querySelector('.meta')).toBeNull(); + expect(container.querySelector('.comment-tree')).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('renders dead comments like Angular (content as sent, toggle still works)', () => { + const { container } = renderWithProviders( + + ); + expect(container.querySelector('.comment-text')).toHaveTextContent('[flagged]'); + expect(container.querySelectorAll('ul.subtree > li')).toHaveLength(0); + expect(screen.getByRole('button', { name: 'Collapse comment by pg' })).toBeInTheDocument(); + }); + + it('navigates to the commenter profile', async () => { + const { user } = renderWithProviders(, { + otherRoutes: { '/user/:id':

user page

}, + }); + await user.click(screen.getByRole('link', { name: 'pg' })); + expect(screen.getByText('user page')).toBeInTheDocument(); }); }); diff --git a/react-app/src/features/item-details/Comment.tsx b/react-app/src/features/item-details/Comment.tsx index 293b6dd20..2036a8aea 100644 --- a/react-app/src/features/item-details/Comment.tsx +++ b/react-app/src/features/item-details/Comment.tsx @@ -1,10 +1,59 @@ +import { useState } from 'react'; +import { NavLink } from 'react-router'; + import type { Comment as CommentModel } from '../../shared/models'; +import { sanitizeHtml } from '../../shared/utils'; +import { onButtonKeyDown } from './buttonKeys'; +import './Comment.scss'; export interface CommentProps { comment: CommentModel; } -/** Placeholder — Item-details child ports Angular `item-details/comment` (recursive tree). */ +/** Port of Angular `app-comment` (item-details/comment): recursive tree with a `[-]`/`[+]` collapse toggle. */ export function Comment({ comment }: CommentProps) { - return
Comment placeholder: {comment.user}
; + const [collapse, setCollapse] = useState(false); + const toggle = () => setCollapse((value) => !value); + + if (comment.deleted) { + return ( +
+
+ [deleted] | Comment Deleted +
+
+ ); + } + + return ( +
+
+ + [{collapse ? '+' : '-'}] + + {comment.user} + {comment.time_ago} +
+
+ +
+
+ ); } diff --git a/react-app/src/features/item-details/ItemDetails.scss b/react-app/src/features/item-details/ItemDetails.scss new file mode 100644 index 000000000..7c2784cb8 --- /dev/null +++ b/react-app/src/features/item-details/ItemDetails.scss @@ -0,0 +1,169 @@ +// Port of src/app/item-details/item-details.component.scss, scoped under `.main-content.item-details`. +// Angular's view encapsulation limited element selectors (`p`, `a`, `ul`, `li`, `*`) to this template, so here they +// only target template elements, never the sanitized HN HTML or the comment tree. `:where()` keeps Angular's +// specificity so `_themes.scss` rules still win. +@use '../../shared/scss/media' as *; + +.main-content.item-details { + 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; +} + +:where(.main-content.item-details) { + .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; + } + + :where(.mobile, .laptop) > p, + :where(.item) > p { + margin: 2px 0; + } + + .subject { + word-wrap: break-word; + margin-top: 20px; + } + + :where(.item-header, .laptop) 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; + } + } + + .subtext .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; + } + } + + .comment-list { + list-style-type: none; + padding: 10px 0; + } + + .comment-list > li { + display: list-item; + } +} + +// Sanitized HN HTML (story text, poll options) had no component styles in Angular: browser-default paragraphs and +// links, pinned at full specificity so leaked rules from other components can't restyle them. +.main-content.item-details :is(.subject, .pollContent > div) { + p { + margin: 1em 0; + } + a { + font-weight: normal; + text-decoration: underline; + } +} diff --git a/react-app/src/features/item-details/ItemDetails.test.tsx b/react-app/src/features/item-details/ItemDetails.test.tsx index 6d6925c6f..7237a2d14 100644 --- a/react-app/src/features/item-details/ItemDetails.test.tsx +++ b/react-app/src/features/item-details/ItemDetails.test.tsx @@ -1,13 +1,229 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { useNavigate } from 'react-router'; -import { renderWithProviders } from '../../shared/test-utils'; +import { apiUrls } from '../../shared/api'; +import type { Story } from '../../shared/models'; +import { deferred, mockFetch, renderWithProviders, textResponse } from '../../shared/test-utils'; import { ItemDetails } from './ItemDetails'; -// Placeholder test — owned by the Item-details child; replace with real ItemDetails tests. -describe('ItemDetails (placeholder)', () => { - it('reads :id', () => { - renderWithProviders(, { route: '/item/42', path: '/item/:id' }); - expect(screen.getByTestId('item-details')).toHaveTextContent('item 42'); +function story(overrides: Partial = {}): Story { + return { + id: 42, + title: 'A great story', + points: 128, + user: 'pg', + time: 0, + time_ago: '3 hours ago', + type: 'link', + url: 'https://example.com/post', + domain: 'example.com', + comments: [ + { + id: 100, + level: 0, + user: 'dang', + time: 0, + time_ago: '2 hours ago', + content: 'First!', + comments: [], + }, + ], + comments_count: 1, + ...overrides, + }; +} + +function renderItem(id: string | number = 42, otherRoutes = {}) { + return renderWithProviders(, { route: `/item/${id}`, path: '/item/:id', otherRoutes }); +} + +describe('ItemDetails', () => { + it('shows the loader while the item loads, then the item', async () => { + const pending = deferred(); + mockFetch({ [apiUrls.item(42)]: () => pending.promise }); + const { container } = renderItem(); + expect(screen.getByRole('status')).toHaveTextContent('Loading...'); + pending.resolve(story()); + expect(await screen.findAllByText('A great story')).toHaveLength(2); + expect(screen.queryByRole('status')).toBeNull(); + expect(container.querySelector('.main-content.item-details > .item')).toBeInTheDocument(); + }); + + it('scrolls to the top on mount, also while loading', () => { + mockFetch({ [apiUrls.item(42)]: () => deferred().promise }); + renderItem(); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('renders title, domain, points, user, time and the comment label like Angular', async () => { + const { container } = (mockFetch({ [apiUrls.item(42)]: story({ comments_count: 7 }) }), renderItem()); + await screen.findAllByText('A great story'); + const laptop = container.querySelector('.laptop') as HTMLElement; + expect(laptop).toHaveClass('item-header'); + const title = within(laptop).getByRole('link', { name: 'A great story' }); + expect(title).toHaveAttribute('href', 'https://example.com/post'); + expect(title).not.toHaveAttribute('target'); + expect(laptop.querySelector('p')!.textContent).toBe('A great story (example.com)'); + const subtext = laptop.querySelector('.subtext') as HTMLElement; + expect(subtext.textContent).toBe('128 points by pg 3 hours ago | 7 comments'); + expect(within(subtext).getByRole('link', { name: 'pg' })).toHaveAttribute('href', '/user/pg'); + expect(within(subtext).getByRole('link', { name: '7 comments' })).toHaveAttribute('href', '/item/42'); + expect(subtext.querySelector('.item-details')).toHaveTextContent('3 hours ago'); + expect(container.querySelector('.mobile.item-header .title-block .title')).toHaveTextContent('A great story'); + expect(container.querySelectorAll('ul.comment-list > li .app-comment')).toHaveLength(1); + }); + + it.each([ + [0, 'discuss'], + [1, '1 comment'], + ])('labels %i comments as "%s" (Angular comment pipe)', async (count, label) => { + mockFetch({ [apiUrls.item(42)]: story({ comments_count: count, comments: [] }) }); + const { container } = renderItem(); + await screen.findAllByText('A great story'); + expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); + expect(container.querySelector('.laptop')!.classList.contains('item-header')).toBe(count > 0); + }); + + it('opens external title links in a new tab when openLinkInNewTab is set', async () => { + localStorage.setItem('openLinkInNewTab', 'true'); + mockFetch({ [apiUrls.item(42)]: story() }); + renderItem(); + const titles = await screen.findAllByRole('link', { name: 'A great story' }); + for (const title of titles) { + expect(title).toHaveAttribute('target', '_blank'); + expect(title).toHaveAttribute('rel', 'noopener noreferrer'); + } + }); + + it('renders Ask HN posts with an internal title link and sanitized content', async () => { + mockFetch({ + [apiUrls.item(42)]: story({ + title: 'Ask HN: Anything?', + url: 'item?id=42', + domain: undefined, + content: + '

Question see

', + }), + }); + const { container } = renderItem(); + const titles = await screen.findAllByRole('link', { name: 'Ask HN: Anything?' }); + for (const title of titles) { + expect(title).toHaveAttribute('href', '/item/42'); + expect(title).toHaveClass('title', 'active'); + } + expect(container.querySelector('.domain')).toBeNull(); + const subject = container.querySelector('p.subject')!; + expect(subject).toHaveTextContent('Question see'); + expect(subject.querySelector('script, img, [onclick], [onerror]')).toBeNull(); + expect(subject.querySelector('a')).toHaveAttribute('href', 'https://news.ycombinator.com/item?id=7'); + }); + + it('renders jobs without points, user or comments link', async () => { + mockFetch({ + [apiUrls.item(42)]: story({ type: 'job', points: null as never, user: null as never, comments_count: 0 }), + }); + const { container } = renderItem(); + await screen.findAllByText('A great story'); + const laptop = container.querySelector('.laptop')!; + expect(laptop).toHaveClass('item-header'); + expect(laptop.querySelector('.subtext')!.textContent).toBe(' 3 hours ago'); + expect(laptop.querySelector('.item-details')).toBeNull(); + expect(screen.queryByText(/points by/)).toBeNull(); + expect(screen.queryByRole('link', { name: 'discuss' })).toBeNull(); + }); + + it('renders poll options with points and proportional vote bars', async () => { + mockFetch({ + [apiUrls.item(42)]: story({ + type: 'poll', + url: 'item?id=42', + poll: [ + { item: 'A', points: 1 }, + { item: 'B', points: 1 }, + ] as never, + }), + [apiUrls.item(43)]: { id: 43, type: 'pollopt', content: 'Yes', points: 30 }, + [apiUrls.item(44)]: { id: 44, type: 'pollopt', content: 'No', points: 10 }, + }); + const { container } = renderItem(); + await screen.findAllByText('A great story'); + const options = container.querySelectorAll('.pollResults > .pollContent'); + expect(options).toHaveLength(2); + expect(options[0].querySelector('b')).toHaveTextContent('Yes'); + expect(options[0].querySelector('.subtext')).toHaveTextContent('30 points'); + expect((options[0].querySelector('.pollBar') as HTMLElement).style.width).toBe('75%'); + expect((options[1].querySelector('.pollBar') as HTMLElement).style.width).toBe('25%'); + expect(options[1].querySelector('script')).toBeNull(); + }); + + it('guards the vote bars against a poll with no votes', async () => { + mockFetch({ + [apiUrls.item(42)]: story({ type: 'poll', poll: [{ item: 'A', points: 0 }] as never }), + [apiUrls.item(43)]: { id: 43, type: 'pollopt', content: 'Only', points: 0 }, + }); + const { container } = renderItem(); + await screen.findAllByText('A great story'); + expect((container.querySelector('.pollBar') as HTMLElement).style.width).toBe('0%'); + }); + + it.each([ + ['an unknown item (node-hnapi 200 + error body)', 999, { error: 'Item 999 not found' }], + ['a server error', 42, textResponse('boom', 404)], + ])('shows Angular’s error for %s', async (_name, id, response) => { + mockFetch({ [apiUrls.item(id)]: response }); + renderItem(id); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load item comments.'); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it.each(['abc', '0', '-5', '1.5'])('shows the error for the invalid id /item/%s without a request', async (id) => { + const handle = mockFetch(); + renderItem(id); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load item comments.'); + expect(handle.calls).toHaveLength(0); + }); + + it('goes back with the back button (click, Enter and Space)', async () => { + mockFetch({ [apiUrls.item(42)]: story() }); + function Start() { + const navigate = useNavigate(); + return ( + + ); + } + const { user } = renderWithProviders(, { + route: '/start', + path: '/start', + otherRoutes: { '/item/:id': }, + }); + const openItem = async () => { + await user.click(screen.getByRole('button', { name: 'open' })); + return screen.findByRole('button', { name: 'Back' }); + }; + + await user.click(await openItem()); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()); + + const back = await openItem(); + expect(back).toHaveClass('back-button'); + expect(back).toHaveAttribute('tabindex', '0'); + back.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()); + + (await openItem()).focus(); + await user.keyboard(' '); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()); + }); + + it('links to the poster’s profile', async () => { + mockFetch({ [apiUrls.item(42)]: story() }); + const { container, user } = renderItem(42, { '/user/:id':

user page

}); + await screen.findAllByText('A great story'); + await user.click(within(container.querySelector('.subtext') as HTMLElement).getByRole('link', { name: 'pg' })); + expect(screen.getByText('user page')).toBeInTheDocument(); }); }); diff --git a/react-app/src/features/item-details/ItemDetails.tsx b/react-app/src/features/item-details/ItemDetails.tsx index 6caca4249..340564464 100644 --- a/react-app/src/features/item-details/ItemDetails.tsx +++ b/react-app/src/features/item-details/ItemDetails.tsx @@ -1,11 +1,126 @@ -import { useParams } from 'react-router'; +import { NavLink, useNavigate, useParams } from 'react-router'; -/** Placeholder — Item-details child ports Angular `item-details`. Reads `:id`. */ +import { useItem } from '../../shared/api'; +import { ErrorMessage, Loader } from '../../shared/components'; +import type { Story } from '../../shared/models'; +import { useSettings } from '../../shared/settings'; +import { commentLabel, sanitizeHtml, useScrollToTop } from '../../shared/utils'; +import { Comment } from './Comment'; +import { onButtonKeyDown } from './buttonKeys'; +import './ItemDetails.scss'; + +const ITEM_ERROR_MESSAGE = 'Could not load item comments.'; + +/** Angular `hasUrl`: external stories have an absolute `http(s)` url; Ask HN/text posts have `item?id=…`. */ +function hasUrl(item: Story): boolean { + return typeof item.url === 'string' && item.url.indexOf('http') === 0; +} + +/** Poll bar width in %; `poll_votes_count` can be 0 (Angular divided unguarded). */ +function pollBarWidth(points: number, total: number | undefined): string { + return total && total > 0 ? `${(points / total) * 100}%` : '0%'; +} + +/** Port of Angular `app-item-details` (`/item/:id`). */ export function ItemDetails() { const { id } = useParams<{ id: string }>(); + useScrollToTop(id); + const { data: item, isPending, isError } = useItem(id); + + return ( +
+ {isPending ? : null} + {isError ? : null} + {item ? : null} +
+ ); +} + +function ItemView({ item }: { item: Story }) { + const navigate = useNavigate(); + const { settings } = useSettings(); + const external = hasUrl(item); + const isJob = item.type === 'job'; + const goBack = () => void navigate(-1); + const newTab = settings.openLinkInNewTab ? ({ target: '_blank', rel: 'noopener noreferrer' } as const) : {}; + const titleLink = external ? ( + + {item.title} + + ) : ( + + {item.title} + + ); + return ( -
- ItemDetails placeholder: item {id} +
+
+

+ + {titleLink} +

+
+
0 || isJob ? 'laptop item-header' : 'laptop'}> +

+ {titleLink} + {external && item.domain ? ( + <> + {' '} + ({item.domain}) + + ) : null} +

+
+ {!isJob ? ( + + {item.points} points by {item.user} + + ) : null} + + {' '} + {item.time_ago} + {!isJob ? ( + <> + {' '} + + {'| '} + {commentLabel(item.comments_count)} + + + ) : null} + +
+
+ {item.type === 'poll' ? ( +
+ {(item.poll ?? []).map((pollResult, index) => ( +
+
+
{pollResult.points} points
+
+
+ ))} +
+ ) : null} +

+
    + {(item.comments ?? []).map((comment) => ( +
  • + +
  • + ))} +
); } diff --git a/react-app/src/features/item-details/buttonKeys.ts b/react-app/src/features/item-details/buttonKeys.ts new file mode 100644 index 000000000..bde8d2e96 --- /dev/null +++ b/react-app/src/features/item-details/buttonKeys.ts @@ -0,0 +1,11 @@ +import type { KeyboardEvent } from 'react'; + +/** Enter/Space activation for clickable non-button elements that keep their Angular markup (`role="button"`). */ +export function onButtonKeyDown(action: () => void) { + return (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + action(); + } + }; +} diff --git a/react-app/src/features/user/UserProfile.scss b/react-app/src/features/user/UserProfile.scss new file mode 100644 index 000000000..c3c9149e4 --- /dev/null +++ b/react-app/src/features/user/UserProfile.scss @@ -0,0 +1,101 @@ +// Port of src/app/user/user.component.scss, scoped under `.user-profile` (Angular's `` host). +// `:where()` keeps Angular's specificity so `_themes.scss` rules still win. +@use '../../shared/scss/media' as *; + +:where(.user-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; + } +} + +// Angular's encapsulated page had browser defaults here; pin them against leaked rules from other components +// (e.g. a header `.name { display: none }` or a feed `p { margin: 2px 0 }`). +.user-profile .main-details .name { + display: inline; +} +.user-profile :is(.main-details > .age, .other-details p) { + margin: 1em 0; +} diff --git a/react-app/src/features/user/UserProfile.test.tsx b/react-app/src/features/user/UserProfile.test.tsx index 1dc06ed87..a6d559e1b 100644 --- a/react-app/src/features/user/UserProfile.test.tsx +++ b/react-app/src/features/user/UserProfile.test.tsx @@ -1,13 +1,117 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import { useNavigate } from 'react-router'; import { describe, expect, it } from 'vitest'; -import { renderWithProviders } from '../../shared/test-utils'; +import { apiUrls } from '../../shared/api'; +import type { User } from '../../shared/models'; +import { deferred, mockFetch, renderWithProviders, textResponse } from '../../shared/test-utils'; import { UserProfile } from './UserProfile'; -// Placeholder test — owned by the User child; replace with real UserProfile tests. -describe('UserProfile (placeholder)', () => { - it('reads :id', () => { - renderWithProviders(, { route: '/user/pg', path: '/user/:id' }); - expect(screen.getByTestId('user-profile')).toHaveTextContent('user pg'); +function hnUser(overrides: Partial = {}): User { + return { + id: 'pg', + created_time: 1160418092, + created: '18 years ago', + karma: 157316, + about: 'Bug fixer.', + ...overrides, + }; +} + +function renderUser(id = 'pg') { + return renderWithProviders(, { route: `/user/${id}`, path: '/user/:id' }); +} + +describe('UserProfile', () => { + it('shows the loader while loading, then the profile', async () => { + const pending = deferred(); + mockFetch({ [apiUrls.user('pg')]: () => pending.promise }); + const { container } = renderUser(); + expect(screen.getByRole('status')).toHaveTextContent('Loading...'); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + pending.resolve(hnUser()); + expect(await screen.findByText('Profile: pg')).toBeInTheDocument(); + expect(screen.queryByRole('status')).toBeNull(); + expect(container.querySelector('.user-profile > .profile')).toBeInTheDocument(); + }); + + it('renders id, karma, created and about like Angular', async () => { + const { container } = (mockFetch({ [apiUrls.user('pg')]: hnUser() }), renderUser()); + await screen.findByText('Profile: pg'); + expect(container.querySelector('.main-details .name')).toHaveTextContent('pg'); + expect(container.querySelector('.main-details .right')!.textContent).toBe('157316 ★'); + expect(container.querySelector('.main-details p.age')!.textContent).toBe('Created 18 years ago'); + expect(container.querySelector('.other-details > p')).toHaveTextContent('Bug fixer.'); + expect(container.querySelector('.mobile.item-header .title-block')!.textContent).toBe('Profile: pg'); + }); + + it('sanitizes about HTML and makes HN-relative links absolute', async () => { + mockFetch({ + [apiUrls.user('pg')]: hnUser({ + about: 'See this and that

more

', + }), + }); + const { container } = renderUser(); + await screen.findByText('Profile: pg'); + const about = container.querySelector('.other-details')!; + expect(about.querySelector('script, [onclick], [target]')).toBeNull(); + const [hnLink, jsLink] = Array.from(about.querySelectorAll('a')); + expect(hnLink).toHaveAttribute('href', 'https://news.ycombinator.com/item?id=1'); + expect(hnLink).toHaveAttribute('rel', 'nofollow noopener noreferrer'); + expect(jsLink).not.toHaveAttribute('href'); + expect(about.querySelector('i')).toHaveTextContent('more'); + }); + + it('omits the about block when the user has none', async () => { + mockFetch({ [apiUrls.user('test')]: hnUser({ id: 'test', about: '' }) }); + const { container } = renderUser('test'); + await screen.findByText('Profile: test'); + expect(container.querySelector('.other-details')).toBeNull(); + }); + + it.each([ + ['200 + null', textResponse('null')], + ['200 + plain text', textResponse("Cannot read property 'apply' of undefined")], + ['404', textResponse('Not found', 404)], + ])('shows Angular’s error for an unknown user (HNPWA %s), never an endless loader', async (_name, response) => { + mockFetch({ [apiUrls.user('nobody_xyz')]: response }); + renderUser('nobody_xyz'); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load user nobody_xyz.'); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('goes back with the back button (click, Enter and Space)', async () => { + mockFetch({ [apiUrls.user('pg')]: hnUser() }); + function Start() { + const navigate = useNavigate(); + return ( + + ); + } + const { user } = renderWithProviders(, { + route: '/start', + path: '/start', + otherRoutes: { '/user/:id': }, + }); + const openProfile = async () => { + await user.click(screen.getByRole('button', { name: 'open' })); + return screen.findByRole('button', { name: 'Back' }); + }; + + const back = await openProfile(); + expect(back).toHaveClass('back-button'); + expect(back).toHaveAttribute('tabindex', '0'); + await user.click(back); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()); + + (await openProfile()).focus(); + await user.keyboard('{Enter}'); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()); + + (await openProfile()).focus(); + await user.keyboard(' '); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()); }); }); diff --git a/react-app/src/features/user/UserProfile.tsx b/react-app/src/features/user/UserProfile.tsx index 6611e98ac..df2c5a31e 100644 --- a/react-app/src/features/user/UserProfile.tsx +++ b/react-app/src/features/user/UserProfile.tsx @@ -1,11 +1,57 @@ -import { useParams } from 'react-router'; +import type { KeyboardEvent } from 'react'; +import { useNavigate, useParams } from 'react-router'; -/** Placeholder — User child ports Angular `user`. Reads `:id`. */ +import { useUser } from '../../shared/api'; +import { ErrorMessage, Loader } from '../../shared/components'; +import { sanitizeHtml, useScrollToTop } from '../../shared/utils'; +import './UserProfile.scss'; + +/** Port of Angular `app-user` (`/user/:id`). */ export function UserProfile() { const { id } = useParams<{ id: string }>(); + useScrollToTop(id); + const navigate = useNavigate(); + const { data: user, isPending, isError } = useUser(id); + + const goBack = () => void navigate(-1); + const onBackKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + goBack(); + } + }; + return ( -
- UserProfile placeholder: user {id} +
+ {isPending ? : null} + {isError ? : null} + {user ? ( +
+
+

+ + Profile: {user.id} +

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

Created {user.created}

+
+ {user.about ? ( +
+

+
+ ) : null} +
+ ) : null}
); }