Skip to the code
cf.

Nested comment thread

Under each tea, customers leave comments, and reply to each other's. Build CommentThread, a named export of App.jsx, which takes comments: an array of { id, author, text, replies }, where replies is an array of comments of the same shape, as deep as people like. replies may be missing, which means none. The default export, App, is only for the Preview.

Draw the comments in a <ul>, one <li> per comment, in order. Each <li> shows the author in a <strong> and the text in a <p>. A comment's replies go in another <ul> inside its own <li>, so every level of replies sits one list deeper.

A comment with replies also has a button that hides and shows them. It counts every reply under the comment, at every depth, and reads Hide 3 replies while they're shown, or Show 3 replies while they're hidden (1 reply for one). Its aria-expanded is true while they're shown. Replies start shown, and hidden replies aren't on the page. A comment with no replies has no button.

Each comment's button works on its own: hiding one thread leaves the others as they are. When comments is empty, show the paragraph No comments yet. instead of the list.

<CommentThread
  comments={[
    { id: 1, author: "Asha", text: "Love this sencha.", replies: [
      { id: 2, author: "Ben", text: "Try it iced.", replies: [] },
    ] },
  ]}
/>
 
// draws:
<ul>
  <li>
    <strong>Asha</strong>
    <p>Love this sencha.</p>
    <button aria-expanded="true">Hide 1 reply</button>
    <ul>
      <li><strong>Ben</strong><p>Try it iced.</p></li>
    </ul>
  </li>
</ul>

What the tests check

  1. shows every comment at every depth
    const comments = [
      { id: 1, author: "Asha", text: "Love this sencha.", replies: [
        { id: 2, author: "Ben", text: "Try it iced.", replies: [{ id: 3, author: "Chen", text: "Will do.", replies: [] }] },
      ] },
      { id: 7, author: "Gus", text: "Great shop." },
    ];
    render(<CommentThread comments={comments} />);
    for (const text of ["Love this sencha.", "Try it iced.", "Will do.", "Great shop."]) {
      expect(screen.getByText(text)).toBeInTheDocument();
    }
    for (const author of ["Asha", "Ben", "Chen", "Gus"]) {
      expect(screen.getByText(author).tagName).toBe("STRONG");
    }
    
  2. puts replies in a list inside their parent's item
    const comments = [
      { id: 1, author: "Asha", text: "Love this sencha.", replies: [
        { id: 2, author: "Ben", text: "Try it iced.", replies: [{ id: 3, author: "Chen", text: "Will do.", replies: [] }] },
      ] },
    ];
    render(<CommentThread comments={comments} />);
    const item = (text) => screen.getByText(text).closest("li");
    expect(item("Try it iced.").parentElement.tagName).toBe("UL");
    expect(item("Try it iced.").parentElement.closest("li")).toBe(item("Love this sencha."));
    expect(item("Will do.").parentElement.closest("li")).toBe(item("Try it iced."));
    expect(item("Love this sencha.").parentElement.closest("li")).toBeNull();
    
  3. counts every reply under a comment, at every depth
    const comments = [
      { id: 1, author: "Asha", text: "Love this sencha.", replies: [
        { id: 2, author: "Ben", text: "Try it iced.", replies: [{ id: 3, author: "Chen", text: "Will do.", replies: [] }] },
        { id: 4, author: "Dara", text: "Same here.", replies: [] },
      ] },
      { id: 5, author: "Eli", text: "Is the oolong back?", replies: [{ id: 6, author: "Fen", text: "Next week.", replies: [] }] },
    ];
    render(<CommentThread comments={comments} />);
    const item = (text) => screen.getByText(text).closest("li");
    expect(within(item("Love this sencha.")).getByRole("button", { name: "Hide 3 replies" })).toBeInTheDocument();
    expect(within(item("Try it iced.")).getByRole("button", { name: "Hide 1 reply" })).toBeInTheDocument();
    expect(within(item("Is the oolong back?")).getByRole("button", { name: "Hide 1 reply" })).toBeInTheDocument();
    
  4. has no button on a comment without replies
    const comments = [
      { id: 1, author: "Asha", text: "Love this sencha.", replies: [{ id: 4, author: "Dara", text: "Same here.", replies: [] }] },
      { id: 7, author: "Gus", text: "Great shop." },
    ];
    render(<CommentThread comments={comments} />);
    const item = (text) => screen.getByText(text).closest("li");
    expect(within(item("Same here.")).queryByRole("button")).not.toBeInTheDocument();
    expect(within(item("Great shop.")).queryByRole("button")).not.toBeInTheDocument();
    
  5. hiding a comment's replies removes all of them and flips the button
    const comments = [
      { id: 1, author: "Asha", text: "Love this sencha.", replies: [
        { id: 2, author: "Ben", text: "Try it iced.", replies: [{ id: 3, author: "Chen", text: "Will do.", replies: [] }] },
        { id: 4, author: "Dara", text: "Same here.", replies: [] },
      ] },
    ];
    render(<CommentThread comments={comments} />);
    const button = screen.getByRole("button", { name: "Hide 3 replies" });
    expect(button).toHaveAttribute("aria-expanded", "true");
    await user.click(button);
    expect(screen.queryByText("Try it iced.")).not.toBeInTheDocument();
    expect(screen.queryByText("Will do.")).not.toBeInTheDocument();
    expect(screen.queryByText("Same here.")).not.toBeInTheDocument();
    expect(screen.getByText("Love this sencha.")).toBeInTheDocument();
    const shown = screen.getByRole("button", { name: "Show 3 replies" });
    expect(shown).toHaveAttribute("aria-expanded", "false");
    
  6. Show brings the replies back
    const comments = [
      { id: 5, author: "Eli", text: "Is the oolong back?", replies: [{ id: 6, author: "Fen", text: "Next week.", replies: [] }] },
    ];
    render(<CommentThread comments={comments} />);
    await user.click(screen.getByRole("button", { name: "Hide 1 reply" }));
    await user.click(screen.getByRole("button", { name: "Show 1 reply" }));
    expect(screen.getByText("Next week.")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Hide 1 reply" })).toHaveAttribute("aria-expanded", "true");
    
  7. hiding one thread leaves the others alone
    const comments = [
      { id: 1, author: "Asha", text: "Love this sencha.", replies: [
        { id: 2, author: "Ben", text: "Try it iced.", replies: [{ id: 3, author: "Chen", text: "Will do.", replies: [] }] },
        { id: 4, author: "Dara", text: "Same here.", replies: [] },
      ] },
      { id: 5, author: "Eli", text: "Is the oolong back?", replies: [{ id: 6, author: "Fen", text: "Next week.", replies: [] }] },
    ];
    render(<CommentThread comments={comments} />);
    const item = (text) => screen.getByText(text).closest("li");
    await user.click(within(item("Is the oolong back?")).getByRole("button", { name: "Hide 1 reply" }));
    expect(screen.queryByText("Next week.")).not.toBeInTheDocument();
    expect(screen.getByText("Try it iced.")).toBeInTheDocument();
    await user.click(within(item("Try it iced.")).getByRole("button", { name: "Hide 1 reply" }));
    expect(screen.queryByText("Will do.")).not.toBeInTheDocument();
    expect(screen.getByText("Same here.")).toBeInTheDocument();
    expect(within(item("Love this sencha.")).getByRole("button", { name: "Hide 3 replies" })).toBeInTheDocument();
    
  8. says so when there are no comments
    render(<CommentThread comments={[]} />);
    expect(screen.getByText("No comments yet.")).toBeInTheDocument();
    expect(screen.queryByRole("list")).not.toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).