RedwoodJS v7.7 Tutorial Chapter 3

Aug 14, 2024

はじめに

Chapter 3はフォームの作成、データベースへの書き込み処理まで実装するという内容になります。

前回の記事

フォームの作成

ページの作成

まずは「Contact Us」ページを用意します。

yarn rw g page contact
 Generating page files...
 Successfully wrote file `./web/src/pages/ContactPage/ContactPage.stories.tsx`
 Successfully wrote file `./web/src/pages/ContactPage/ContactPage.test.tsx`
 Successfully wrote file `./web/src/pages/ContactPage/ContactPage.tsx`
 Updating routes file...
 Generating types...
 One more thing...
  Page created! A note about <Metadata>:
  At the top of your newly created page is a <Metadata> component,
  which contains the title and description for your page, essential
  to good SEO. Check out this page for best practices:
  https://developers.google.com/search/docs/advanced/appearance/good-titles-snippets

BlogLayoutにリンクを追加します。

// web/src/layouts/BlogLayout/BlogLayout.tsx
import { Link, routes } from '@redwoodjs/router'

type BlogLayoutProps = {
  children?: React.ReactNode
}

const BlogLayout = ({ children }: BlogLayoutProps) => {
  return (
    <>
      <header>
        <h1>
          <Link to={routes.home()}>Redwood Blog</Link>
        </h1>
        <nav>
          <ul>
            <li>
              <Link to={routes.home()}>Home</Link>
            </li>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
            <li>
              <Link to={routes.contact()}>Contact</Link>
            </li>
          </ul>
        </nav>
      </header>
      <main>{children}</main>
    </>
  )
}

export default BlogLayout

次にRoutes.tsxのContactPageのルートをBlogLayout直下に移動します。

// web/src/Routes.tsx
import { Router, Route, Set } from '@redwoodjs/router'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
      <Set wrap={ScaffoldLayout} title="Posts" titleTo="posts" buttonLabel="New Post" buttonTo="newPost">
        <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
        <Route path="/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
        <Route path="/posts/{id:Int}" page={PostPostPage} name="post" />
        <Route path="/posts" page={PostPostsPage} name="posts" />
      </Set>
      <Set wrap={BlogLayout}>
        <Route path="/article/{id:Int}" page={ArticlePage} name="article" />
        <Route path="/contact" page={ContactPage} name="contact" />
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

フォームヘルパー

RedwoodJSでは簡単にフォームが構築できるフォームヘルパーというものが提供されています。 まず、ContactPageに@redwoodjs/formsパッケージからコンポーネントをimportして以下のようにフォームが作成できます。

// web/src/pages/ContactPage/ContactPage.tsx
import {
  Form,
  Submit,
  SubmitHandler,
  TextAreaField,
  TextField,
} from '@redwoodjs/forms'
import { Metadata } from '@redwoodjs/web'

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data)
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page" />

      <Form onSubmit={onSubmit}>
        <label htmlFor="name">Name</label>
        <TextField name="name" />
        <label htmlFor="email">Email</label>
        <TextField name="email" />
        <label htmlFor="message">Message</label>
        <TextAreaField name="message" />
        <Submit>Save</Submit>
      </Form>
    </>
  )
}

export default ContactPage

Contact Page

実際にフォームに値を入力して「Save」ボタンを押すと、コンソールのログに入力した値が表示されると思います。

フォームバリデーション

フォームでバリデーションを行うには、各XXXFieldコンポーネントのvalidation propにバリデーションルールを記述できます。 エラーメッセージの表示はFieldErrorコンポーネントを使います。

import {
  FieldError,
  Form,
  Label,
  Submit,
  SubmitHandler,
  TextAreaField,
  TextField,
} from '@redwoodjs/forms'
import { Metadata } from '@redwoodjs/web'

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data)
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page" />

      <Form onSubmit={onSubmit}>
        <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 }}
          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

ここでいくつかポイントがあります。

  • FieldErrorコンポーネントのnameをXXXFieldコンポーネントのnameに合わせる
  • FieldErrorにスタイルを適用したい場合は、classNameまたはstyleで行う
  • XXXFieldのエラースタイル適用はerrorClassNameまたはerrorStyleで行う
  • labelタグをLabelコンポーネントに差し替え、htmlForからnameに変える
  • Labelのエラースタイル適用はerrorClassNameまたはerrorStyleで行う

画面表示は以下のようになりました。

Contact Error

入力フォーマットのバリデーション

入力フォーマットのバリデーションは正規表現を指定して行うことができます。

<TextField
  name="email"
  validation={{
    required: true,
    pattern: {
      value: /^[^@]+@[^.]+\..+$/,
      message: 'Please enter a valid email address',
    },
  }}
  errorClassName="error"
/>

今のままだと入力途中でもバリデーション処理が走って、エラーメッセージが出てしまいます。 これを直すために、入力から離れた時にバリデーション処理が走るように変更します。

<Form onSubmit={onSubmit} config={{ mode: 'onBlur' }}>

データの保存

これまではデータベースからデータを読み取って表示することをやってきましたが、ここからはフォームから入力されたデータをデータベースへ書き込む実装を行います。

マイグレーション

まずContactモデルを定義して新しいテーブルをデータベースに作成します。

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

マイグレーションを実行します。

yarn rw prisma migrate dev
Running Prisma CLI...
$ yarn prisma migrate dev --schema /path/to/project/my-first-redwood-app/api/db/schema.prisma

Environment variables loaded from .env
Prisma schema loaded from api/db/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

 Enter a name for the new migration: create contact
Applying migration `xxxxxx_create_contact`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ xxxxxxx_create_contact/
    └─ migration.sql

Your database is now in sync with your schema.

 Generated Prisma Client (v5.14.0) to ./node_modules/@prisma/client in 34ms

SDLとサービスの作成

次に先ほど作成したテーブルへアクセスするためのGraphQLインターフェースを作成します。

yarn rw g sdl Contact
 Generating SDL files...
 Successfully wrote file `./api/src/graphql/contacts.sdl.ts`
 Successfully wrote file `./api/src/services/contacts/contacts.scenarios.ts`
 Successfully wrote file `./api/src/services/contacts/contacts.test.ts`
 Successfully wrote file `./api/src/services/contacts/contacts.ts`
 Generating types ...

テストに関するファイルは一旦無視するとして、SDLとサービスの2ファイルが生成されました。

  • api/src/graphql/contacts.sdl.ts: GraphQLのスキーマを定義するファイル。Schema Definition Language
  • api/src/services/contacts/contacts.ts: ビジネスロジックを記述するファイル

まずはcontacts.sdl.tsの中身をみてみましょう。

// api/src/graphql/contacts.sdl.ts
export const schema = gql`
  type Contact {
    id: Int!
    name: String!
    email: String!
    message: String!
    createdAt: DateTime!
  }

  type Query {
    contacts: [Contact!]! @requireAuth
    contact(id: Int!): Contact @requireAuth
  }

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

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

  type Mutation {
    createContact(input: CreateContactInput!): Contact! @requireAuth
    updateContact(id: Int!, input: UpdateContactInput!): Contact! @requireAuth
    deleteContact(id: Int!): Contact! @requireAuth
  }
`

@requireAuthは認証に関するスキーマディレクティブで、認証されたユーザーのみクエリを許可させたい場合に使用します。今は認証機能を実装していないので、常に許可される状態になっています。

今回はデータの保存なのでMutationのcreateContactを使用します。

今回は「Contact Us」のフォームのため、更新や削除は不要なので、消しておきましょう。

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

次はサービスファイルcontacts.tsを見てみます。

import type { QueryResolvers, MutationResolvers } from 'types/graphql'

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

export const contacts: QueryResolvers['contacts'] = () => {
  return db.contact.findMany()
}

export const contact: QueryResolvers['contact'] = ({ id }) => {
  return db.contact.findUnique({
    where: { id },
  })
}

export const createContact: MutationResolvers['createContact'] = ({
  input,
}) => {
  return db.contact.create({
    data: input,
  })
}

export const updateContact: MutationResolvers['updateContact'] = ({
  id,
  input,
}) => {
  return db.contact.update({
    data: input,
    where: { id },
  })
}

export const deleteContact: MutationResolvers['deleteContact'] = ({ id }) => {
  return db.contact.delete({
    where: { id },
  })
}

こちらはかなりシンプルでGraphQLを介して受け取ったパラメータを使い、データベースへの読み書き処理を行っているだけになります。

こちらも更新updateContactと削除deleteContactは不要なため消しておきます。

GraphQL Playground

GraphQLでアプリケーション開発を行なった方はお馴染みかもしれませんが、先ほど準備したSDLとサービスの動作確認はGraphQL Playgroundで行えます。

開発サーバーを起動した状態で、http://localhost:8911/graphql へアクセスすると以下のような画面が表示されます。

Redwood GraphQL Playground

ここでは前回のようにセルで記述したGraphQLクエリを実行してみることもできますし、左側のサイドバーにあるDocsではどのようなQueryやMutationが使えるかの仕様も確認することができます。

試しに以下のようにサイドバーのExplorerからcreateContactMutationを作成、実行してみました。

createContact Mutation

Prisma Studioでデータベースの内容を確認すると、レコードが作られていました。

Contactデータの登録

サーバー側の実装が完了したので、いよいよクライアント側の実装を行います。

ContactPageを編集して、createContactMutationのリクエストを投げるようにします。

// web/src/pages/ContactPage/ContactPage.tsx

import {
  CreateContactMutation,
  CreateContactMutationVariables,
} from 'types/graphql'

import {
  FieldError,
  Form,
  Label,
  Submit,
  SubmitHandler,
  TextAreaField,
  TextField,
} from '@redwoodjs/forms'
import { Metadata, useMutation } from '@redwoodjs/web'

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

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const [create] = useMutation<
    CreateContactMutation,
    CreateContactMutationVariables
  >(CREATE_CONTACT)

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    create({ variables: { input: data } })
  }

  return (
    <>
      <Metadata 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

実装の流れとしては先ほどPlygroundで実行したようなMutationを定義して、それをuseMutationフックに渡します。

そのフックの返り値の中にMutationを実行できる関数createがあります。 それをフォーム送信時に実行することでデータを保存します。

試しにフォームに値を入力して「Save」ボタンを押してみると、特にリアクションはないですが確かにデータベースにレコードが作成されていました。

このままだと送信後に反応がなかったり、連続でボタンが押せてしまうのでもう少し手を加えます。

// web/src/pages/ContactPage/ContactPage.tsx

import { toast, Toaster } from '@redwoodjs/web/toast'
// ...

const ContactPage = () => {
  const [create, { loading, error }] = useMutation<
    CreateContactMutation,
    CreateContactMutationVariables
  >(CREATE_CONTACT, {
    onCompleted: () => {
      toast.success('Thank you for your submission!')
    },
  })

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    create({ variables: { input: data } })
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page" />

      <Toaster />
      <Form onSubmit={onSubmit} config={{ mode: 'onBlur' }}>
        // ...
        <Submit disabled={loading}>Save</Submit>
      </Form>
    </>
  )
}

// ...

これでリクエスト中はボタンが押せなくなり、リクエスト成功時にトーストが表示されるようになりました。

サーバー側のエラー表示

最初にクライアント側のバリデーションやエラー表示は実装しました。

しかし、もちろんサーバー側でのバリデーションも必要になります。

サーバー側のバリデーションはサービスで行うようです。

// api/src/services/contacts/contacts.ts
import type { QueryResolvers, MutationResolvers } from 'types/graphql'

import { validate } from '@redwoodjs/api'

// ...

export const createContact: MutationResolvers['createContact'] = ({ input }) => {
  validate(input.email, 'email', { email: true })
  return db.contact.create({ data: input })
}

validate関数を呼び出して、

  • 第1引数: バリデーションを実行する入力値
  • 第2引数: <TextField>コンポーネントのname propに合わせる(TextFieldにエラーと認識させるため)
  • 第3引数: バリデーションルールを書く

動作確認のためにEmailのTextFieldコンポーネントのpattern propを削除しておきましょう。 不正なメールアドレスを入力するとクライアント側のバリデーションに弾かれて、サーバー側に送れません。

さらにサーバー側からのerrorをFormに渡します。

<Form
  onSubmit={onSubmit}
  config={{
    mode: 'onBlur',
  }}
  error={error}
>
// ...
  <TextField
    name="email"
    validation={{
      required: true,
    }}
    errorClassName="error"
  />
// ...

ちなみにエラー表示には<FormError>コンポーネントも使えるそうです。 フォームのトップに表示させたい場合などに使えそうです。

<FormError error={error} wrapperClassName="form-error" />

フォームのクリア

今は保存成功後でもフォームに入力した値が残ってしまっているため、クリア処理を入れます。

useForm()フックを使って実現できます。

// ...
import {
  // ...
  useForm,
} from '@redwoodjs/forms'

// ...

const ContactPage = () => {
  const formMethods = useForm()
  const [create, { loading, error }] = useMutation<
    CreateContactMutation,
    CreateContactMutationVariables
  >(CREATE_CONTACT, {
    onCompleted: () => {
      toast.success('Thank you for your submission!')
      formMethods.reset()
    },
  })

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    create({ variables: { input: data } })
  }

  return (
    <>
      // ...
      <Form
        onSubmit={onSubmit}
        config={{
          mode: 'onBlur',
        }}
        error={error}
        formMethods={formMethods}
      >
        // ...
      </Form>
    </>
  )
}

これで「Contact Us」フォームの完成です。

Chapter 3は以上となります。