Multiple Comments

Our amazing blog posts will obviously garner a huge and passionate fanbase and we will very rarely have only a single comment. Let's work on displaying a list of comments.

Let's think about where our comments are being displayed. Probably not on the homepage, since that only shows a summary of each post. A user would need to go to the full page to show the comments for that blog post. But that page is only fetching the data for the single blog post itself, nothing else. We'll need to get the comments and since we'll be fetching and displaying them, that sounds like a job for a Cell.

Couldn't the query for the blog post page also fetch the comments?

Yes, it could! But the idea behind Cells is to make components even more composable by having them be responsible for their own data fetching and display. If we rely on a blog post to fetch the comments then the new Comments component we're about to create now requires something else to fetch the comments and pass them in. If we re-use the Comments component somewhere, now we're fetching comments in two different places.

But what about the Comment component we just made, why doesn't that fetch its own data?

There aren't any instances I (the author) could think of where we would ever want to display only a single comment in isolation—it would always be a list of all comments on a post. If displaying a single comment was common for your use case then it could definitely be converted to a CommentCell and have it responsible for pulling the data for that single comment itself. But keep in mind that if you have 50 comments on a blog post, that's now 50 GraphQL calls that need to go out, one for each comment. There's always a trade-off!

Then why make a standalone Comment component at all? Why not just do all the display in the CommentsCell?

We're trying to start in small chunks to make the tutorial more digestible for a new audience so we're starting simple and getting more complex as we go. But it also just feels nice to build up a UI from these smaller chunks that are easier to reason about and keep separate in your head.

But what about—

Look, we gotta end this sidebar and get back to building this thing. You can ask more questions later, promise!

Storybook#

Let's generate a CommentsCell:

yarn rw g cell Comments

Storybook updates with a new CommentsCell under the Cells folder. Let's update the Success story to use the Comment component created earlier, and return all of the fields we'll need for the Comment to render:

// web/src/components/CommentsCell/CommentsCell.js
import Comment from 'src/components/Comment'
export const QUERY = gql`
query CommentsQuery {
comments {
id
name
body
createdAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ comments }) => {
return comments.map((comment) => (
<Comment key={comment.id} comment={comment} />
))
}

We're passing an additional key prop to make React happy when iterating over an array with map.

If you check Storybook, you'll seen an error. We'll need to update the mock.js file that came along for the ride when we generated the Cell so that it returns an array instead of just a simple object with some sample data:

// web/src/components/CommentsCell/CommentsCell.mock.js
export const standard = (/* vars, { ctx, req } */) => ({
comments: [
{
id: 1,
name: 'Rob Cameron',
body: 'First comment',
createdAt: '2020-01-02T12:34:56Z',
},
{
id: 2,
name: 'David Price',
body: 'Second comment',
createdAt: '2020-02-03T23:00:00Z',
},
],
})

What's this standard thing? Think of it as the standard, default mock if you don't do anything else. We would have loved to use the name "default" but that's already a reserved word in Javascript!

Storybook refreshes and we've got comments! We've got the same issue here where it's hard to see our rounded corners and also the two separate comments are hard to distinguish because they're right next to each other:

image

The gap between the two comments is a concern for this component, since it's responsible for drawing multiple comments and their layout. So let's fix that in CommentsCell:

// web/src/components/CommentsCell/CommentsCell.js
export const Success = ({ comments }) => {
return (
<div className="-mt-8">
{comments.map((comment) => (
<div key={comment.id} className="mt-8">
<Comment comment={comment} />
</div>
))}
</div> )
}

We had to move the key prop to the surrounding <div>. We then gave each comment a top margin and removed an equal top margin from the entire container to set it back to zero.

Why a top margin and not a bottom margin? Remember when we said a component should be responsible for its own display? If you add a bottom margin, that's one component influencing the one below it (which it shouldn't care about). Adding a top margin is this component moving itself down, which means it's again responsible for its own display.

Let's add a margin around the story itself, similar to what we did in the Comment story:

// web/src/components/CommentsCell/CommentsCell.stories.js
export const success = () => {
return Success ? (
<div className="m-8 mt-16">
<Success {...standard()} />
</div> ) : null
}

Why both m-8 and mt-16? One of the fun rules of CSS is that if a parent and child both have margins, but no border or padding between them, their margin-top and margin-bottom collapses. So even though the story container will have a margin of 8 (which equals 2rem) remember that the container for CommentsCell has a -8 margin (-2rem). Those two collapse and essentially cancel each other out to 0 top margin. Setting mt-16 sets a 4rem margin, which after subtracting 2rem leaves us with 2rem, which is what we wanted to start with!

image

Looking good! Let's add our CommentsCell to the actual blog post display page:

// web/src/components/BlogPost/BlogPost.js
import { Link, routes } from '@redwoodjs/router'
import CommentsCell from 'src/components/CommentsCell'
const truncate = (text, length) => {
return text.substring(0, length) + '...'
}
const BlogPost = ({ post, summary = false }) => {
return (
<article className="mt-10">
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.blogPost({ id: post.id })}>{post.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(post.body, 100) : post.body}
</div>
{!summary && <CommentsCell />}
</article>
)
}
export default BlogPost

If we are not showing the summary, then we'll show the comments. Take a look at the Full and Summary stories and you should see comments on one and not on the other.

Shouldn't the CommentsCell cause an actual GraphQL request? How does this work?

Redwood has added some functionality around Storybook so if you're testing a component that itself isn't a Cell (like BlogPost) but that renders a cell (CommentsCell) that it needs to mock the GraphQL and use the standard mock that goes along with that Cell. Pretty cool, huh?

Once again our component is bumping right up against the edges of the window. We've got two stories in this file and would have to manually add margins around both of them. Ugh. Luckily Storybook has a way to add styling to all stories using decorators. In the default export at the bottom of the story you can define a decorators key and the value is JSX that will wrap all the stories in the file automatically:

// web/src/components/BlogPost/BlogPost.stories.js
export default {
title: 'Components/BlogPost',
decorators: [
(Story) => <div className="m-8"><Story /></div>
]
}

Save, and both the Full and Summary stories should have margins around them now.

For more extensive, global styling options, look into Storybook theming.

image

We could use a gap between the end of the blog post and the start of the comments to help separate the two:

// web/src/components/BlogPost/BlogPost.js
const BlogPost = ({ post, summary = false }) => {
return (
<article className="mt-10">
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.blogPost({ id: post.id })}>{post.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(post.body, 100) : post.body}
</div>
{!summary && (
<div className="mt-24">
<CommentsCell />
</div>
)}
</article>
)
}

image

Okay, comment display is looking good! However, you may have noticed that if you tried going to the actual site there's an error where the comments should be:

image

Why is that? Remember that we started with the CommentsCell, but never actually created a Comment model in schema.prisma or created an SDL and service! That's another neat part of working with Storybook: you can build out UI functionality completely isolated from the api-side. In a team setting this is great because a web-side team can work on the UI while the api-side team can be building the backend end simultaneously and one doesn't have to wait for the other.

Testing#

We added one component, Comments, and edited another, BlogPost, so we'll want to add tests in both.

Testing Comments#

The actual Comment component does most of the work so there's no need to test all of that functionality again. What things does CommentsCell do that make it unique?

  • Has a loading message
  • Has an error message
  • Has a failure message
  • When it renders successfully, it outputs as many comments as were returned by the QUERY

The default CommentsCell.test.js actually tests every state for us, albeit at an absolute minimum level—it make sure no errors are thrown:

import { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './CommentsCell'
import { standard } from './CommentsCell.mock'
describe('CommentsCell', () => {
test('Loading renders successfully', () => {
expect(() => {
render(<Loading />)
}).not.toThrow()
})
test('Empty renders successfully', async () => {
expect(() => {
render(<Empty />)
}).not.toThrow()
})
test('Failure renders successfully', async () => {
expect(() => {
render(<Failure error={new Error('Oh no')} />)
}).not.toThrow()
})
test('Success renders successfully', async () => {
expect(() => {
render(<Success comments={standard().comments} />)
}).not.toThrow()
})
})

And that's nothing to scoff at! As you've probably experienced, a React component usually either works 100% or throws an error. If it works, great! If it fails then the test fails too, which is exactly what we want to happen.

But in this case we can do a little more to make sure CommentsCell is doing what we expect. Let's update the Success test in CommentsCell.test.js to check that exactly the number of comments we passed in as a prop are rendered. How do we know a comment was rendered? How about if we check that each comment.body (the most important part of the comment) is present on the screen:

// web/src/components/CommentsCell/CommentsCell.test.js
test('Success renders successfully', async () => {
const comments = standard().comments
render(<Success comments={comments} />)
comments.forEach((comment) => {
expect(screen.getByText(comment.body)).toBeInTheDocument()
})
})

We're looping through each comment from the mock, the same mock used by Storybook, so that even if we add more later, we're covered.

Testing BlogPost#

The functionality we added to <BlogPost> says to show the comments for the post if we are not showing the summary. We've got a test for both the "full" and "summary" renders already. Generally you want your tests to be testing "one thing" so let's add two additional tests for our new functionality:

// web/src/components/BlogPost/BlogPost.test.js
import { render, screen, waitFor } from '@redwoodjs/testing'
import BlogPost from './BlogPost'
import { standard } from 'src/components/CommentsCell/CommentsCell.mock'
const POST = {
id: 1,
title: 'First post',
body: `Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Street art next level umami squid. Hammock hexagon glossier 8-bit banjo. Neutra la croix mixtape echo park four loko semiotics kitsch forage chambray. Semiotics salvia selfies jianbing hella shaman. Letterpress helvetica vaporware cronut, shaman butcher YOLO poke fixie hoodie gentrify woke heirloom.`,
createdAt: new Date().toISOString(),
}
describe('BlogPost', () => {
it('renders a blog post', () => {
render(<BlogPost post={POST} />)
expect(screen.getByText(POST.title)).toBeInTheDocument()
expect(screen.getByText(POST.body)).toBeInTheDocument()
})
it('renders comments when displaying a full blog post', async () => {
const comment = standard().comments[0]
render(<BlogPost post={POST} />)
await waitFor(() =>
expect(screen.getByText(comment.body)).toBeInTheDocument()
)
})
it('renders a summary of a blog post', () => {
render(<BlogPost post={POST} summary={true} />)
expect(screen.getByText(POST.title)).toBeInTheDocument()
expect(
screen.getByText(
'Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Str...'
)
).toBeInTheDocument()
})
it('does not render comments when displaying a summary', async () => {
const comment = standard().comments[0]
render(<BlogPost post={POST} summary={true} />)
await waitFor(() =>
expect(screen.queryByText(comment.body)).not.toBeInTheDocument()
)
})
})

We're introducing a new test function here, waitFor(), which will wait for things like GraphQL queries to finish running before checking for what's been rendered. Since BlogPost renders CommentsCell we need to wait for the Success component of CommentsCell to be rendered.

The summary version of BlogPost does not render the CommentsCell, but we should still wait. Why? If we did mistakenly start including CommentsCell, but didn't wait for the render, we would get a falsely passing test—indeed the text isn't on the page but that's because it's still showing the Loading component! If we had waited we would have seen the actual comment body get rendered, and the test would (correctly) fail.

Okay we're finally ready to let users create their comments.