RedwoodJS

v0.19.0

# Saving Data

Let's add a new database table. Open up api/prisma/schema.prisma and add a Contact model after the Post model that's there now:

// api/prisma/schema.prisma

model Contact {
  id        Int @id @default(autoincrement())
  name      String
  email     String
  message   String
  createdAt DateTime @default(now())
}

To mark a column as optional (that is, allowing NULL as a value) you can suffix the datatype with question mark: name String?

Next we create a migration file:

yarn rw db save create contact

Finally we execute the migration to run the DDL commands to upgrade the database:

yarn rw db up

Now we'll create the GraphQL interface to access this table. We haven't used this generate command yet (although the scaffold command did use it behind the scenes):

yarn rw g sdl contact

Just like the scaffold command, this will create two new files under the api directory:

  1. api/src/graphql/contacts.sdl.js: defines the GraphQL schema in GraphQL's schema definition language
  2. api/src/services/contacts/contacts.js: contains your app's business logic.

Open up api/src/graphql/contacts.sdl.js and you'll see the Contact, CreateContactInput and UpdateContactInput types were already defined for us—the generate sdl command introspected the schema and created a Contact type containing each database field in the table, as well as a Query type with a single query contacts which returns an array of Contact types:

// api/src/graphql/contacts.sdl.js

export const schema = gql`
  type Contact {
    id: Int!
    name: String!
    email: String!
    message: String!
    createdAt: DateTime!
  }

  type Query {
    contacts: [Contact!]!
  }

  input CreateContactInput {
    name: String!
    email: String!
    message: String!
  }

  input UpdateContactInput {
    name: String
    email: String
    message: String
  }
`

What's CreateContactInput and UpdateContactInput? Redwood follows the GraphQL recommendation of using Input Types in mutations rather than listing out each and every field that can be set. Any fields required in schema.prisma are also required in CreateContactInput (you can't create a valid record without them) but nothing is explictly required in UpdateContactInput. This is because you could want to update only a single field, or two fields, or all fields. The alternative would be to create separate Input types for every permutation of fields you would want to update. We felt that only having one update input, while maybe not pedantically the absolute correct way to create a GraphQL API, was a good compromise for optimal developer experience.

Redwood assumes your code won't try to set a value on any field named id or createdAt so it left those out of the Input types, but if your database allowed either of those to be set manually you can update CreateContactInput or UpdateContactInput and add them.

Since all of the DB columns were required in the schema.prisma file they are marked as required here (the ! suffix on the datatype).

Remember: schema.prisma syntax requires an extra ? character when a field is not required, GraphQL's SDL syntax requires an extra ! when a field is required.

As described in Side Quest: How Redwood Deals with Data there are no explicit resolvers defined in the SDL file. Redwood follows a simple naming convention—each field listed in the Query and Mutation types map to a function with the same name in the services file and in the sdl file (api/src/graphql/contacts.sdl.js -> api/src/services/contacts/contacts.js)

In this case we're creating a single Mutation that we'll call createContact. Add that to the end of the SDL file (before the closing backtick):

// api/src/graphql/contacts.sdl.js

type Mutation {
  createContact(input: CreateContactInput!): Contact
}

The createContact mutation will accept a single variable, input, that is an object that conforms to what we expect for a CreateContactInput, namely { name, email, message }.

That's it for the SDL file, let's define the service that will actually save the data to the database. The service includes a default contacts function for getting all contacts from the database. Let's add our mutation to create a new contact:

// api/src/services/contacts/contacts.js

import { db } from 'src/lib/db'

export const contacts = () => {
  return db.contact.findMany()
}

export const createContact = ({ input }) => {  return db.contact.create({ data: input })}

Thanks to Prisma Client JS it takes very little code to actually save something to the database! This is an asynchronous call but we didn't have to worry about resolving Promises or dealing with async/await. Apollo will do that for us!

Before we plug this into the UI, let's take a look at a nifty GUI you get just by running yarn redwood dev.

# GraphQL Playground

Often it's nice to experiment and call your API in a more "raw" form before you get too far down the path of implementation only to find out something is missing. Is there a typo in the API layer or the web layer? Let's find out by accessing just the API layer.

When you started development with yarn redwood dev you actually started a second process running at the same time. Open a new browser tab and head to http://localhost:8911/graphql This is Prisma's GraphQL Playground, a web-based GUI for GraphQL APIs:

Not very exciting yet, but check out that "Docs" tab on the far right:

It's the complete schema as defined by our SDL files! The Playground will ingest these definitions and give you autocomplete hints on the left to help you build queries from scratch. Try getting the IDs of all the posts in the database; type the query at the left and then click the "Play" button to execute:

The GraphQL Playground is a great way to experiment with your API or troubleshoot when you come across a query or mutation that isn't behaving in the way you expect.

# Creating a Contact

Our GraphQL mutation is ready to go on the backend so all that's left is to invoke it on the frontend. Everything related to our form is in ContactPage so that's the logical place to put the mutation call. First we define the mutation as a constant that we call later (this can be defined outside of the component itself, right after the import statements):

// web/src/pages/ContactPage/ContactPage.js

const CREATE_CONTACT = gql`
  mutation CreateContactMutation($input: CreateContactInput!) {
    createContact(input: $input) {
      id
    }
  }
`

We reference the createContact mutation we defined in the Contacts SDL passing it an input object which will contain the actual name, email and message fields.

Next we'll call the useMutation hook provided by Apollo which will allow us to execute the mutation when we're ready (don't forget the import statement):

// web/src/pages/ContactPage/ContactPage.js

import {
  Form,
  TextField,
  TextAreaField,
  Submit,
  FieldError,
  Label,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'import BlogLayout from 'src/layouts/BlogLayout'

const ContactPage = () => {
  const [create] = useMutation(CREATE_CONTACT)
  const onSubmit = (data) => {
    console.log(data)
  }

  return (...)
}

create is a function that invokes the mutation and takes an object with a variables key, containing another object with an input key. As an example, we could call it like:

create({
  variables: {
    input: {
      name: 'Rob',
      email: 'rob@redwoodjs.com',
      message: 'I love Redwood!',
    },
  },
})

If you'll recall <Form> gives us all of the fields in a nice object where the key is the name of the field, which means the data object we're receiving in onSubmit is already in the proper format that we need for the input!

Now we can update the onSubmit function to invoke the mutation with the data it receives:

// web/src/pages/ContactPage/ContactPage.js

const ContactPage = () => {
  const [create] = useMutation(CREATE_CONTACT)

  const onSubmit = (data) => {
    create({ variables: { input: data }})    console.log(data)
  }

  return (...)
}

Try filling out the form and submitting—you should have a new Contact in the database! You can verify that with the GraphQL Playground if you were so inclined:

image

# Improving the Contact Form

Our contact form works but it has a couple of issues at the moment:

  • Clicking the submit button multiple times will result in multiple submits
  • The user has no idea if their submission was successful
  • If an error was to occur on the server, we have no way of notifying the user

Let's address these issues.

The useMutation hook returns a couple more elements along with the function to invoke it. We can destructure these as the second element in the array that's returned. The two we care about are loading and error:

// web/src/pages/ContactPage/ContactPage.js

const ContactPage = () => {
  const [create, { loading, error }] = useMutation(CREATE_CONTACT)
  const onSubmit = (data) => {
    create({ variables: { input: data } })
    console.log(data)
  }

  return (...)
}

Now we know if the database call is still in progress by looking at loading. An easy fix for our multiple submit issue would be to disable the submit button if the response is still in progress. We can set the disabled attribute on the "Save" button to the value of loading:

// web/src/pages/ContactPage/ContactPage.js

return (
  // ...
  <Submit disabled={loading}>Save</Submit>  // ...
)

It may be hard to see a difference in development because the submit is so fast, but you could enable network throttling via the Network tab Chrome's Web Inspector to simulate a slow connection:

You'll see that the "Save" button become disabled for a second or two while waiting for the response.

Next, let's use Redwood's Flash system to let the user know their submission was successful. useMutation accepts an options object as a second argument. One of the options is a callback function, onCompleted, that will be invoked when the mutation successfully completes. We'll use that callback to add a message for the Flash component to display. Add the Flash component to the page and use the timeout prop to schedule the message's dismissal. (You can read the full documentation about Redwood's Flash system here.)

// web/src/pages/ContactPage/ContactPage.js

// ...
import { Flash, useFlash, useMutation } from '@redwoodjs/web'import BlogLayout from 'src/layouts/BlogLayout'

// ...

const ContactPage = () => {
  const { addMessage } = useFlash()
  const [create, { loading, error }] = useMutation(CREATE_CONTACT, {
    onCompleted: () => {      addMessage('Thank you for your submission!', {        style: { backgroundColor: 'green', color: 'white', padding: '1rem' }      })    },  })

  // ...

  return (
    <BlogLayout>
      <Flash timeout={1000} />      // ...

# Displaying Server Errors

Next we'll inform the user of any server errors. So far we've only notified the user of client errors: a field was missing or formatted incorrectly. But if we have server-side constraints in place <Form> can't know about those, but we still need to let the user know something went wrong.

We have email validation on the client, but any good developer knows never trust the client. Let's add the email validation on the API as well to be sure no bad data gets into our database, even if someone somehow bypassed our client-side validation.

Why don't we need server-side validation for the existence of name, email and message? Because the database is doing that for us. Remember the String! in our SDL definition? That adds a constraint in the database that the field cannot be null. If a null was to get all the way down to the database it would reject the insert/update and GraphQL would throw an error back to us on the client.

There's no Email! datatype so we'll need to validate that on our own.

We talked about business logic belonging in our services files and this is a perfect example. Let's add a validate function to our contacts service:

// api/src/services/contacts/contacts.js

import { UserInputError } from '@redwoodjs/api'
import { db } from 'src/lib/db'

const validate = (input) => {  if (input.email && !input.email.match(/[^@]+@[^.]+\..+/)) {    throw new UserInputError("Can't create new contact", {      messages: {        email: ['is not formatted like an email address'],      },    })  }}
export const contacts = () => {
  return db.contact.findMany()
}

export const createContact = ({ input }) => {
  validate(input)  return db.contact.create({ data: input })
}

So when createContact is called it will first validate the inputs and only if no errors are thrown will it continue to actually create the record in the database.

We already capture any existing error in the error constant that we got from useMutation, so we could manually display an error box on the page somewhere containing those errors, maybe at the top of the form:

// web/src/pages/ContactPage/ContactPage.js

<Form onSubmit={onSubmit} validation={{ mode: 'onBlur' }}>
  {error && (    <div style={{ color: 'red' }}>      {"We couldn't send your message: "}      {error.message}    </div>  )}  // ...

If you need to handle your errors manually, you can do this:

// web/src/pages/ContactPage/ContactPage.js
const onSubmit = async (data) => {
  try {    await create({ variables: { input: data } })    console.log(data)  catch (error) {    console.log(error)  }}

To get a server error to fire, let's remove the email format validation so that the client-side error isn't shown:

// web/src/pages/ContactPage/ContactPage.js

<TextField
  name="email"
  validation={{
    required: true,
  }}
  errorClassName="error"
/>

Now try filling out the form with an invalid email address:

It ain't pretty, but it works. Seeing a "GraphQL error" is not ideal, and it would be nice if the field itself was highlighted like it was when the inline validation was in place...

Remember when we said that <Form> had one more trick up its sleeve? Here it comes!

Remove the inline error display we just added ({ error && ...}) and replace it with <FormError>, passing the error constant we got from useMutation and a little bit of styling to wrapperStyle (don't forget the import). We'll also pass error to <Form> so it can setup a context:

// web/src/pages/ContactPage/ContactPage.js

import {
  Form,
  TextField,
  TextAreaField,
  Submit,
  FieldError,
  Label,
  FormError,} from '@redwoodjs/forms'
import { Flash, useFlash, useMutation } from '@redwoodjs/web'
// ...

return (
  <BlogLayout>
    <Flash timeout={1000}>
    <Form onSubmit={onSubmit} validation={{ mode: 'onBlur' }} error={error}>      <FormError        error={error}        wrapperStyle={{ color: 'red', backgroundColor: 'lavenderblush' }}      />
      //...
)

Now submit a message with an invalid email address:

We get that error message at the top saying something went wrong in plain english and the actual field is highlighted for us, just like the inline validation! The message at the top may be overkill for such a short form, but it can be key if a form is multiple screens long; the user gets a summary of what went wrong all in one place and they don't have to resort to hunting through a long form looking for red boxes. You don't have to use that message box at the top, though; just remove <FormError> and the field will still be highlighted as expected.

<FormError> has several styling options which are attached to different parts of the message:

  • wrapperStyle / wrapperClassName: the container for the entire message
  • titleStyle / titleClassName: the "Can't create new contact" title
  • listStyle / listClassName: the <ul> that contains the list of errors
  • listItemStyle / listItemClassName: each individual <li> around each error

# One more thing...

Since we're not redirecting after the form submits we should at least clear out the form fields. This requires we get access to a reset() function that's part of react-hook-form but we don't have access to it when using the simplest usage of <Form> (like we're currently using).

react-hook-form has a hook called useForm() which is normally called for us within <Form>. In order to reset the form we need to invoke that hook ourselves. But the functionality that useForm() provides still needs to be used in Form. Here's how we do that.

First we'll import useForm:

// web/src/pages/ContactPage/ContactPage.js

import { useForm } from 'react-hook-form'

And now call it inside of our component:

// web/src/pages/ContactPage/ContactPage.js

const ContactPage = () => {
  const formMethods = useForm()  //...

Finally we'll tell <Form> to use the formMethods we just instantiated instead of doing it itself:

// web/src/pages/ContactPage/ContactPage.js

return (
  <BlogLayout>
    <Flash timeout={1000}>
    <Form
      onSubmit={onSubmit}
      validation={{ mode: 'onBlur' }}
      error={error}
      formMethods={formMethods}    >
    // ...

Now we can call reset() on formMethods after the success message is added to the Flash system:

// web/src/pages/ContactPage/ContactPage.js

const [create, { loading, error }] = useMutation(CREATE_CONTACT, {
  onCompleted: () => {
    // addMessage...
    formMethods.reset()
  },
})
Screenshot of Contact form with Flash success message

You can put the email validation back into the <TextField> now, but you should leave the server validation in place, just in case.

Here's the final ContactForm.js page:

import {
  Form,
  TextField,
  TextAreaField,
  Submit,
  FieldError,
  Label,
  FormError,
} from '@redwoodjs/forms'
import { Flash, useFlash, useMutation } from '@redwoodjs/web'
import { useForm } from 'react-hook-form'
import BlogLayout from 'src/layouts/BlogLayout'

const CREATE_CONTACT = gql`
  mutation CreateContactMutation($input: CreateContactInput!) {
    createContact(input: $input) {
      id
    }
  }
`

const ContactPage = () => {
  const formMethods = useForm()
  const { addMessage } = useFlash()

  const [create, { loading, error }] = useMutation(CREATE_CONTACT, {
    onCompleted: () => {
      addMessage('Thank you for your submission!', {
        style: { backgroundColor: 'green', color: 'white', padding: '1rem' }
      })
      formMethods.reset()
    },
  })

  const onSubmit = (data) => {
    create({ variables: { input: data } })
    console.log(data)
  }

  return (
    <BlogLayout>
      <Flash timeout={1000} />
      <Form
        onSubmit={onSubmit}
        validation={{ mode: 'onBlur' }}
        error={error}
        formMethods={formMethods}
      >
        <FormError
          error={error}
          wrapperStyle={{ color: 'red', backgroundColor: 'lavenderblush' }}
        />
        <Label name="name" errorClassName="error">
          Name
        </Label>
        <TextField
          name="name"
          validation={{ required: true }}
          errorClassName="error"
        />
        <FieldError name="name" className="error" />

        <Label name="name" errorClassName="error">
          Email
        </Label>
        <TextField
          name="email"
          validation={{
            required: true,
          }}
          errorClassName="error"
        />
        <FieldError name="email" className="error" />

        <Label name="name" errorClassName="error">
          Message
        </Label>
        <TextAreaField
          name="message"
          validation={{ required: true }}
          errorClassName="error"
        />
        <FieldError name="message" className="error" />

        <Submit disabled={loading}>Save</Submit>
      </Form>
    </BlogLayout>
  )
}

export default ContactPage

That's it! React Hook Form provides a bunch of functionality that <Form> doesn't expose. When you want to get to that functionality you can: just call useForm() yourself but make sure to pass the returned object (we called it formMethods) as a prop to <Form> so that the validation and other functionality keeps working.

You may have noticed that the onBlur form validation stopped working once you started calling useForm() yourself. That's because Redwood calls useForm() behind the scenes and automaticaly passes it the validation prop that you gave to <Form>. Redwood is no longer calling useForm() for you so if you need some options passed you need to do it manually:

const formMethods = useForm({ mode: 'onBlur' })

The public site is looking pretty good. How about the administrative features that let us create and edit posts? We should move them to some kind of admin section and put them behind a login so that random users poking around at URLs can't create ads for discount pharmaceuticals.