Saving Data

Add a Contact Model#

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

// api/db/schema.prisma
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
}
model Contact {
id Int @id @default(autoincrement())
name String
email String
message String
createdAt DateTime @default(now())
}

Prisma syntax for optional fields

To mark a field as optional (that is, allowing NULL as a value) you can suffix the datatype with a question mark, e.g. name String?. This will allow name's value to be either a String or NULL.

Next we create and apply a migration:

yarn rw prisma migrate dev

We can name this one something like "create contact".

Create an SDL & Service#

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 (also creates associated test files)

If you remember our discussion in how Redwood works with data you'll recall that queries and mutations in an SDL file are automatically mapped to resolvers defined in a service, so when you generate an SDL file you'll get a service file as well, since one requires the other.

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!]! @requireAuth
}
input CreateContactInput {
name: String!
email: String!
message: String!
}
input UpdateContactInput {
name: String
email: String
message: String
}
`

The @requireAuth string is a schema directive which says that in order to access this GraphQL query the user is required to be authenticated. We haven't added authentication yet, so this won't have any effect—anyone will be able to query it, logged in or not, because until you add authentication the function behind @requireAuth always returns true.

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 explicitly 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 type 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 in the GraphQL Types with the ! suffix on the datatype (e.g. name: String!).

GraphQL syntax for required fields

GraphQL's SDL syntax requires an extra ! when a field is required. Remember: schema.prisma syntax requires an extra ? character when a field is not 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 in the sdl file (api/src/graphql/contacts.sdl.js) maps to a function with the same name in the services file (api/src/services/contacts/contacts.js).

So the default SDL that's created by the generators is effectively read-only: there is no way to create or update an existing record. Which means we'll need to add our own create functionality.

Psssstttt I'll let you in on a little secret: you can have Redwood create all the operators you need to perform CRUD (Create, Retrieve, Update, Delete) functions against your data automatically! But that wouldn't teach you anything, so we're doing it the hard way here. The next time you generate an SDL, try adding the --crud flag:

yarn rw g sdl Contact --crud

Shhhhhhhhh...

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
export const schema = gql`
type Contact {
id: Int!
name: String!
email: String!
message: String!
createdAt: DateTime!
}
type Query {
contacts: [Contact!]! @requireAuth
}
input CreateContactInput {
name: String!
email: String!
message: String!
}
input UpdateContactInput {
name: String
email: String
message: String
}
type Mutation {
createContact(input: CreateContactInput!): Contact @skipAuth
}
`

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 }. We've also added on a new directive: @skipAuth. This one says that authentication is not required and will allow anyone to anonymously send us a message. Note that having at least one schema directive is required for each Query and Mutation or you'll get an error: Redwood embraces the idea of "secure by default" meaning that we try and keep your application safe, even if you do nothing special to prevent access. In this case it's much safer to throw an error than to accidentally expose all of your users' data to the internet!

Serendipitously, the default schema directive of @requireAuth is exactly what we want for the contacts query that returns ALL contacts—only we, the owners of the blog, should have access to read them all.

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 one:

// 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 })
}

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 (or yarn rw 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 Apollo Server'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 where we'll 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
import { MetaTags } from '@redwoodjs/web'
import {
FieldError,
Form,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
const ContactPage = () => {
const onSubmit = (data) => {
console.log(data)
}
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Form onSubmit={onSubmit} config={{ mode: 'onBlur' }}>
<Label name="name" errorClassName="error">
Name
</Label>
<TextField
name="name"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="name" className="error" />
<Label name="email" errorClassName="error">
Email
</Label>
<TextField
name="email"
validation={{
required: true,
pattern: {
value: /^[^@]+@[^.]+\..+$/,
message: 'Please enter a valid email address',
},
}}
errorClassName="error"
/>
<FieldError name="email" className="error" />
<Label name="message" errorClassName="error">
Message
</Label>
<TextAreaField
name="message"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="message" className="error" />
<Submit>Save</Submit>
</Form>
</>
)
}
export default ContactPage

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 values.

Next we'll call the useMutation hook provided by Redwood 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 { MetaTags } from '@redwoodjs/web'
import {
FieldError,
Form,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
const ContactPage = () => {
const [create] = useMutation(CREATE_CONTACT)
const onSubmit = (data) => {
console.log(data)
}
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Form onSubmit={onSubmit} config={{ mode: 'onBlur' }}>
<Label name="name" errorClassName="error">
Name
</Label>
<TextField
name="name"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="name" className="error" />
<Label name="email" errorClassName="error">
Email
</Label>
<TextField
name="email"
validation={{
required: true,
pattern: {
value: /^[^@]+@[^.]+\..+$/,
message: 'Please enter a valid email address',
},
}}
errorClassName="error"
/>
<FieldError name="email" className="error" />
<Label name="message" errorClassName="error">
Message
</Label>
<TextAreaField
name="message"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="message" className="error" />
<Submit>Save</Submit>
</Form>
</>
)
}
export default ContactPage

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!

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

// web/src/pages/ContactPage/ContactPage.js
import { MetaTags } from '@redwoodjs/web'
import {
FieldError,
Form,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
const ContactPage = () => {
const [create] = useMutation(CREATE_CONTACT)
const onSubmit = (data) => {
create({ variables: { input: data } })
}
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Form onSubmit={onSubmit} config={{ mode: 'onBlur' }}>
<Label name="name" errorClassName="error">
Name
</Label>
<TextField
name="name"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="name" className="error" />
<Label name="email" errorClassName="error">
Email
</Label>
<TextField
name="email"
validation={{
required: true,
pattern: {
value: /^[^@]+@[^.]+\..+$/,
message: 'Please enter a valid email address',
},
}}
errorClassName="error"
/>
<FieldError name="email" className="error" />
<Label name="message" errorClassName="error">
Message
</Label>
<TextAreaField
name="message"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="message" className="error" />
<Submit>Save</Submit>
</Form>
</>
)
}
export default ContactPage

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

image

Wait, I thought you said this was secure by default and someone couldn't view all contacts without being logged in?

Remember: we haven't added authentication yet, so the concept of someone being logged in is meaningless right now. In order to prevent frustrating errors in a new application, the @requireAuth directive simply returns true until you setup an authentication system. At that point the directive will use real logic for determining if the user is logged in or not and behave accordingly.

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.

Disable Save on Loading#

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: