Saving Data
Add a Contact Model
新しいデータベーステーブルを追加してみましょう。 api/db/schema.prisma
を開いて、Postモデルの後に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())
}
フィールドをオプショナルにする(つまり、値として NULL
を許可する) には、例えば name String?
のように、データ型の後ろにクエスチョンマークを付加します。この場合、 name
の値は String
または NULL
のいずれかになります。
次に、マイグレーションを作成し、適用します:
yarn rw prisma migrate dev
これには "create contact" のような名前を付けることができます。
Create an SDL & Service
では、このテーブルにアクセスするためのGraphQLインターフェイスを作成します。この generate
コマンドはまだ使っていません( scaffold
コマンドは裏で使っていましたが):
yarn rw g sdl Contact
scaffold
コマンドと同じように、api
ディレクトリの下にいくつかの新しいファイルを作成します:
api/src/graphql/contacts.sdl.ts
: GraphQLのスキーマ定義言語でGraphQLスキーマを定義するapi/src/services/contacts/contacts.ts
: アプリのビジネスロジックを含む(関連するテストファイルも作成される)
how Redwood works with data(Redwoodはデータをどのように取り扱うのか) での議論を思い出してもらえれば、SDL ファイル内のクエリやミューテーションが、サービスで定義されたリゾルバに自動的にマップされることがわかります。したがって SDL ファイルを生成したときにサービスファイルも作成されるのは、依存関係があるからです。
api/src/graphql/contacts.sdl.ts
を開くと、Post scaffold で作成したのと同じ Query 型と Mutation 型が Contact 用に定義されているのがわかります。 Contact
、 CreateContactInput
、 UpdateContactInput
、そして contacts
と contact
を含む Query
型、さらに createContact
、 updateContact
、 deleteContact
を含む Mutation
型が定義されています。
- JavaScript
- TypeScript
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
}
`
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
}
`
Query
型と Mutation
型の後にある @requireAuth
の文字列は schema directive で、この GraphQL クエリにアクセスするにはユーザ認証が必要であることを示しています。認証を追加するまでは @requireAuth
(の背後で動作する関数)は常に true
を返すので、ログインしていようがいまいが、誰でもクエリを実行することができます。
CreateContactInput
と UpdateContactInput
とは何でしょうか?Redwood はミューテーションにおいて設定可能なフィールドを一つ一つ列挙するのではなく、GraphQL が推奨する Input Types を使います。 schema.prisma
で必要なフィールドは CreateContactInput
でも必要ですが (それがないと有効なレコードを作成できません)、 UpdateContactInput
では必須のフィールドはありません。これは、1つのフィールドのみ、または2つのフィールド、あるいはすべてのフィールドを更新したい場合があるからです。別の方法は、更新したいフィールドの組み合わせごとに、別々の Input 型を作成することです。私たちは、更新用の Input 型を1つだけ持つことが、開発者のエクスペリエンスを最適化するための良い妥協点だと考えています。
Redwood はコードが id
や createdAt
という名前のフィールドに値を設定しようとしないことを想定しているので、これらを Input 型から外しています。しかし、もしデータベースがこれらの値を手動で設定できるようになっていれば、 CreateContactInput
や UpdateContactInput
を更新してこれらを追加できます。
DB のカラムはすべて schema.prisma
ファイルで必須とされていたため、GraphQL Types ではデータ型に !
サフィックスをつけて必須としてマークされています(例: name: String!
)。
GraphQL の SDL 構文では、フィールドが必須で ある 場合、末尾の !
が必要です。覚えておいてください: schema.prisma
の構文では、フィールドが必須で ない 場合、末尾の ?
文字が必要です。
Side Quest: How Redwood Deals with Data で説明したように、SDL ファイルには明示的なリゾルバが定義されていません。Redwood はシンプルな命名規則に従っています: sdl
ファイル( api/src/graphql/contacts.sdl.ts
)の Query
型と Mutation
型にリストされている各フィールドは、services
ファイル( api/src/services/contacts/contacts.ts
)の同じ名前を持つ関数と対応づけられています。
Psssstttt ちょっとした秘密を教えましょう:単純な読み取り専用の SDL が必要なだけなら、次のように SDL ジェネレータにフラグを渡すことで create/update/delete ミューテーションの作成を省略することができます:
yarn rw g sdl Contact --no-crud
すべてを返す contacts
型しか生成されません。
お問い合わせページでは createContact
だけが必要です。 createContact
は変数 input
をひとつだけ受け取ります。 input
は CreateContactInput
に期待されるもの、すなわち { name, email, message }
に適合するオブジェクトです。このミューテーションは誰でもアクセスできるようにする必要がありますので、 @requireAuth
を @skipAuth
に変更する必要があります。これは認証が必要で なく 、誰でも匿名でメッセージを送ることができるようにする、というものです。
それぞれの Query
と Mutation
には少なくとも1つのスキーマディレクティブが必要で、さもないとエラーになることに注意してください: Redwood は "secure by default" という考え方を取り入れています。つまり、アクセスを防ぐために特別なことをしなくても、あなたのアプリケーションを安全に保とうとするのです。この場合、誤ってユーザのデータをインターネットに公開してしまうよりも、エラーを投げる方がずっと安全です!
偶然にも、デフォルトのスキーマディレクティブである @requireAuth
は、まさにすべてのお問い合わせを返す contacts
クエリに必要なもので -- 私たちブログのオーナーだけがそれらをすべて読むことができるアクセス権を持っているはずです。
メッセージの更新や削除を誰にもさせないようにするため、 これらのフィールドを完全に削除することができます。変更後の SDL ファイルは以下のようになります:
- JavaScript
- TypeScript
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!
}
type Mutation {
createContact(input: CreateContactInput!): Contact! @skipAuth
}
`
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! @skipAuth
}
`
SDL ファイルについては以上です。次はサービスを見てみましょう:
- JavaScript
- TypeScript
import { db } from 'src/lib/db'
export const contacts = () => {
return db.contact.findMany()
}
export const contact = ({ id }) => {
return db.contact.findUnique({
where: { id },
})
}
export const createContact = ({ input }) => {
return db.contact.create({
data: input,
})
}
export const updateContact = ({ id, input }) => {
return db.contact.update({
data: input,
where: { id },
})
}
export const deleteContact = ({ id }) => {
return db.contact.delete({
where: { id },
})
}
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 },
})
}
とてもシンプルですね。ここで createContact()
関数が input
引数を想定し、 create()
呼び出しの中でそれをPrismaに渡すだけであることがわかるでしょう。
ここで updateContact
と deleteContact
を削除することもできますが、アクセス可能な GraphQL フィールドがなくなったため、いずれにしてもクライアントからは使用できなくなりました。
これをUIに繋ぎこむ前に、yarn redwood dev
を実行するだけで得られる気の利いたGUIを見てみましょう。
GraphQL Playground
実装の道を進みすぎてから何かが欠けていることに気づく前に、 "生の" API呼び出しで実験することは良いことです。APIレイヤかWebレイヤのどちらかに typo (タイプミス)がありますか?APIレイヤにだけアクセスして調べてみましょう。
yarn redwood dev
(または yarn rw dev
)で開発を開始したとき、実は同時に2つ目のプロセスを起動していました。ブラウザで新しいタブを開いて、http://localhost:8911/graphql にアクセスしてください。これは GraphQL Yoga の GraphiQL で、GraphQL API 用の Web ベースの GUI です:
まだあまりエキサイティングではありませんが、右端の "Docs" タブを選択し、query: Query
をクリックします
これは、SDL ファイルで定義された完全なスキーマです!プレイグラウンドはこれらの定義を取り込み、左側にオートコンプリートのヒントを表示するので、イチからクエリを作成するのに役立ちます。データベース内のすべてのブログ記事の ID を取得してみましょう;左側にクエリを入力し、 "Play" ボタンをクリックすると実行されます:
GraphQL Playgroundは、APIの実験や、期待通りの動作をしないクエリやミューテーションに遭遇した時のトラブルシューティングに最適な方法です。
Creating a Contact
GraphQL ミューテーションはバックエンドで実行する準備ができているので、あとはフロントエンドから呼び出すだけです。フォームに関連するものはすべて ContactPage
にあるので、そこでミューテーションを呼び出します。まず、ミューテーションを定数として定義し、後で呼び出すことにします(これはコンポーネントの外側で、 import
ステートメントの直後に定義することができます):
- JavaScript
- TypeScript
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
import { MetaTags } from '@redwoodjs/web'
import {
FieldError,
Form,
Label,
TextField,
TextAreaField,
Submit,
SubmitHandler,
} from '@redwoodjs/forms'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
interface FormValues {
name: string
email: string
message: string
}
const ContactPage = () => {
const onSubmit: SubmitHandler<FormValues> = (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
Contacts SDL で定義した createContact
ミューテーションを参照し、 input
オブジェクトを渡します。このオブジェクトには、実際の名前、メールアドレス、メッセージの値が入ります。
次に、Redwoodが提供する useMutation
フックを呼び出し、準備ができたときにミューテーションを実行できるようにします( import
を忘れずに):
- JavaScript
- TypeScript
import { MetaTags, useMutation } 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 [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
import { MetaTags, useMutation } from '@redwoodjs/web'
import {
FieldError,
Form,
Label,
TextField,
TextAreaField,
Submit,
SubmitHandler,
} from '@redwoodjs/forms'
import {
CreateContactMutation,
CreateContactMutationVariables,
} from 'types/graphql'
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) => {
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
はミューテーションを呼び出す関数で、 variables
キーを持つオブジェクトを受け取り、 input
キーを持つ別のオブジェクトを格納します。例えば、次のように呼び出すことができます:
create({
variables: {
input: {
name: 'Rob',
email: 'rob@redwoodjs.com',
message: 'I love Redwood!',
},
},
})
思い出してほしいのですが、 <Form>
はすべてのフィールドを、フィールド名をキーとするオブジェクトで提供してくれます。つまり、 onSubmit
で受け取る data
オブジェクトは、 input
に必要な適切なフォーマットになっています!
つまり、 onSubmit
関数を更新して、受け取ったデータでミューテーションを呼び出すことができるのです:
- JavaScript
- TypeScript
import { MetaTags, useMutation } 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 [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
import { MetaTags, useMutation } from '@redwoodjs/web'
import {
FieldError,
Form,
Label,
TextField,
TextAreaField,
Submit,
SubmitHandler,
} from '@redwoodjs/forms'
import {
CreateContactMutation,
CreateContactMutationVariables,
} from 'types/graphql'
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 (
<>
<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
フォームに入力して送信してみてください。データベースに新しいContactが追加されているはずです! Prisma Studio や GraphQL Playground で確認することができます:
Remember: 私たちはまだ認証を追加していないので、誰かがログインしているという概念は今は無意味です。新しいアプリケーションでイライラするようなエラーを防ぐために、 @requireAuth
ディレクティブは認証システムをセットアップするまでは単に true
を返します。その時点で、ディレクティブはユーザがログインしているかどうかを判断するための実際のロジックを使用し、それに応じて動作するようになります。
Improving the Contact Form
お問い合わせフォームは正常に動作しますが、現在いくつかの課題があります:
- 送信ボタンを複数回クリックすると、複数回送信される
- 投稿が成功したかどうか、ユーザにはわからない
- サーバでエラーが発生した場合、ユーザに通知する方法がない
これらの課題を解決しましょう。
Disable Save on Loading
フックの useMutation
は、それを呼び出す関数と一緒にさらにいくつかの要素を返します。これらは、返される配列の2番目の要素としてデストラクトすることができます。気になるのは loading
と error
の2つです:
- JavaScript
- TypeScript
// ...
const ContactPage = () => {
const [create, { loading, error }] = useMutation(CREATE_CONTACT)
const onSubmit = (data) => {
create({ variables: { input: data } })
}
return (...)
}
// ...
// ...
const ContactPage = () => {
const [create, { loading, error }] = useMutation<
CreateContactMutation,
CreateContactMutationVariables
>(CREATE_CONTACT)
const onSubmit: SubmitHandler<FormValues> = (data) => {
create({ variables: { input: data } })
}
return (...)
}
// ...
これで loading
を見ることで、データベースの呼び出しがまだ進行中かどうかを知ることができます。多重投稿の問題を簡単に解決するには、応答がまだ進行中の場合は送信ボタンを無効にすることです。 "Save" ボタンの disabled
属性に loading
の値を設定すればよいのです:
- JavaScript
- TypeScript
return (
// ...
<Submit disabled={loading}>Save</Submit>
// ...
)
return (
// ...
<Submit disabled={loading}>Save</Submit>
// ...
)
送信が速いので開発中は違いがわかりにくいかもしれませんが、ChromeのWebインスペクタのNetworkタブでネットワークのスロットリングを有効にすれば、遅い接続をシミュレートすることができます:
応答を待つ間、 "Save" ボタンが1〜2秒無効になるのがわかります。
Notification on Save
次に、送信が成功したことをユーザに知らせる通知を表示しましょう。Redwoodには react-hot-toast があり、ページ上にポップアップ通知を素早く表示することができます。
useMutation
は、第二引数としてオプションオブジェクトを受け取ります。オプションの1つはコールバック関数の onCompleted
で、これはミューテーションが正常に完了したときに呼び出されます。このコールバックを使って toast()
関数を呼び出すと、 <Toaster> コンポーネントに表示するメッセージが追加されます。
useMutation
に onCompleted
コールバックを追加し、 <Toaster> コンポーネントを、 return
の <Form> の直前に追加してください:
- JavaScript
- TypeScript
import { MetaTags, useMutation } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
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 [create, { loading, error }] = useMutation(CREATE_CONTACT, {
onCompleted: () => {
toast.success('Thank you for your submission!')
},
})
const onSubmit = (data) => {
create({ variables: { input: data } })
}
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<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 disabled={loading}>Save</Submit>
</Form>
</>
)
}
export default ContactPage
import { MetaTags, useMutation } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import {
FieldError,
Form,
Label,
TextField,
TextAreaField,
Submit,
SubmitHandler,
} from '@redwoodjs/forms'
import {
CreateContactMutation,
CreateContactMutationVariables,
} from 'types/graphql'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
interface FormValues {
name: string
email: string
message: string
}
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 (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<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 disabled={loading}>Save</Submit>
</Form>
</>
)
}
export default ContactPage
Toast の完全なドキュメントは ここ で読むことができます。
Displaying Server Errors
次に、サーバのエラーをユーザに通知します。今までは client エラーしか表示できませんでした:フィールドがないか、フォーマットが正しくないといったものです。しかし、サーバサイドの制約がある場合 <Form>
はそれらについて知ることができませんが、それでも何か問題があったことをユーザに知らせる必要があります。
クライアント側にメールアドレスの検証を実装しましたが、シリコンバレーに見合う開発者なら誰でも クライアントを信用してはいけない ことを知っています。
たとえクライアント側の検証を回避する人がいたとしても(エリートハッカーはいつもこれをやっています)、データベースによくないデータが入らないようにするために、APIサイドにもメールアドレスの検証を追加しましょう。
なぜ、名前、メールアドレス、メッセージの存在をサーバサイドで検証する必要がないのでしょうか?なぜなら、GraphQL がすでにそれを行っているからです!SDL ファイルの Contact
型にある String!
宣言を覚えているかもしれません:これは、これらのフィールドが APIサイドに到達した時点で null
であってはならないという制約を追加するものです。もしそうであれば、GraphQL はリクエストを拒否して、クライアントにエラーを返します。
しかし、あるサービスを別のサービスの中から使い始めると、検証は行われないでしょう!GraphQLは "外部" からのリクエスト(ブラウザなど)があった場合にのみ関与します。フィールドが存在するか、正しくフォーマットされているかを本当に確認したいのであれば、サービス自体の中にバリデーションを追加する必要があります。そうすれば、誰がそのサービス関数を呼び出しても(GraphQLや他のサービス)、あなたのデータは確実にチェックされることになります。
わたしたちは自由にバリデーションを実装できる追加レイヤを設けています:名前、メールアドレス、メッセージは schema.prisma
ファイルで必須として設定されているので、データベースは null
が記録されないようにします。通常、入力の検証をデータベースのみに依存しないことをお勧めします:データのフォーマットはビジネスロジックの範疇であり、Redwoodのアプリではビジネスロジックはサービスにあります!
ビジネスロジックはサービスファイルに属するという話をしましたが、これはその好例です。入力の検証はよくあることなので、Redwood は Service Validations で再び私たちの生活を楽にしてくれています。
このcontacts
サービスの validate
関数は、 email
フィールドが実際にメールアドレスのようにフォーマットされているかどうかを検証するためのものです:
- JavaScript
- TypeScript
import { validate } from '@redwoodjs/api'
// ...
export const createContact = ({ input }) => {
validate(input.email, 'email', { email: true })
return db.contact.create({ data: input })
}
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 })
}
email
への言及が多いので、分解してみましょう:
- 第一引数はチェックしたい値。この場合、
input
にはすべてのお問い合わせデータが含まれており、email
の値がチェックしたい値 - 第二引数は
<TextField>
のname
プロパティで、ページ上のどの入力フィールドでエラーが発生したかを知ることができる - 第三引数は呼び出したい バリデーションディレクティブ を含むオブジェクト。この場合1つだけで、
email: true
は組み込みのEメールアドレスバリデータを使うことを意味する
したがって、createContact
が呼ばれると、まず入力を検証し、エラーがスローされない場合にのみ、実際のデータベースへのレコード作成に進みます。
今すぐには、サーバ上でバリデーションをテストすることすらできないでしょう。なぜなら <TextField>
の validation
propsで、入力がメールアドレスのようにフォーマットされていることをすでにチェックしているからです。一時的にこれを削除して、不正なデータがサーバに送信されるようにしましょう:
- JavaScript
- TypeScript
<TextField
name="email"
validation={{
required: true,
- pattern: {
- value: /^[^@]+@[^.]+\..+$/,
- message: 'Please enter a valid email address',
- },
}}
errorClassName="error"
/>
<TextField
name="email"
validation={{
required: true,
- pattern: {
- value: /^[^@]+@[^.]+\..+$/,
- message: 'Please enter a valid email address',
- },
}}
errorClassName="error"
/>
以前 <Form>
にはもう一つトリックがあると言ったのを覚えていますか?それがこれです!
<FormError>
コンポーネントを追加し、 useMutation
から取得した error
定数を渡し、 wrapperStyle
に少しスタイルを指定します( import
をお忘れなく)。また、<Form>
に error
を渡して、コンテキストをセットアップできるようにします:
- JavaScript
- TypeScript
import { MetaTags, useMutation } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import {
FieldError,
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
const ContactPage = () => {
const [create, { loading, error }] = useMutation(CREATE_CONTACT, {
onCompleted: () => {
toast.success('Thank you for your submission!')
},
})
const onSubmit = (data) => {
create({ variables: { input: data } })
}
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<Form onSubmit={onSubmit} config={{ mode: 'onBlur' }} error={error}>
<FormError error={error} wrapperClassName="form-error" />
<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 disabled={loading}>Save</Submit>
</Form>
</>
)
}
export default ContactPage
import { MetaTags, useMutation } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import {
FieldError,
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
SubmitHandler,
} from '@redwoodjs/forms'
import {
CreateContactMutation,
CreateContactMutationVariables,
} from 'types/graphql'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
interface FormValues {
name: string
email: string
message: string
}
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 (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<Form onSubmit={onSubmit} config={{ mode: 'onBlur' }} error={error}>
<FormError error={error} wrapperClassName="form-error" />
<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 disabled={loading}>Save</Submit>
</Form>
</>
)
}
export default ContactPage
それでは無効なメールアドレスでメッセージを送信してください:
何か問題があったという素の英語のエラーメッセージが上部に表示され、 かつ 実際の入力フィールドがインラインバリデーションのようにハイライトされます!上部のメッセージは短いフォームでは過剰かもしれませんが、フォームが複数画面に及ぶ場合は重要です;何が問題だったのかがまとめて一度にわかるので、長いフォームの中で赤枠を探す手間が省けます。しかし、上部にあるメッセージボックスを使用する必要は ありません 。 <FormError>
を削除するだけでよく、ハイライトはそのままになります。
<FormError>
にはいくつかのスタイルオプションがあり、メッセージの異なる部分に付加されます:
wrapperStyle
/wrapperClassName
: メッセージ全体のコンテナtitleStyle
/titleClassName
: "Errors prevents this form..." というタイトルlistStyle
/listClassName
: エラーのリストを格納する<ul>
listItemStyle
/listItemClassName
: 各エラーを囲むそれぞれの<li>
これは、Service Validationsでできることのほんの一部です。1度の呼び出しで複数のディレクティブを組み合わせるなど、より複雑なバリデーションを実行することができます。もし "車" を表すモデルがあり、ユーザが私たちの独占的な車のショッピングサイトにそれらを送信することができるとしたらどうでしょう?どのようにすれば、よりすぐりの電気自動車だけを入手できるでしょうか?サービスバリデーションを使えば、カスタムチェックをせずに、組み込みの validate()
呼び出しだけで、ユーザが送信できる値について非常に細かく指定することができるようになります:
- JavaScript
- TypeScript
export const createCar = ({ input }) => {
validate(input.make, 'make', {
inclusion: ['Audi', 'BMW', 'Ferrari', 'Lexus', 'Tesla'],
})
validate(input.color, 'color', {
exclusion: { in: ['Beige', 'Mauve'], message: "No one wants that color" }
})
validate(input.hasDamage, 'hasDamage', {
absence: true
})
validate(input.vin, 'vin', {
format: /[A-Z0-9]+/,
length: { equal: 17 }
})
validate(input.odometer, 'odometer', {
numericality: { positive: true, lessThanOrEqual: 10000 }
})
return db.car.create({ data: input })
}
export const createCar = ({ input }: Car) => {
validate(input.make, 'make', {
inclusion: ['Audi', 'BMW', 'Ferrari', 'Lexus', 'Tesla'],
})
validate(input.color, 'color', {
exclusion: { in: ['Beige', 'Mauve'], message: "No one wants that color" }
})
validate(input.hasDamage, 'hasDamage', {
absence: true
})
validate(input.vin, 'vin', {
format: /[A-Z0-9]+/,
length: { equal: 17 }
})
validate(input.odometer, 'odometer', {
numericality: { positive: true, lessThanOrEqual: 10000 }
})
return db.car.create({ data: input })
}
独自のバリデーションロジックを組み込んで、組み込みのバリデーションと同じようにエラーを処理させることもできます:
- JavaScript
- TypeScript
validateWith(() => {
const oneWeekAgo = new Date()
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
if (input.lastCarWashDate < oneWeekAgo) {
throw new Error("We don't accept dirty cars")
}
})
validateWith(() => {
const oneWeekAgo = new Date()
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
if (input.lastCarWashDate < oneWeekAgo) {
throw new Error("We don't accept dirty cars")
}
})
これで、ポンコツは排除できます!
One more thing...
フォームが送信された後にリダイレクトを行わないので、少なくともフォームフィールドをクリアする必要があります。そのためには React Hook Form の一部である reset()
関数にアクセスする必要がありますが、(現在使用しているような) <Form>
の基本的な使い方ではアクセスすることができません。
Redwood には useForm()
( React Hook Form 由来)というフックがあり、通常は <Form>
内で呼び出されます。フォームをリセットするには、このフックを自分自身で呼び出す必要があります。しかし useForm()
が提供する機能は Form
の中で使用する必要があります。ここでは、その方法を説明します:
まず、 useForm
をインポートします:
- JavaScript
- TypeScript
import {
FieldError,
Form,
FormError,
Label,
Submit,
TextAreaField,
TextField,
useForm,
} from '@redwoodjs/forms'
import {
FieldError,
Form,
FormError,
Label,
Submit,
TextAreaField,
TextField,
useForm,
} from '@redwoodjs/forms'
そして、コンポーネントの中で呼び出します:
- JavaScript
- TypeScript
const ContactPage = () => {
const formMethods = useForm()
//...
const ContactPage = () => {
const formMethods = useForm()
//...
最後に、 <Form>
に対して useForm()
から取得した formMethods
を使うよう指示します:
- JavaScript
- TypeScript
return (
<>
<Toaster />
<Form
onSubmit={onSubmit}
config={{ mode: 'onBlur' }}
error={error}
formMethods={formMethods}
>
// ...
return (
<>
<Toaster />
<Form
onSubmit={onSubmit}
config={{ mode: 'onBlur' }}
error={error}
formMethods={formMethods}
>
// ...
これで、 toast()
を呼び出した後に formMethods
で reset()
を呼べるようになりました:
- JavaScript
- TypeScript
// ...
const [create, { loading, error }] = useMutation(CREATE_CONTACT, {
onCompleted: () => {
toast.success('Thank you for your submission!')
formMethods.reset()
},
})
// ...
// ...
const [create, { loading, error }] = useMutation<
CreateContactMutation,
CreateContactMutationVariables
>(CREATE_CONTACT, {
onCompleted: () => {
toast.success('Thank you for your submission!')
formMethods.reset()
},
})
// ...
これで <TextField>
にメールアドレス検証を戻すことができますが、念のためサーババリデーションも残しておきましょう。
ページ全体はこちらです:
- JavaScript
- TypeScript
import { MetaTags, useMutation } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import {
FieldError,
Form,
FormError,
Label,
Submit,
TextAreaField,
TextField,
useForm,
} from '@redwoodjs/forms'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
const ContactPage = () => {
const formMethods = useForm()
const [create, { loading, error }] = useMutation(CREATE_CONTACT, {
onCompleted: () => {
toast.success('Thank you for your submission!')
formMethods.reset()
},
})
const onSubmit = (data) => {
create({ variables: { input: data } })
}
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<Form
onSubmit={onSubmit}
config={{ mode: 'onBlur' }}
error={error}
formMethods={formMethods}
>
<FormError error={error} wrapperClassName="form-error" />
<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 disabled={loading}>Save</Submit>
</Form>
</>
)
}
export default ContactPage
import { MetaTags, useMutation } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import {
FieldError,
Form,
FormError,
Label,
Submit,
SubmitHandler,
TextAreaField,
TextField,
useForm,
} from '@redwoodjs/forms'
import {
CreateContactMutation,
CreateContactMutationVariables,
} from 'types/graphql'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
interface FormValues {
name: string
email: string
message: string
}
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 (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<Form
onSubmit={onSubmit}
config={{ mode: 'onBlur' }}
error={error}
formMethods={formMethods}
>
<FormError error={error} wrapperClassName="form-error" />
<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 disabled={loading}>Save</Submit>
</Form>
</>
)
}
export default ContactPage
以上です! React Hook Form は <Form>
が公開しないたくさんの 機能 を提供します。それらの機能を利用したい場合は、自分で useForm()
を呼び出しますが、返されたオブジェクト (ここでは formMethods
と呼びます) を <Form>
に props として渡して、バリデーションやその他の機能が動作し続けるようにしなければなりません。
onBlur フォームの設定は、自分で useForm()
を呼び出すと動作しなくなることにお気づきかもしれません。これは、Redwood が裏で useForm()
を呼び出し、あなたが <Form>
に与えた config
propsを自動的に渡しているためです。Redwood はもはやあなたのために useForm()
を呼び出さないので、もし何らかのオプションを渡す必要がある場合は手動で行う必要があります:
- JavaScript
- TypeScript
const ContactPage = () => {
const formMethods = useForm({ mode: 'onBlur' })
//...
const ContactPage = () => {
const formMethods = useForm({ mode: 'onBlur' })
//...
公開サイトはかなりいい感じになってきましたね。ブログ記事を作成したり編集したりする管理機能はどうでしょうか?管理セクションのようなものに移動して、ログインの後ろに置くべきでしょう。そうすれば、URLを詮索するランダムなユーザが勝手に割引医薬品の広告を作成することができなくなります。