RedwoodJS

v0.26.2

# Building a Component the Redwood Way

What's our blog missing? Comments. Let's add a simple comment engine so people can leave their completely rational, well-reasoned comments on our blog posts. It's the internet, what could go wrong?

There are two main features we need to build:

  1. Comment form and creation
  2. Comment retrieval and display

Which order we build them in is up to us. To ease into things, let's start with the fetching and displaying comments first and then we'll move on to more complex work of adding a form and service to create a new comment. Of course, this is Redwood, so even forms and services aren't that complex!

# Storybook

Let's create a component for the display of a single comment. First up, the generator:

yarn rw g component Comment

Storybook should refresh and our "Generated" Comment story will be ready to go:

image

Let's think about what we want to ask users for and then display in a comment. How about just their name and the content of the comment itself? And we'll throw in the date/time it was created. Let's update the Comment component to accept a comment object with those two properties:

// web/src/components/Comment/Comment.js

const Comment = ({ comment }) => {  return (
    <div>
      <h2>{comment.name}</h2>      <time datetime={comment.createdAt}>{comment.createdAt}</time>      <p>{comment.body}</p>    </div>
  )
}

export default Comment

Once you save that file and Storybook reloads you'll see it blow up:

image

We need to update the story to include that comment object and pass it as a prop:

// web/src/components/Comment/Comment.stories.js

import Comment from './Comment'

export const generated = () => {
  return (
    <Comment
      comment={{        name: 'Rob Cameron',        body: 'This is the first comment!',        createdAt: '2020-01-01T12:34:56Z'      }}    />
  )
}

export default { title: 'Components/Comment' }

Note that Datetimes will come from GraphQL in ISO8601 format so we need to return one in that format here.

Storybook will reload and be much happier:

image

Let's add a little bit of styling and date conversion to get this Comment component looking like a nice, completed design element:

// web/src/components/Comment/Comment.js

const formattedDate = (datetime) => {  const parsedDate = new Date(datetime)  const month = parsedDate.toLocaleString('en-US', { month: 'long' })  return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}`}
const Comment = ({ comment }) => {
  return (
    <div className="bg-gray-200 p-8 rounded-lg">      <header className="flex justify-between">        <h2 className="font-semibold text-gray-700">{comment.name}</h2>        <time className="text-xs text-gray-500" dateTime={comment.createdAt}>          {formattedDate(comment.createdAt)}        </time>      </header>      <p className="text-sm mt-2">{comment.body}</p>    </div>  )
}

export default Comment

image

It's tough to see our rounded corners, but rather than adding margin or padding to the component itself (which would add them everywhere we use the component) let's add a margin in the story so it only shows in Storybook:

// web/src/components/Comment/Comment.stories.js

import Comment from './Comment'

export const generated = () => {
  return (
    <div className="m-4">
      <Comment
        comment={{
          name: 'Rob Cameron',
          body: 'This is the first comment!',
          createdAt: '2020-01-01T12:34:56Z',
        }}
      />    </div>  )
}

export default { title: 'Components/Comment' }

A best practice to keep in mind when designing in HTML and CSS is to keep a visual element responsible for its own display only, and not assume what it will be contained within. In this case a Comment doesn't and shouldn't know where it will be displayed, so it shouldn't add any design influence outside of its container (like forcing a margin around itself).

Now we can see our roundedness quite easily in Storybook:

image

If you haven't used TailwindCSS before just know that the m in the className is short for "margin" and the 4 refers to four "units" of margin. By default one unit is 0.25rem. So "m-4" is equivalent to margin: 1rem.

# Testing

We don't want Santa to skip our house for being naughty developers so let's test our Comment component. We could test that the author's name and the body of the comment appear, as well as the date it was posted.

The default test that comes with a generated component just makes sure that no errors are thrown, which is the least we could ask of our components!

Let's add a sample comment to the test and check that the various parts are being rendered:

// web/src/components/Comment.test.js

import { render, screen } from '@redwoodjs/testing'
import Comment from './Comment'

describe('Comment', () => {
  it('renders successfully', () => {
    const comment = {      name: 'John Doe',      body: 'This is my comment',      createdAt: '2020-01-02T12:34:56Z',    }    render(<Comment comment={comment} />)    expect(screen.getByText(comment.name)).toBeInTheDocument()    expect(screen.getByText(comment.body)).toBeInTheDocument()    const dateExpect = screen.getByText('2 January 2020')    expect(dateExpect).toBeInTheDocument()    expect(dateExpect.nodeName).toEqual('TIME')    expect(dateExpect).toHaveAttribute('datetime', comment.createdAt)  })
})

Here we're testing for both elements of the output createdAt timestamp: the actual text that's output (similar to how we tested for a blog post's truncated body) but also that the element that wraps that text is a <time> tag and that it contains a datetime attribute with the raw value of comment.createdAt. This might seem like overkill but the point of the datetime attribute is to provide a machine-readable timestamp that the browser could (theoretically) hook into and do stuff with. This makes sure that we preserve that ability!

What happens if we change the formatted output of the timestamp? Wouldn't we have to change the test?

Yes, just like we'd have to change the truncation text if we changed the length of the truncation. One alternative approach to testing for the formatted output could be to move the date formatting formula into a function that you can export from the Comment component. Then you can import that in your test and use it to check the formatted output. Now if you change the formula the test keeps passing because it's sharing the function with Comment.