はじめに
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
実際にフォームに値を入力して「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で行う
画面表示は以下のようになりました。
入力フォーマットのバリデーション
入力フォーマットのバリデーションは正規表現を指定して行うことができます。
<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
へアクセスすると以下のような画面が表示されます。
ここでは前回のようにセルで記述したGraphQLクエリを実行してみることもできますし、左側のサイドバーにあるDocsではどのようなQueryやMutationが使えるかの仕様も確認することができます。
試しに以下のようにサイドバーのExplorerからcreateContact
Mutationを作成、実行してみました。
Prisma Studioでデータベースの内容を確認すると、レコードが作られていました。
Contactデータの登録
サーバー側の実装が完了したので、いよいよクライアント側の実装を行います。
ContactPageを編集して、createContact
Mutationのリクエストを投げるようにします。
// 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は以上となります。