Creating a Comment Form

Let's generate a component to house our new comment form, build it out and integrate it via Storybook, then add some tests:

yarn rw g component CommentForm

And startup Storybook again if it isn't still running:

yarn rw storybook

You'll see that there's a CommentForm entry in Storybook now, ready for us to get started.

image

Storybook#

Let's build a simple form to take the user's name and their comment and add some styling to match it to the blog:

// web/src/components/CommentForm/CommentForm.js
import {
Form,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
const CommentForm = () => {
return (
<div>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full">
<Label name="name" className="block text-sm text-gray-600 uppercase">
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-xs "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-sm text-gray-600 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-xs"
validation={{ required: true }}
/>
<Submit
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm

image

Note that the form and its inputs are set to 100% width. Again, the form shouldn't be dictating anything about its layout that its parent should be responsible for, like how wide the inputs are. Those should be determined by whatever contains it so that it looks good with the rest of the content on the page. So the form will be 100% wide and the parent (whoever that ends up being) will decide how wide it really is on the page.

You can even try submitting the form right in Storybook! If you leave "name" or "comment" blank then they should get focus when you try to submit, indicating that they are required. If you fill them both in and click Submit nothing happens because we haven't hooked up the submit yet. Let's do that now.

Submitting#

Submitting the form should use the createComment function we added to our services and GraphQL. We'll need to add a mutation to the form component and an onSubmit hander to the form so that the create can be called with the data in the form. And since createComment could return an error we'll add the FormError component to display it:

// web/src/components/CommentForm/CommentForm.js
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
const CREATE = gql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
id
name
body
createdAt
}
}
`
const CommentForm = () => {
const [createComment, { loading, error }] = useMutation(CREATE)
const onSubmit = (input) => {
createComment({ variables: { input } })
}
return (
<div>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full" onSubmit={onSubmit}>
<FormError
error={error}
titleClassName="font-semibold"
wrapperClassName="bg-red-100 text-red-900 text-sm p-3 rounded"
/>
<Label
name="name"
className="block text-xs font-semibold text-gray-500 uppercase"
>
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-sm "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-xs font-semibold text-gray-500 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-sm"
validation={{ required: true }}
/>
<Submit
disabled={loading}
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm

If you try to submit the form you'll get an error in the web console—Storybook will automatically mock GraphQL queries, but not mutations. But, we can mock the request in the story and handle the response manually:

// web/src/components/CommentForm/CommentForm.stories.js
import CommentForm from './CommentForm'
export const generated = () => {
mockGraphQLMutation('CreateCommentMutation', (variables, { ctx }) => {
const id = parseInt(Math.random() * 1000)
ctx.delay(1000)
return {
createComment: {
id,
name: variables.input.name,
body: variables.input.body,
createdAt: new Date().toISOString(),
},
}
})
return <CommentForm />
}
export default { title: 'Components/CommentForm' }

To use mockGraphQLMutation you call it with the name of the mutation you want to intercept and then the function that will handle the interception and return a response. The arguments passed to that function give us some flexibility in how we handle the response.

In our case we want the variables that were passed to the mutation (the name and body) as well as the context object (abbreviated as ctx) so that we can add a delay to simulate a round trip to the server. This will let us test that the Submit button is disabled for that one second and you can't submit a second comment while the first one is still being saved.

Try out the form now and the error should be gone. Also the Submit button should become visually disabled and clicking it during that one second delay does nothing.

Adding the Form to the Blog Post#

Right above the display of existing comments on a blog post is probably where our form should go. So should we add it to the Article component along with the CommentsCell component? If wherever we display a list of comments we'll also include the form to add a new one, that feels like it may as well just go into the CommentsCell component itself. However, this presents a problem:

If we put the CommentForm in the Success component of CommentsCell then what happens when there are no comments yet? The Empty component renders, which doesn't include the form! So it becomes impossible to add the first comment.

We could copy the CommentForm to the Empty component as well, but as soon as you find yourself duplicating code like this it can be a hint that you need to rethink something about your design.

Maybe CommentsCell should really only be responsible for retrieving and displaying comments. Having it also accept user input seems outside of its primary concern.

So let's use Article as the cleaning house for where all these disparate parts are combined—the actual blog post, the form to add a new comment, and the list of comments (and a little margin between them):

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

image

Looks great in Storybook, how about on the real site?

image

Now comes the ultimate test: creating a comment! LET'S DO IT:

image

What happened here? Notice towards the end of the error message: Field "postId" of required type "Int!" was not provided. When we created our data schema we said that a post belongs to a comment via the postId field. And that field is required, so the GraphQL server is rejecting the request because we're not including that field. We're only sending name and body. Luckily we have access to the ID of the post we're commenting on thanks to the article object that's being passed into Article itself!

Why didn't the Storybook story we wrote earlier expose this problem?

We manually mocked the GraphQL response in the story, and our mock always returns a correct response, regardless of the input!

There's always a tradeoff when creating mock data—it greatly simplifies testing by not having to rely on the entire GraphQL stack, but that means if you want it to be as accurate as the real thing you basically need to re-write the real thing in your mock. In this case, leaving out the postId was a one-time fix so it's probably not worth going through the work of creating a story/mock/test that simulates what would happen if we left it off.

But, if CommentForm ended up being a component that was re-used throughout your application, or the code itself will go through a lot of churn because other developers will constantly be making changes to it, it might be worth investing the time to make sure the interface (the props passed to it and the expected return) are exactly what you want them to be.

First let's pass the post's ID as a prop to CommentForm:

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

And then we'll append that ID to the input object that's being passed to createComment in the CommentForm:

// web/src/components/CommentForm/CommentForm.js
const CommentForm = ({ postId }) => {
const [createComment, { loading, error }] = useMutation(CREATE)
const onSubmit = (input) => {
createComment({ variables: { input: { postId, ...input } } })
}
return (
//...
)
}

Now fill out the comment form and submit! And...nothing happened! Believe it or not that's actually an improvement in the situation—no more error! What if we reload the page?

image

Yay! It would have been nicer if that comment appeared as soon as we submitted the comment, so maybe that's a half-yay? Also, the text boxes stayed filled with our name/messages which isn't ideal. But, we can fix both of those! One involves telling the GraphQL client (Apollo) that we created a new record and, if it would be so kind, to try the query again that gets the comments for this page, and we'll fix the other by just removing the form from the page completely when a new comment is submitted.

GraphQL Query Caching#

Much has been written about the complexities of Apollo caching, but for the sake of brevity (and sanity) we're going to do the easiest thing that works, and that's tell Apollo to just re-run the query that shows comments in the cell, known as "refetching."

Along with the variables you pass to a mutation function (createComment in our case) there's an option named refetchQueries where you pass an array of queries that should be re-run because, presumably, the data you just mutated is reflected in the result of those queries. In our case there's a single query, the QUERY export of CommentsCell. We'll import that at the top of CommentForm (and rename so it's clear what it is to the rest of our code) and then pass it along to the refetchQueries option:

// web/src/components/CommentForm/CommentForm.js
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
// ...
const CommentForm = ({ postId }) => {
const [createComment, { loading, error }] = useMutation(CREATE, {
refetchQueries: [{ query: CommentsQuery }],
})
//...
}

Now when we create a comment it appears right away! It might be hard to tell because it's at the bottom of the comments list (which is a fine position if you want to read comments in chronological order, oldest to newest). Let's pop up a little notification that the comment was successful to let the user know their contribution was successful in case they don't realize it was added to the end of the page.

We'll make use of good old fashioned React state to keep track of whether a comment has been posted in the form yet or not. If so, let's remove the comment form completely and show a "Thanks for your comment" message. Redwood includes react-hot-toast for showing popup notifications, so let's use that to thank the user for their comment. We'll remove the form with just a couple of CSS classes:

// web/src/components/CommentForm/CommentForm.js
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
import { useState } from 'react'
const CREATE = gql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
id
name
body
createdAt
}
}
`
const CommentForm = ({ postId }) => {
const [hasPosted, setHasPosted] = useState(false)
const [createComment, { loading, error }] = useMutation(CREATE, {
onCompleted: () => {
setHasPosted(true)
toast.success('Thank you for your comment!')
},
refetchQueries: [{ query: CommentsQuery }],
})
const onSubmit = (input) => {
createComment({ variables: { input: { postId, ...input } } })
}
return (
<div className={hasPosted ? 'hidden' : ''}>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full" onSubmit={onSubmit}>
<FormError
error={error}
titleClassName="font-semibold"
wrapperClassName="bg-red-100 text-red-900 text-sm p-3 rounded"
/>
<Label
name="name"
className="block text-xs font-semibold text-gray-500 uppercase"
>
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-sm "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-xs font-semibold text-gray-500 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-sm"
validation={{ required: true }}
/>
<Submit
disabled={loading}
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm

image

We used hidden to just hide the form and "Leave a comment" title completely from the page, but keeps the component itself mounted. But where's our "Thank you for your comment" notification? We still need to add the Toaster component (from react-host-toast) somewhere in our app so that the message can actually be displayed. We could just add it here, in CommentForm, but what if we want other code to be able to post notifications, even when CommentForm isn't mounted? Where's the one place we put UI elements that should be visible everywhere? The BlogLayout!

// web/src/layouts/BlogLayout/BlogLayout.js
import { Link, routes } from '@redwoodjs/router'
import { useAuth } from '@redwoodjs/auth'
import { Toaster } from '@redwoodjs/web/toast'
const BlogLayout = ({ children }) => {
const { logOut, isAuthenticated, currentUser } = useAuth()
return (
<>
<Toaster />
<header className="relative flex justify-between items-center py-4 px-8 bg-blue-700 text-white">
<h1 className="text-5xl font-semibold tracking-tight">
<Link
className="text-blue-400 hover:text-blue-100 transition duration-100"
to={routes.home()}
>
Redwood Blog
</Link>
</h1>
<nav>
<ul className="relative flex items-center font-light">
<li>
<Link
className="py-2 px-4 hover:bg-blue-600 transition duration-100 rounded"
to={routes.about()}
>
About
</Link>
</li>
<li>
<Link
className="py-2 px-4 hover:bg-blue-600 transition duration-100 rounded"
to={routes.contact()}
>
Contact
</Link>
</li>
<li>
{isAuthenticated ? (
<div>
<button type="button" onClick={logOut} className="py-2 px-4">
Logout
</button>
</div>
) : (
<Link to={routes.login()} className="py-2 px-4">
Login
</Link>
)}
</li>
</ul>
{isAuthenticated && (
<div className="absolute bottom-1 right-0 mr-12 text-xs text-blue-300">
{currentUser.email}
</div>
)}
</nav>
</header>
<main className="max-w-4xl mx-auto p-12 bg-white shadow rounded-b">
{children}
</main>
</>
)
}
export default BlogLayout

Now add a comment:

image

Almost Done?#

So it looks like we're just about done here! Try going back to the homepage and go to another blog post. Let's bask in the glory of our amazing coding abilities and—OH NO:

image

All posts have the same comments! WHAT HAVE WE DONE??

Remember our foreshadowing callout a few pages back, wondering if our comments() service which only returns all comments could come back to bite us? It finally has: when we get the comments for a post we're not actually getting them for only that post. We're ignoring the postId completely and just returning all comments in the database! Turns out the old axiom is true: computers only do exactly what you tell them to do. :(

Let's fix it!

Returning Only Some Comments#

We'll need to make both frontend and backend changes to get only some comments to show. Let's start with the backend and do a little test-driven development to make this change.

Introducing the Redwood Console#

It would be nice if we could try out sending some arguments to our Prisma calls and be sure that we can request a single post's comments without having to write the whole stack into the app (component/cell, GraphQL, service) just to see if it works.

That's where the Redwood Console comes in! In a new terminal instance, try this:

yarn rw console

You'll see a standard Node console but with most of Redwood's internals already imported and ready to go! Most importantly, that includes the database. Try it out:

> db.comment.findMany()
[
{
id: 1,
name: 'Rob',
body: 'The first real comment!',
postId: 1,
createdAt: 2020-12-08T23:45:10.641Z
},
{
id: 2,
name: 'Tom',
body: 'Here is another comment',
postId: 1,
createdAt: 2020-12-08T23:46:10.641Z
}
]

(Output will be slightly different, of course, depending on what comments you already have in your database.)

Let's try the syntax that will allow us to only get comments for a given postId:

> db.comment.findMany({ where: { postId: 1 }})
[
{
id: 1,
name: 'Rob',
body: 'The first real comment!',
postId: 1,
createdAt: 2020-12-08T23:45:10.641Z
},
{
id: 2,
name: 'Tom',
body: 'Here is another comment',
postId: 1,
createdAt: 2020-12-08T23:46:10.641Z
}
]

Well it worked, but the list is exactly the same. That's because we've only added comments for a single post! Let's create a comment for a second post and make sure that only those comments for a specific postId are returned.

We'll need the id of another post. Make sure you have at least two (create one through the admin if you need to). We can get a list of all the existing posts and copy the id:

> db.post.findMany({ select: { id: true } })
[ { id: 1 }, { id: 2 }, { id: 3 } ]

Okay, now let's create a comment for that second post via the console:

> db.comment.create({ data: { name: 'Peter', body: 'I also like leaving comments', postId: 2 } })
{
id: 3,
name: 'Peter',
body: 'I also like leaving comments',
postId: 2,
createdAt: 2020-12-08T23:47:10.641Z
}

Now we'll try our comment query again, once with each postId:

> db.comment.findMany({ where: { postId: 1 }})
[
{
id: 1,
name: 'Rob',
body: 'The first real comment!',
postId: 1,
createdAt: 2020-12-08T23:45:10.641Z
},
{
id: 2,
name: 'Tom',
body: 'Here is another comment',
postId: 1,
createdAt: 2020-12-08T23:46:10.641Z
}
]
> db.comment.findMany({ where: { postId: 2 }})
[
{
id: 3,
name: 'Peter',
body: 'I also like leaving comments',
postId: 2,
createdAt: 2020-12-08T23:45:10.641Z
},

Great! Now that we've tested out the syntax let's use that in the service. You can exit the console by pressing Ctrl-C twice or typing .exit

Where's the await?

Calls to db return a Promise, which you would normally need to add an await to in order to get the results right away. Having to add await every time is pretty annoying though, so the Redwood console does it for you—Redwood awaits so you don't have to!

Updating the Service#

Try running the test suite (or if it's already running take a peek at that terminal window) and make sure all of our tests still pass. The "lowest level" of the api-side is the services, so let's start there.

One way to think about your codebase is a "top to bottom" view where the top is what's "closest" to the user and what they interact with (React components) and the bottom is the "farthest" thing from them, in the case of a web application that would usually be a database or other data store (behind a third party API, perhaps). One level above the database are the services, which directly communicate to the database:

Browser
|
React ─┐
| │
Graph QL ├─ Redwood
| │
Services ─┘
|
Database

There are no hard and fast rules here, but generally the farther down you put your business logic (the code that deals with moving and manipulating data) the easier it will be to build and maintain your application. Redwood encourages you to put your business logic in services since they're "closest" to the data and behind the GraphQL interface.

Open up the comments service test and let's update it to pass the postId argument to the comments() function like we tested out in the console:

// api/src/services/comments/comments.test.js
scenario('returns all comments', async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
expect(result.length).toEqual(Object.keys(scenario.comment).length)
})

When the test suite runs everything will still pass. Javascript won't care if you're passing an argument all of a sudden (although if you were using Typescript you will actually get an error at this point!). In TDD you generally want to get your test to fail before adding code to the thing you're testing which will then cause the test to pass. What's something in this test that will be different once we're only returning some comments? How about the number of comments expected to be returned?

Let's take a look at the scenario we're using (remember, it's standard() by default):

// api/src/services/comments/comments.scenarios.js
export const standard = defineScenario({
comment: {
jane: {
data: {
name: 'Jane Doe',
body: 'I like trees',
post: {
create: {
title: 'Redwood Leaves',
body: 'The quick brown fox jumped over the lazy dog.',
},
},
},
},
john: {
data: {
name: 'John Doe',
body: 'Hug a tree today',
post: {
create: {
title: 'Root Systems',
body: 'The five boxing wizards jump quickly.',
},
},
},
},
},
})

Each scenario here is associated with its own post, so rather than counting all the comments in the database (like the test does now) let's only count the number of comments attached to the single post we're getting commnents for (we're passing the postId into the comments() call now). Let's see what it looks like in test form:

// api/src/services/comments/comments.test.js
import { comments, createComment } from './comments'
import { db } from 'api/src/lib/db'
describe('comments', () => {
scenario('returns all comments', async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
})
// ...
})

So we're first getting the result from the services, all the comments for a given postId. Then we pull the actual post from the database and include its comments. Then we expect that the number of comments returned from the service is the same as the number of comments actually attached to the post in the database. Now the test fails and you can see why in the output:

FAIL api api/src/services/comments/comments.test.js
• comments › returns all comments
expect(received).toEqual(expected) // deep equality
Expected: 1
Received: 2

So we expected to receive 1 (from post.comments.length), but we actually got 2 (from result.length).

Before we get it passing again, let's also change the name of the test to reflect what it's actually testing:

// api/src/services/comments/comments.test.js
scenario(
'returns all comments for a single post from the database',
async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
}
)

Okay, open up the actual comments.js service and we'll update it to accept the postId argument and use it as an option to findMany():

// api/src/services/comments/comments.js
export const comments = ({ postId }) => {
return db.comment.findMany({ where: { postId } })
}

Save that and the test should pass again!

Updating GraphQL#

Next we need to let GraphQL know that it should expect a postId to be passed for the comments query, and it's required (we don't currently have any view that allows you see all comments everywhere so we can ask that it always be present). Open up the comments.sdl.js file:

// api/src/graphql/comments.sdl.js
type Query {
comments(postId: Int!): [Comment!]!
}

Now if you try refreshing the real site in dev mode you'll see an error where the comments should be displayed:

image

And yep, it's complaining about postId not being present—exactly what we want!

That completes the backend updates, now we just need to tell CommentsCell to pass through the postId to the GraphQL query it makes.

Updating the Cell#

First we'll need to get the postId to the cell itself. Remember when we added a postId prop to the CommentForm component so it knew which post to attach the new comment to? Let's do the same for CommentsCell.

Open up Article:

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

And finally, we need to take that postId and pass it on to the QUERY in the cell:

// web/src/components/CommentsCell/CommentsCell.js
export const QUERY = gql`
query CommentsQuery($postId: Int!) {
comments(postId: $postId) {
id
name
body
createdAt
}
}
`

Where does this magical $postId come from? Redwood is nice enough to automatically provide it to you since you passed it in as a prop when you called the component!

Try going to a couple of different blog posts and you should see only comments associated to the proper posts (including the one we created in the console!). You can add a comment to each blog post individually and they'll stick to their proper owners:

image

However, you may have noticed that now when you post a comment it no longer appears right away! ARGH! Okay, turns out there's one more thing we need to do. Remember when we told the comment creation logic to refetchQueries? We need to include any variables that were present the first time so that it can refetch the proper ones.

Updating the Form Refetch#

Okay this is the last fix, promise!

// web/src/components/CommentForm/CommentForm.js
const [createComment, { loading, error }] = useMutation(CREATE, {
onCompleted: () => {
setHasPosted(true)
toast.success('Thank you for your comment!')
},
refetchQueries: [{ query: CommentsQuery, variables: { postId } }],
})

There we go, comment engine complete! Our blog is totally perfect and there's absolutely nothing we could do to make it better.

Or is there?