Voyez-vous des traductions manquantes ou incorrectes ? Aidez-nous à traduire! Consultez notre guide de traduction: Guide de traduction
Let's generate a component to house our new comment form, build it out and integrate it via Storybook, then add some tests:
And startup Storybook again if it isn't still running:
You'll see that there's a CommentForm entry in Storybook now, ready for us to get started.
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:
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 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:
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:
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
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.
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.
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):
Looks great in Storybook, how about on the real site?
Now comes the ultimate test: creating a comment! LET'S DO IT:
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
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
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
postIdwas 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.
CommentFormended 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
And then we'll append that ID to the
input object that's being passed to
createComment in the
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?
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.
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
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:
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
Now add a comment:
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:
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!
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.
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:
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:
(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
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
Okay, now let's create a comment for that second post via the console:
Now we'll try our comment query again, once with each
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
dbreturn a Promise, which you would normally need to add an
awaitto in order to get the results right away. Having to add
awaitevery time is pretty annoying though, so the Redwood console does it for you—Redwood
awaits so you don't have to!
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:
Let's take a look at the scenario we're using (remember, it's
standard() by default):
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:
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:
So we expected to receive 1 (from
post.comments.length), but we actually got 2 (from
Before we get it passing again, let's also change the name of the test to reflect what it's actually testing:
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
Save that and the test should pass again!
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
Now if you try refreshing the real site in dev mode you'll see an error where the comments should be displayed:
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.
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
And finally, we need to take that
postId and pass it on to the
QUERY in the cell:
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:
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.
Okay this is the last fix, promise!
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?