Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions react-app/src/features/item-details/Comment.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Port of src/app/item-details/comment/comment.component.scss, scoped under `.app-comment` (Angular's
// `<app-comment>` 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;
}
}
131 changes: 125 additions & 6 deletions react-app/src/features/item-details/Comment.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Comment comment={{ id: 1, user: 'pg', comments: [] } as unknown as CommentModel} />);
expect(screen.getByTestId('comment')).toHaveTextContent('pg');
function comment(overrides: Partial<CommentModel> = {}): CommentModel {
return {
id: 1,
level: 0,
user: 'pg',
time: 0,
time_ago: '2 hours ago',
content: '<p>Top level</p>',
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(
<Comment comment={comment({ content: 'Hi<script>alert(1)</script><p>there <a href="item?id=5">x</a>' })} />
);
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(<Comment comment={tree} />);
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(<Comment comment={tree} />);
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(<Comment comment={tree} />);
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(<Comment comment={tree} />);
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(
<Comment comment={comment({ deleted: true, user: '', content: '', comments: [comment({ id: 9 })] })} />
);
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(
<Comment comment={comment({ dead: true, content: '[flagged]', comments: undefined as never })} />
);
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(<Comment comment={comment()} />, {
otherRoutes: { '/user/:id': <p>user page</p> },
});
await user.click(screen.getByRole('link', { name: 'pg' }));
expect(screen.getByText('user page')).toBeInTheDocument();
});
});
53 changes: 51 additions & 2 deletions react-app/src/features/item-details/Comment.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid="comment">Comment placeholder: {comment.user}</div>;
const [collapse, setCollapse] = useState(false);
const toggle = () => setCollapse((value) => !value);

if (comment.deleted) {
return (
<div className="app-comment">
<div className="deleted-meta">
<span className="collapse">[deleted]</span> | Comment Deleted
</div>
</div>
);
}

return (
<div className="app-comment">
<div className={collapse ? 'meta meta-collapse' : 'meta'}>
<span
className="collapse"
role="button"
tabIndex={0}
aria-expanded={!collapse}
aria-label={`${collapse ? 'Expand' : 'Collapse'} comment by ${comment.user}`}
onClick={toggle}
onKeyDown={onButtonKeyDown(toggle)}
>
[{collapse ? '+' : '-'}]
</span>
<NavLink to={`/user/${comment.user}`}>{comment.user}</NavLink>
<span className="time">{comment.time_ago}</span>
</div>
<div className="comment-tree">
<div hidden={collapse}>
<p className="comment-text" dangerouslySetInnerHTML={{ __html: sanitizeHtml(comment.content) }}></p>
<ul className="subtree">
{(comment.comments ?? []).map((subComment) => (
<li key={subComment.id}>
<Comment comment={subComment} />
</li>
))}
</ul>
</div>
</div>
</div>
);
}
Loading
Loading