Creating a Comment Form
新しいコメントフォームを収めるコンポーネントを生成し、それを構築してStorybook経由で統合し、いくつかのテストを追加してみましょう:
yarn rw g component CommentForm
そして、いま起動していない場合は、改めてStorybookを起動してください:
yarn rw storybook
Storybookに CommentForm という項目があり、すぐに始められるようになっているのがわかると思います。
Storybook
ユーザの名前とコメントを受け取る簡単なフォームを作り、ブログとマッチするようにスタイルを追加してみましょう:
- JavaScript
- TypeScript
import {
Form,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
const CommentForm = () => {
return (
<div>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full">
<Label name="name" className="block text-sm text-gray-600 uppercase">
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-xs "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-sm text-gray-600 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-xs"
validation={{ required: true }}
/>
<Submit
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm
import {
Form,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
const CommentForm = () => {
return (
<div>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full">
<Label name="name" className="block text-sm text-gray-600 uppercase">
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-xs"
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-sm text-gray-600 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-xs"
validation={{ required: true }}
/>
<Submit
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm
フォームとその入力フィールドは100%の幅に設定されていることに注意してください。繰り返しますが、フォームのレイアウトについては、入力の幅のように親が責任を負うべきことを指定すべきではありません。
フォームのレイアウトはそのフォームを親が決めるべきで、ページ上の他のコンテンツと揃えて見栄えをよくする必要があります。 ですから、フォームの幅は100%で、ページ上での実際の幅は(親が誰であろうと)親が決めることになります。
Storybookでフォームを送信してみることもできます!もし、"name" や "comment" を空白のままにした場合、送信しようとすると、それらが必須であることを示すフォーカスが当たるはずです。両方を入力して Submit をクリックしても、まだ送信処理を繋ぎ込んでいないので何も起こりません。では、それをやってみましょう。
Submitting
フォームを送信するには、サービスとGraphQLに追加した createComment
関数を使う必要があります。フォームのデータで create を呼び出せるように、フォームコンポーネントにミューテーションを追加し、 onSubmit
ハンドラを追加する必要があります。また、createComment
がエラーを返す可能性があるので、それを表示するために FormError コンポーネントを追加します:
- JavaScript
- TypeScript
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
const CREATE = gql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
id
name
body
createdAt
}
}
`
const CommentForm = () => {
const [createComment, { loading, error }] = useMutation(CREATE)
const onSubmit = (input) => {
createComment({ variables: { input } })
}
return (
<div>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full" onSubmit={onSubmit}>
<FormError
error={error}
titleClassName="font-semibold"
wrapperClassName="bg-red-100 text-red-900 text-sm p-3 rounded"
/>
<Label
name="name"
className="block text-xs font-semibold text-gray-500 uppercase"
>
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-sm "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-xs font-semibold text-gray-500 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-sm"
validation={{ required: true }}
/>
<Submit
disabled={loading}
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
SubmitHandler,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
const CREATE = gql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
id
name
body
createdAt
}
}
`
interface FormValues {
name: string
comment: string
}
const CommentForm = () => {
const [createComment, { loading, error }] = useMutation(CREATE)
const onSubmit: SubmitHandler<FormValues> = (input) => {
createComment({ variables: { input } })
}
return (
<div>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full" onSubmit={onSubmit}>
<FormError
error={error}
titleClassName="font-semibold"
wrapperClassName="bg-red-100 text-red-900 text-sm p-3 rounded"
/>
<Label
name="name"
className="block text-xs font-semibold text-gray-500 uppercase"
>
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-sm "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-xs font-semibold text-gray-500 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-sm"
validation={{ required: true }}
/>
<Submit
disabled={loading}
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm
フォームを送信しようとすると、ウェブコンソールにエラーが表示されます -- Storybookは自動的にGraphQLクエリをモックしますが、ミューテーションはモックしません。しかし、ストーリーの中でリクエストをモックし、レスポンスを手動で処理することができます:
- JavaScript
- TypeScript
import CommentForm from './CommentForm'
export const generated = () => {
mockGraphQLMutation('CreateCommentMutation', (variables, { ctx }) => {
const id = Math.floor(Math.random() * 1000)
ctx.delay(1000)
return {
createComment: {
id,
name: variables.input.name,
body: variables.input.body,
createdAt: new Date().toISOString(),
},
}
})
return <CommentForm />
}
export default { title: 'Components/CommentForm' }
import CommentForm from './CommentForm'
import type {
CreateCommentMutation,
CreateCommentMutationVariables,
} from 'types/graphql'
export const generated = () => {
mockGraphQLMutation<CreateCommentMutation, CreateCommentMutationVariables>(
'CreateCommentMutation',
(variables, { ctx }) => {
const id = Math.floor(Math.random() * 1000)
ctx.delay(1000)
return {
createComment: {
id,
name: variables.input.name,
body: variables.input.body,
createdAt: new Date().toISOString(),
},
}
}
)
return <CommentForm />
}
export default { title: 'Components/CommentForm' }
それでもエラーが発生する場合は、ブラウザでStorybookのタブを再読み込みしてみてください。
mockGraphQLMutation
を使用するには、インターセプトしたいミューテーションの名前を指定して呼び出し、インターセプトを処理し応答を返す関数を指定します。その関数に渡される引数によって、レスポンスをどのように処理するかについて柔軟性を持たせることができます。
この場合、ミューテーションに渡された変数( name
と body
) とコンテキストオブジェクト(略して ctx
) が必要で、サーバへのラウンドトリップ(処理の行き来)をシミュレートするために遅延を追加することができます。これにより Submit ボタンが1秒間無効になり、最初のコメントの保存が終わるまでは2番目のコメントを投稿できないことをテストすることができます。
今すぐフォームを試してみると、エラーはなくなっているはずです。また Submitボタンが視覚的に無効になり、1秒間の遅延の間にクリックしても何も起こらないはずです。
Adding the Form to the Blog Post
ブログ記事の既存コメントの真上は、おそらく私たちのフォームが置かれるべき場所です。では、Article
コンポーネントに CommentsCell
コンポーネントと一緒に追加するべきなのでしょうか?もしコメントの一覧を表示するところに新しいコメントを追加するフォームも含めるのであれば、CommentsCell
コンポーネント自体に追加した方が良いように感じます。しかし、これには問題があります:
もし CommentsCell
の Success
コンポーネントに CommentForm
を配置したら、まだコメントがないときはどうなるでしょうか?フォームが含まれていない Empty
コンポーネントがレンダリングされます!そのため、最初のコメントを追加することができなくなります。
CommentForm
を Empty
コンポーネントにコピーすることもできますが、このようにコードが重複していることは、気づいたらすぐにデザインについて考え直すタイミングであるという兆候です。
おそらく CommentsCell
は本当にコメントを取得して表示することだけを責務として担うべきでしょう。ユーザの入力も受け付けるというのは、主要な関心事から外れているように思います。
そこで、これらすべてのばらばらのパーツが組み合わされる掃除屋として Article
を使ってみましょう。 -- 実際のブログ記事、新しいコメントを追加するフォーム、コメントのリスト(とそれらの間の小さなマージン)です:
- JavaScript
- TypeScript
import { Link, routes } from '@redwoodjs/router'
import CommentForm from 'src/components/CommentForm'
import CommentsCell from 'src/components/CommentsCell'
const truncate = (text, length) => {
return text.substring(0, length) + '...'
}
const Article = ({ article, summary = false }) => {
return (
<article>
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(article.body, 100) : article.body}
</div>
{!summary && (
<div className="mt-12">
<CommentForm />
<div className="mt-12">
<CommentsCell />
</div>
</div>
)}
</article>
)
}
export default Article
import { Link, routes } from '@redwoodjs/router'
import CommentForm from 'src/components/CommentForm'
import CommentsCell from 'src/components/CommentsCell'
import type { Post } from 'types/graphql'
const truncate = (text: string, length: number) => {
return text.substring(0, length) + '...'
}
const Article = ({ article, summary = false }) => {
return (
<article>
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(article.body, 100) : article.body}
</div>
{!summary && (
<div className="mt-12">
<CommentForm />
<div className="mt-12">
<CommentsCell />
</div>
</div>
)}
</article>
)
}
export default Article
Storybookではいい感じです。実際のサイトではどうでしょうか?
いよいよ、究極のテストです:コメント作成!さあ、やってみましょう:
何が起こったのでしょうか?エラーメッセージの最後の方に注目してください: Field "postId" of required type "Int!" was not provided
(必須型 "Int!" のフィールド "postId" が提供されていません)。データスキーマを作成したときに、ブログ記事は postId
フィールドを介してコメントに所属すると説明しました。そしてその postId
フィールドは必須なので、GraphQLサーバはこのフィールドを含んでいないリクエストを拒否しています。私たちは name
と body
だけを送信しています。幸い Article
に渡される article
オブジェクトのおかげで、コメントしているブログ記事の ID にアクセスすることができます!
ストーリーの中でGraphQLのレスポンスを手動でモックしたので、私たちのモックは入力に関係なく常に正しいレスポンスを返してくれます!
モックデータを作成する際には、常にトレードオフがあります -- テストをとてもシンプルにするとGraphQLスタック全体に依存する必要がなくなるため、できるだけ現実に即したテストをしようと思ったら 現実に起こることをコードで書き直す ことになります。この場合 postId
を省略するのは1回限りの修正なので、省略した場合にどうなるかをシミュレートするストーリー/モック/テストを作成する作業を行う価値はないでしょう。
しかし、もし CommentForm
がアプリケーション全体で再利用されるコンポーネントになった場合、あるいは他の開発者が常に変更を加えるためコード自体が大きく変化する場合、インターフェイス (渡される props と期待される戻り値) が正確にあなたの望むものになるように時間を投資する価値はあるかもしれません。
まず、ブログ記事の ID を CommentForm
に props として渡します:
- JavaScript
- TypeScript
import { Link, routes } from '@redwoodjs/router'
import CommentsCell from 'src/components/CommentsCell'
import CommentForm from 'src/components/CommentForm'
const truncate = (text, length) => {
return text.substring(0, length) + '...'
}
const Article = ({ article, summary = false }) => {
return (
<article>
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(article.body, 100) : article.body}
</div>
{!summary && (
<div className="mt-12">
<CommentForm postId={article.id} />
<div className="mt-12">
<CommentsCell />
</div>
</div>
)}
</article>
)
}
export default Article
import { Link, routes } from '@redwoodjs/router'
import CommentsCell from 'src/components/CommentsCell'
import CommentForm from 'src/components/CommentForm'
const truncate = (text: string, length: number) => {
return text.substring(0, length) + '...'
}
const Article = ({ article, summary = false }) => {
return (
<article>
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(article.body, 100) : article.body}
</div>
{!summary && (
<div className="mt-12">
<CommentForm postId={article.id} />
<div className="mt-12">
<CommentsCell />
</div>
</div>
)}
</article>
)
}
export default Article
そして、その ID を CommentForm
の createComment
に渡される input
オブジェクトに追加します:
- JavaScript
- TypeScript
const CommentForm = ({ postId }) => {
const [createComment, { loading, error }] = useMutation(CREATE)
const onSubmit = (input) => {
createComment({ variables: { input: { postId, ...input } } })
}
return (
//...
)
}
interface Props {
postId: number
}
const CommentForm = ({ postId }: Props) => {
const [createComment, { loading, error }] = useMutation(CREATE)
const onSubmit: SubmitHandler<FormValues> = (input) => {
createComment({ variables: { input: { postId, ...input } } })
}
return (
//...
)
}
では、コメントフォームに必要事項を記入して送信してください そうすると...何も起こりませんでした!信じられないかもしれませんが、実はこれは状況が改善されたのです -- もうこれでエラーはありません! ページを再読み込みしてみるとどうでしょう?
やったー!コメントを送信すると同時に表示されればもっと良かったので、5合目って感じでしょうか?しかし、テキストボックスに私たちの名前とメッセージが入力されたまま(ページを再読み込みする前の状態)なのは、理想的ではありません。でも、どちらも修正できます。一つはGraphQLクライアント(Apollo)に新しいレコードを作成したことを伝え、できれば、このページのコメントを取得するクエリをもう一度試してもらうことです。もう一つは、新しいコメントが送信されたときに、ページからフォームを完全に削除します。
GraphQL Query Caching
Apollo の caching における complexities (複雑さ)についてはたくさん書かれていますが、簡潔にするために(そして健全にするために)、最も簡単に機能することを行うつもりで、それはセルにおいてApolloにクエリを再実行してコメントを表示することを伝えます。これは "refetching" として知られています。
ミューテーション関数(ここでは createComment
)に渡す変数と一緒に、 refetchQueries
というオプションがあります。
これは再実行する必要があるクエリの配列で、これはおそらく、いま変更したデータがクエリ結果に反映されるからです。
この例ではひとつのクエリ、CommentsCell
でエクスポートした QUERY
があります。
これを CommentForm
の先頭でインポートし(そして名前を変更し、他のコードから見てもそれが何であるかがわかるようにします)、 refetchQueries
オプションに渡します:
- JavaScript
- TypeScript
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
// ...
const CommentForm = ({ postId }) => {
const [createComment, { loading, error }] = useMutation(CREATE, {
refetchQueries: [{ query: CommentsQuery }],
})
//...
}
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
// ...
const CommentForm = ({ postId }: Props) => {
const [createComment, { loading, error }] = useMutation(CREATE, {
refetchQueries: [{ query: CommentsQuery }],
})
//...
}
これで、コメントを作成すると、すぐに表示されるようになりました!作成したコメントはコメントリストの一番下にあるので、わかりにくいかもしれません(コメントを古いものから新しいものへと時系列に読みたい場合は、この位置が適しています)。ページの最後に追加されたことに気づかないユーザのために、コメントが成功したことを知らせる小さな通知をポップアップしてみましょう。
古き良きReactのstateを利用して、コメントがフォームに投稿されたかどうかを追跡してみます。投稿されたら、コメントフォームを完全に削除して、 "Thanks for your comment" というメッセージを表示させましょう。Redwoodにはポップアップ通知を表示するための react-hot-toast が含まれているので、それを使ってユーザのコメントにお礼を言いましょう。CSSのクラスをいくつか指定するだけで、フォームを削除することができます:
- JavaScript
- TypeScript
import { useState } from 'react'
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
const CREATE = gql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
id
name
body
createdAt
}
}
`
const CommentForm = ({ postId }) => {
const [hasPosted, setHasPosted] = useState(false)
const [createComment, { loading, error }] = useMutation(CREATE, {
onCompleted: () => {
setHasPosted(true)
toast.success('Thank you for your comment!')
},
refetchQueries: [{ query: CommentsQuery }],
})
const onSubmit = (input) => {
createComment({ variables: { input: { postId, ...input } } })
}
return (
<div className={hasPosted ? 'hidden' : ''}>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full" onSubmit={onSubmit}>
<FormError
error={error}
titleClassName="font-semibold"
wrapperClassName="bg-red-100 text-red-900 text-sm p-3 rounded"
/>
<Label
name="name"
className="block text-xs font-semibold text-gray-500 uppercase"
>
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-sm "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-xs font-semibold text-gray-500 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-sm"
validation={{ required: true }}
/>
<Submit
disabled={loading}
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm
import { useState } from 'react'
import {
Form,
FormError,
Label,
TextField,
TextAreaField,
Submit,
} from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
const CREATE = gql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
id
name
body
createdAt
}
}
`
interface FormValues {
name: string
email: string
message: string
}
interface Props {
postId: number
}
const CommentForm = ({ postId }: Props) => {
const [hasPosted, setHasPosted] = useState(false)
const [createComment, { loading, error }] = useMutation(CREATE, {
onCompleted: () => {
setHasPosted(true)
toast.success('Thank you for your comment!')
},
refetchQueries: [{ query: CommentsQuery }],
})
const onSubmit: SubmitHandler<FormValues> = (input) => {
createComment({ variables: { input: { postId, ...input } } })
}
return (
<div className={hasPosted ? 'hidden' : ''}>
<h3 className="font-light text-lg text-gray-600">Leave a Comment</h3>
<Form className="mt-4 w-full" onSubmit={onSubmit}>
<FormError
error={error}
titleClassName="font-semibold"
wrapperClassName="bg-red-100 text-red-900 text-sm p-3 rounded"
/>
<Label
name="name"
className="block text-xs font-semibold text-gray-500 uppercase"
>
Name
</Label>
<TextField
name="name"
className="block w-full p-1 border rounded text-sm "
validation={{ required: true }}
/>
<Label
name="body"
className="block mt-4 text-xs font-semibold text-gray-500 uppercase"
>
Comment
</Label>
<TextAreaField
name="body"
className="block w-full p-1 border rounded h-24 text-sm"
validation={{ required: true }}
/>
<Submit
disabled={loading}
className="block mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50"
>
Submit
</Submit>
</Form>
</div>
)
}
export default CommentForm
フォームと "Leave a comment" のタイトルを完全に隠すために hidden
を使いましたが、コンポーネント自体はマウントしたままです。しかし "Thank you for your comment" の通知はどこにあるのでしょうか?メッセージを表示するためには、(react-hot-toastの) Toaster
コンポーネントをアプリ内のどこかに追加する必要があります。CommentForm
に追加することはできますが、しかし CommentForm
がマウントされていないときでも他のコードで通知したい場合はどうしたらよいでしょうか?どこにでも表示されるべき UI 要素をどこに置けばいいのでしょうか?それは BlogLayout
です!
- JavaScript
- TypeScript
import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
const BlogLayout = ({ children }) => {
const { logOut, isAuthenticated, currentUser } = useAuth()
return (
<>
<Toaster />
<header className="relative flex justify-between items-center py-4 px-8 bg-blue-700 text-white">
<h1 className="text-5xl font-semibold tracking-tight">
<Link
className="text-blue-400 hover:text-blue-100 transition duration-100"
to={routes.home()}
>
Redwood Blog
</Link>
</h1>
<nav>
<ul className="relative flex items-center font-light">
<li>
<Link
className="py-2 px-4 hover:bg-blue-600 transition duration-100 rounded"
to={routes.about()}
>
About
</Link>
</li>
<li>
<Link
className="py-2 px-4 hover:bg-blue-600 transition duration-100 rounded"
to={routes.contact()}
>
Contact
</Link>
</li>
<li>
{isAuthenticated ? (
<div>
<button type="button" onClick={logOut} className="py-2 px-4">
Logout
</button>
</div>
) : (
<Link to={routes.login()} className="py-2 px-4">
Login
</Link>
)}
</li>
</ul>
{isAuthenticated && (
<div className="absolute bottom-1 right-0 mr-12 text-xs text-blue-300">
{currentUser.email}
</div>
)}
</nav>
</header>
<main className="max-w-4xl mx-auto p-12 bg-white shadow rounded-b">
{children}
</main>
</>
)
}
export default BlogLayout
import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
type BlogLayoutProps = {
children?: React.ReactNode
}
const BlogLayout = ({ children }: BlogLayoutProps) => {
const { logOut, isAuthenticated, currentUser } = useAuth()
return (
<>
<Toaster />
<header className="relative flex justify-between items-center py-4 px-8 bg-blue-700 text-white">
<h1 className="text-5xl font-semibold tracking-tight">
<Link
className="text-blue-400 hover:text-blue-100 transition duration-100"
to={routes.home()}
>
Redwood Blog
</Link>
</h1>
<nav>
<ul className="relative flex items-center font-light">
<li>
<Link
className="py-2 px-4 hover:bg-blue-600 transition duration-100 rounded"
to={routes.about()}
>
About
</Link>
</li>
<li>
<Link
className="py-2 px-4 hover:bg-blue-600 transition duration-100 rounded"
to={routes.contact()}
>
Contact
</Link>
</li>
<li>
{isAuthenticated ? (
<div>
<button type="button" onClick={logOut} className="py-2 px-4">
Logout
</button>
</div>
) : (
<Link to={routes.login()} className="py-2 px-4">
Login
</Link>
)}
</li>
</ul>
{isAuthenticated && (
<div className="absolute bottom-1 right-0 mr-12 text-xs text-blue-300">
{currentUser.email}
</div>
)}
</nav>
</header>
<main className="max-w-4xl mx-auto p-12 bg-white shadow rounded-b">
{children}
</main>
</>
)
}
export default BlogLayout
ここでコメントを追加します:
Almost Done?
ということで、ここまでで一応終了のようです!トップページに戻って、別のブログ記事を見てみてください。 私たちの素晴らしいコーディング能力の栄誉に酔いしれましょう、そして -- OH-NO:
全てのブログ記事に同じコメントがついています! 私たちは何をしたのでしょう??
数ページ前の、 comments()
サービスは すべての コメントを返すだけだが、という伏線を覚えていますか?
それがついに回収されました:あるブログ記事のコメントを取得したとき、実はその投稿だけのコメントを取得しているわけではありません。つまり、 postId
を完全に無視して、データベースにある すべての コメントを返しているのです!古来からの原則が正しいと証明されました:コンピュータは指示したとおりにしか動かないのです。
直しましょう!
Returning Only Some Comments
一部のコメントだけを表示させるためには、フロントエンドとバックエンドの両方を変更する必要があります。バックエンドから始めて、この変更を行うために少しテスト駆動開発をしてみましょう。
Introducing the Redwood Console
Prismaの呼び出しにいくつかの引数を送信して、1つのブログ記事のコメントを要求できることを確認できれば、それが動作するかどうかを確認するためにアプリにすべてのスタック(コンポーネント/セル、GraphQL、サービス)を記述する必要はありません。
そこで、Redwood Consoleの登場です!新しいターミナルインスタンスで、次のようにしてみてください:
yarn rw console
標準的な Node コンソールが表示されますが、Redwood の内部のほとんどがすでにインポートされており、すぐに使えるようになっています!最も重要なのは、データベースがすぐに使えることです。試してみてください:
> db.comment.findMany()
[
{
id: 1,
name: 'Rob',
body: 'The first real comment!',
postId: 1,
createdAt: 2020-12-08T23:45:10.641Z
},
{
id: 2,
name: 'Tom',
body: 'Here is another comment',
postId: 1,
createdAt: 2020-12-08T23:46:10.641Z
}
]
(もちろん、すでにデータベースにあるコメントの内容によって、出力は若干異なります)
与えられた postId
のコメントのみを取得する構文を試してみましょう:
> db.comment.findMany({ where: { postId: 1 }})
[
{
id: 1,
name: 'Rob',
body: 'The first real comment!',
postId: 1,
createdAt: 2020-12-08T23:45:10.641Z
},
{
id: 2,
name: 'Tom',
body: 'Here is another comment',
postId: 1,
createdAt: 2020-12-08T23:46:10.641Z
}
]
さて、うまくいきましたが、リストはまったく同じです。それは、1つのブログ記事に対してしかコメントを追加していないからです!2番目のブログ記事に対するコメントを作成し、特定の postId
に対するコメントのみが返されるようにしましょう。
別のブログ記事の id
が必要です。ブログ記事が少なくとも2つあることを確認してください(必要であれば、管理画面から作成してください)。既存の全ブログ記事のリストを取得して、id
をコピーすればよいです:
> db.post.findMany({ select: { id: true } })
[ { id: 1 }, { id: 2 }, { id: 3 } ]
よし。ではその2つ目の投稿に対して、コンソールからコメントを作成してみましょう:
> db.comment.create({ data: { name: 'Peter', body: 'I also like leaving comments', postId: 2 } })
{
id: 3,
name: 'Peter',
body: 'I also like leaving comments',
postId: 2,
createdAt: 2020-12-08T23:47:10.641Z
}
ここで、コメントクエリをもう一度、それぞれの postId
を使って試してみましょう:
> db.comment.findMany({ where: { postId: 1 }})
[
{
id: 1,
name: 'Rob',
body: 'The first real comment!',
postId: 1,
createdAt: 2020-12-08T23:45:10.641Z
},
{
id: 2,
name: 'Tom',
body: 'Here is another comment',
postId: 1,
createdAt: 2020-12-08T23:46:10.641Z
}
]
> db.comment.findMany({ where: { postId: 2 }})
[
{
id: 3,
name: 'Peter',
body: 'I also like leaving comments',
postId: 2,
createdAt: 2020-12-08T23:45:10.641Z
},
素晴らしい!さて、構文のテストが終わったので、サービスで使ってみましょう。Ctrl-Cを2回押すか、.exit
と入力するとコンソールを終了することができます。
await
?db
を呼び出すと Promise が返されます。通常、結果をすぐに取得するためには await
する必要があります。しかし、毎回 await
を書くのはかなり面倒なので、Redwood コンソールがそれをやってくれます -- Redwoodが await
してくれるので、、あなたがする必要はありません!
Updating the Service
テストスイートを実行してみて(あるいは、すでに実行されている場合はターミナルウィンドウを覗いてみて)、すべてのテストがいまだに合格していることを確認してください。APIサイドの "lowest level" (最下層)はサービスなので、そこから始めましょう。
コードベースについて考える一つの方法は、 "top to bottom" (上から下へ)という見方です。一番上はユーザに "最も近く" ユーザが操作するもの(Reactコンポーネント)、一番下はユーザから "最も遠い" もの、ウェブアプリケーションの場合、通常はデータベースやその他のデータストア(おそらくサードパーティAPIの後ろ)だと考えられます。データベースの1つ上の階層はサービスで、これはデータベースと直接通信します。
Browser
|
React ─┐
| │
Graph QL ├─ Redwood
| │
Services ─┘
|
Database
ここに厳密なルールはありませんが、一般的にビジネスロジック(データの移動や操作を行うコード)を下に置くほど、アプリケーションの構築と保守が容易になります。Redwoodでは、ビジネスロジックをサービスに置くことを推奨しています。サービスに置くことで、データに "最も近く" 、GraphQLインターフェースの背後にあるためです。
comments サービスのテストを開き、コンソールでテストしたように comments()
関数に postId
引数を渡すよう更新してみましょう:
- JavaScript
- TypeScript
scenario('returns all comments', async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
expect(result.length).toEqual(Object.keys(scenario.comment).length)
})
scenario('returns all comments', async (scenario: StandardScenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
expect(result.length).toEqual(Object.keys(scenario.comment).length)
})
テストスイートが実行されても、すべて合格します。JavaScriptは、あなたが突然引数を渡しても気にしません(Typescriptを使用していた場合は、この時点で実際にエラーが発生します!)。TDDでは一般的に、コードを書く前にテストが失敗するように仕向けてから、テストに合格するようなコードをテスト対象に追加します。いくつか のコメントだけを返すようになったら、このテストでは何が変わるでしょうか? 返されるコメントの数はどうでしょうか?
今回使用するシナリオを見てみましょう(デフォルトでは standard()
であることを思い出してください)。
- JavaScript
- TypeScript
export const standard = defineScenario({
comment: {
jane: {
data: {
name: 'Jane Doe',
body: 'I like trees',
post: {
create: {
title: 'Redwood Leaves',
body: 'The quick brown fox jumped over the lazy dog.',
},
},
},
},
john: {
data: {
name: 'John Doe',
body: 'Hug a tree today',
post: {
create: {
title: 'Root Systems',
body: 'The five boxing wizards jump quickly.',
},
},
},
},
},
})
export const standard = defineScenario({
comment: {
jane: {
data: {
name: 'Jane Doe',
body: 'I like trees',
post: {
create: {
title: 'Redwood Leaves',
body: 'The quick brown fox jumped over the lazy dog.',
},
},
},
},
john: {
data: {
name: 'John Doe',
body: 'Hug a tree today',
post: {
create: {
title: 'Root Systems',
body: 'The five boxing wizards jump quickly.',
},
},
},
},
},
})
ここでは各シナリオがそれぞれのブログ記事に関連付けられているので、データベース内のすべてのコメントをカウントするのではなく(今のテストがそうであるように)、コメントを取得している単一のブログ記事に付けられたコメントの数だけをカウントしましょう(今は comments()
の呼び出しに postId を渡しています)。テストフォームでどのように見えるか見てみましょう:
- JavaScript
- TypeScript
import { comments, createComment } from './comments'
import { db } from 'src/lib/db'
describe('comments', () => {
scenario('returns all comments', async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
})
// ...
})
import { comments, createComment } from './comments'
import { db } from 'src/lib/db'
import type { StandardScenario } from './comments.scenarios'
describe('comments', () => {
scenario('returns all comments', async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
})
// ...
})
つまり、まずサービスから、与えられた postId
に対するすべてのコメントを取得します。次に、データベースから 実際の ブログ記事とコメントを取得します。そして、サービスから返されたコメントの数が、データベースで実際にブログ記事につけられたコメントの数と同じであることを期待します。いまのところこのテストは失敗し、理由が出力されます:
FAIL api api/src/services/comments/comments.test.js
• comments › returns all comments
expect(received).toEqual(expected) // deep equality
Expected: 1
Received: 2
( post.comments.length
から)1を受け取ることを期待しましたが、実際には( result.length
から)2を受け取りました。
再びテストする前に、実際にテストしている内容を反映させるために、テストの名前も変更しましょう:
- JavaScript
- TypeScript
scenario(
'returns all comments for a single post from the database',
async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
}
)
scenario(
'returns all comments for a single post from the database',
async (scenario: StandardScenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
}
)
では、実際の comments.js
サービスを開いて、引数として postId
を受け取り、それを findMany()
のオプションとして使うように更新します(使っていないほうの comment()
関数ではなく、必ず [s] が付いた comments()
関数を更新してください):
- JavaScript
- TypeScript
export const comments = ({ postId }) => {
return db.comment.findMany({ where: { postId } })
}
export const comments = ({
postId,
}: Required<Pick<Prisma.CommentWhereInput, 'postId'>>) => {
return db.comment.findMany({ where: { postId } })
}
これを保存すれば、テストは再び合格するはずです!
Updating GraphQL
次に、 comments
クエリに渡す postId
が必須であることを GraphQL に知らせる必要があります(現在、すべてのコメントをどこでも見ることができるビューはないので、必須にすることができます)。 comments.sdl.ts
ファイルを開いてください:
- JavaScript
- TypeScript
type Query {
comments(postId: Int!): [Comment!]! @skipAuth
}
type Query {
comments(postId: Int!): [Comment!]! @skipAuth
}
ここで、開発者モードで実際のサイトを更新してみると、コメントが表示されるはずの場所にエラーが表示されます:
セキュリティ上の理由から、ここでは内部エラーメッセージを表示しませんが、 yarn rw dev
を実行しているターミナルウィンドウをチェックすると、本当のメッセージを見ることができます:
Field "comments" argument "postId" of type "Int!" is required, but it was not provided.
そう、postId
が存在しないことに文句を言っているのです -- まさに我々が望んでいることです!
これでバックエンドの更新は完了です。あとは作成する GraphQL クエリに postId
を渡すよう CommentsCell
に指示するだけです。
Updating the Cell
まず、セル自体に postId
を取得する必要があります。CommentForm
コンポーネントに postId
props を追加して、新しいコメントをどのブログ記事につけるのか、わかるようにしたことを思い出してください。同じことを CommentsCell
でもしてみましょう。
Article
を開いてください:
- JavaScript
- TypeScript
const Article = ({ article, summary = false }) => {
return (
<article>
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(article.body, 100) : article.body}
</div>
{!summary && (
<div className="mt-12">
<CommentForm postId={article.id} />
<div className="mt-12">
<CommentsCell postId={article.id} />
</div>
</div>
)}
</article>
)
}
const Article = ({ article, summary = false }) => {
return (
<article>
<header>
<h2 className="text-xl text-blue-700 font-semibold">
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
</h2>
</header>
<div className="mt-2 text-gray-900 font-light">
{summary ? truncate(article.body, 100) : article.body}
</div>
{!summary && (
<div className="mt-12">
<CommentForm postId={article.id} />
<div className="mt-12">
<CommentsCell postId={article.id} />
</div>
</div>
)}
</article>
)
}
そして最後に、その postId
をセル内の QUERY
に渡す必要があります:
- JavaScript
- TypeScript
export const QUERY = gql`
query CommentsQuery($postId: Int!) {
comments(postId: $postId) {
id
name
body
createdAt
}
}
`
export const QUERY = gql`
query CommentsQuery($postId: Int!) {
comments(postId: $postId) {
id
name
body
createdAt
}
}
`
この魔法のような $postId
はどこから来るのでしょうか?コンポーネントを呼び出したときに props として渡したので、Redwoodは自動的にこれを提供してくれるのです!
いくつかの異なるブログ記事にアクセスしてみると、適切なブログ記事(コンソールで作成したものを含め)に関連するコメントだけが表示されるはずです。それぞれのブログ記事にそれぞれコメントを追加すれば、適切なブログ記事につくようになります:
しかし、コメントを投稿してもすぐには表示されなくなったことにお気づきでしょうか!残念です!さて、もう一つやらなければならないことがあります。コメント作成ロジックに refetchQueries
を指定したのを覚えていますか?適切なものを再取得するために、最初に存在した変数を含める必要があります。
Updating the Form Refetch
よし。これが最後の修正です。約束します!
- JavaScript
- TypeScript
const [createComment, { loading, error }] = useMutation(CREATE, {
onCompleted: () => {
setHasPosted(true)
toast.success('Thank you for your comment!')
},
refetchQueries: [{ query: CommentsQuery, variables: { postId } }],
})
const [createComment, { loading, error }] = useMutation(CREATE, {
onCompleted: () => {
setHasPosted(true)
toast.success('Thank you for your comment!')
},
refetchQueries: [{ query: CommentsQuery, variables: { postId } }],
})
さあ、コメントエンジンの完成です!私たちのブログは完全に完璧で、より良くするためにできることは全く何もありません。
それとも、何かあります?