最近ホットなBlitzに入門します!
公式チュートリアル(アンケートサービスみたいなものを作ります)に沿って進めていきますので、困ったら公式見てください()
https://blitzjs.com/docs/tutorial
ただ、バージョンが違うと自動生成されるコードが違ったりする可能性があるので、その点は注意してください。
執筆時は、
blitz: 0.27.0
を利用していました。ちょっと公式との差分もあったので参考になる箇所があるかもしれません。
導入
まず、 npm install -g blitz
とかでBlitzをインストールします。
インストールが完了したら、プロジェクトを作成します。任意のディレクトリで、
blitz new mysite
newの後ろに書くのはプロジェクト名です。お好みでどうぞ。
enterを押すと、ファイルの生成が始まります。Railsと似たような構造になっていますね。すごい親近感。
では、ディレクトリ移動して起動してみましょう。
cd mysite blitz start
http://localhost:3000 にアクセスすると、このような画面が表示されます。
ページを触ってみる
ちょっと編集してみましょう。 app/pages/index.tsx
を下記の内容に置き換えてください。
const Index = () => ( <div> <h1>Hello, world!</h1> </div> ) export default Index
これだけでOKです。Nextのルーティングに則っているので、ルートパスで表示される内容がこれになります。
モデルの作成
チュートリアルではsqliteを利用します。接続情報は schema.prisma
に記載する形になっていますね。
今回利用するモデルは、質問を表現するQuestionと、選択肢のリストであるChoiceの2つです。
まずはQuestionの作成。Railsと同様、generateコマンドでコントローラー / ビュー / Railsのモデルに相当するCRUDが作成できます。
$ blitz generate all question text:string "choices:choice[]" ✔ Model for 'question' updated successfully: > model Question { > id Int @default(autoincrement()) @id > createdAt DateTime @default(now()) > updatedAt DateTime @updatedAt > choices Choice[] > } Now run blitz db migrate to add this model to your database CREATE app/questions/pages/questions/[questionId]/edit.tsx CREATE app/questions/pages/questions/[questionId].tsx CREATE app/questions/pages/questions/index.tsx CREATE app/questions/pages/questions/new.tsx CREATE app/questions/components/QuestionForm.tsx CREATE app/questions/queries/getQuestion.ts CREATE app/questions/queries/getQuestions.ts CREATE app/questions/mutations/createQuestion.ts CREATE app/questions/mutations/deleteQuestion.ts CREATE app/questions/mutations/updateQuestion.ts
generateの後に書いてある all は、生成するリソースの指定に使います。
- model
- queries
- mutations
- pages
の4項目のうち、どれを生成しますか?というものですね。 詳しくはこちら https://blitzjs.com/docs/cli-generate
コマンドについては公式に注意書きがあるのですが、 zshを利用している場合は "choices:choice[]" とダブルクォーテーションで囲まないとエラーになってしまいます。
続けて、Questionにぶら下がるchoiceを生成します。
$ blitz generate resource choice text "votes:int:default[0]" belongsTo:question ✔ Model for 'choice' created successfully: > model Choice { > id Int @default(autoincrement()) @id > createdAt DateTime @default(now()) > updatedAt DateTime @updatedAt > text String > votes Int @default(0) > question Question @relation(fields: [questionId], references: [id]) > questionId Int > } Now run blitz db migrate to add this model to your database CREATE app/choices/queries/getChoice.ts CREATE app/choices/queries/getChoices.ts CREATE app/choices/mutations/createChoice.ts CREATE app/choices/mutations/deleteChoice.ts CREATE app/choices/mutations/updateChoice.ts
ここまでできたらDBに反映します。マイグレーション名を聞かれた場合は適当でいいですが、ここでは init db
にしてあります。
$ blitz db migrate ✔ Name of migration … init db
サーバに接続して触ってみましょう。
$ blitz console ⚡️ > db.question.create({data: {text: "What's new?"}}) ⚡️ > await db.question.findMany() [ { id: 1, createdAt: 2020-12-08T07:36:00.216Z, updatedAt: 2020-12-08T07:36:00.216Z, text: "What's new?" } ]
直感的な操作ができて良いですね。prismaすごい。
Questionの削除について
prismaが子モデルの削除までサポートしてないので、 deleteQuestion
のmutationをちょっと触っておきましょうという記述が公式にあります。
具体的には、questionを削除する前に関連する選択肢の削除処理を挟んでおきます。
app/questions/mutations/deleteQuestion.ts
export default async function deleteQuestion({where}: DeleteQuestionInput, ctx: Ctx) { ctx.session.authorize() await db.choice.deleteMany({where: {question: {id: where.id}}}) const question = await db.question.delete({where}) return question }
これでサーバ側は準備完了なので、フロント側触っていきましょう。
フロント
一覧
app/questions/pages/questions/index.tsx
を触ります。
公式のチュートリアル記載の下記コードだと動かなかったです。
export const QuestionsList = () => { const [questions] = useQuery(getQuestions, {orderBy: {id: "desc"}}) return ( <ul> {questions.map((question) => ( <li key={question.id}> <Link href="/questions/[questionId]" as={`/questions/${question.id}`}> <a>{question.name}</a> </Link> </li> ))} </ul> ) }
ここに書いてない内容として、まず useQuery
をimpotしないと動きません。頭に書いておきましょう。
たぶん既にLinkとかBlitzPageとかをblitzからimportしているはずなので、そこにuseQueryを併記する形でOKです。
import { useQuery } from "blitz"
そして僕の環境では questions.map
が落ちました。useQueryに渡している getQuestions
を確認してみます。generateコマンドで自動生成されたものですね。
app/questions/queries/getQuestions.ts
import { Ctx } from "blitz" import db, { Prisma } from "db" type GetQuestionsInput = Pick<Prisma.FindManyQuestionArgs, "where" | "orderBy" | "skip" | "take"> export default async function getQuestions( { where, orderBy, skip = 0, take }: GetQuestionsInput, ctx: Ctx ) { ctx.session.authorize() const questions = await db.question.findMany({ where, orderBy, take, skip, }) const count = await db.question.count() const hasMore = typeof take === "number" ? skip + take < count : false const nextPage = hasMore ? { take, skip: skip + take! } : null return { questions, nextPage, hasMore, count, } }
returnされているのはこいつらですね。
return { questions, nextPage, hasMore, count, }
なので、indexのquestionsに入ってるのはObjectだということになります。よくないですね。
受け取るのをquestionsにするか、適当な名前で受け取ってquestionsを見るかすればOKです。
export const QuestionsList = () => { const [{questions}] = useQuery(getQuestions, { orderBy: { id: "desc" } }) // もしくは [data] return ( <ul> {questions.map((question) => ( // もしくは {data.questions.map( ~
としましょう。全体像はこんな感じです。
app/questions/pages/questions/index
import { Suspense } from "react" import Layout from "app/layouts/Layout" import { Link, useQuery, BlitzPage } from "blitz" import getQuestions from "app/questions/queries/getQuestions" export const QuestionsList = () => { const [data] = useQuery(getQuestions, { orderBy: { id: "desc" } }) return ( <ul> {data.questions.map((question) => ( <li key={question.id}> <Link href="/questions/[questionId]" as={`/questions/${question.id}`}> <a>{question.text}</a> </Link> </li> ))} </ul> ) } const QuestionsPage: BlitzPage = () => { return ( <div> <p> <Link href="/questions/new"> <a>Create Question</a> </Link> </p> <Suspense fallback={<div>Loading...</div>}> <QuestionsList /> </Suspense> </div> ) } QuestionsPage.getLayout = (page) => <Layout title={"Questions"}>{page}</Layout> export default QuestionsPage
このページは app/questions/pages/questions/index
なので、 http://localhost:3000/questions にいけば見れます。トップにこのページへのリンクを置いておきましょうか。
pages/index.tsx
import { Link } from "blitz" const Index = () => ( <div> <h1>Hello, world!</h1> <Link href="/questions">質問一覧</Link> </div> ) export default Index
新規作成/編集
自動生成されたコードでquestionの作成・編集が実装されているのですが、存在しない name
というカラムを操作しようとしているので変更しておきます。
app/questions/pages/questions/new.tsx
return ( <div> <h1>Create New Question</h1> <QuestionForm initialValues={{}} onSubmit={async () => { try { // 以下変更 const question = await createQuestionMutation({ data: {text: "Do you love Blitz?", choices: {create: [{text: "Yes!"}]}}, }) alert("Success!" + JSON.stringify(question)) router.push(`/questions/${question.id}`)
app/questions/pages/questions/[questionId]/edit.tsx
return ( <div> <h1>Edit Question {question.id}</h1> <pre>{JSON.stringify(question)}</pre> <QuestionForm initialValues={question} onSubmit={async () => { try { // 以下変更 const updated = await updateQuestionMutation({ where: {id: question.id}, data: {text: "Do you really love Blitz?"}, })
これで一覧・詳細・作成・編集が動くようになっているはずです。
プロジェクト生成時に勝手に実装されるアカウント作成・ログインを使ってログイン完了した上で、このあたり触ってみましょう。認証は app/auth 以下で実装されています。
アンケートサービス仕上げ
質問作成時に、選択肢も登録できるようにします。
フォームの設置 子モデルのcreate処理
QuestionForm.tsx
import React from "react" type QuestionFormProps = { initialValues: any onSubmit: React.FormEventHandler<HTMLFormElement> } const QuestionForm = ({ initialValues, onSubmit }: QuestionFormProps) => { return ( <form onSubmit={(event) => { event.preventDefault() onSubmit(event) }} > <input placeholder="Name" /> <input placeholder="Choice 1" /> <input placeholder="Choice 2" /> <input placeholder="Choice 3" /> <button>Submit</button> </form> ) } export default QuestionForm
更新時に入力内容を送信するよう改修
app/questions/pages/questions/new.tsx
import Layout from "app/layouts/Layout" import { Link, useRouter, useMutation, BlitzPage } from "blitz" import createQuestion from "app/questions/mutations/createQuestion" import QuestionForm from "app/questions/components/QuestionForm" const NewQuestionPage: BlitzPage = () => { const router = useRouter() const [createQuestionMutation] = useMutation(createQuestion) return ( <div> <h1>Create New Question</h1> <QuestionForm initialValues={{}} onSubmit={async (event) => { try { // 主にこのあたりを変更 const question = await createQuestionMutation({ data: { text: event.target[0].value, choices: { create: [ { text: event.target[1].value }, { text: event.target[2].value }, { text: event.target[3].value }, ], }, }, }) alert("Success!" + JSON.stringify(question)) router.push("/questions/[questionId]", `/questions/${question.id}`) } catch (error) { alert("Error creating question " + JSON.stringify(error, null, 2)) } }} /> <p> <Link href="/questions"> <a>Questions</a> </Link> </p> </div> ) } NewQuestionPage.getLayout = (page) => <Layout title={"Create New Question"}>{page}</Layout> export default NewQuestionPage
詳細画面で選択肢も表示するようにする 関連の取得処理
現状ではquestionの情報しか取得できないので、questionに紐づく選択肢も取得できるようにqueryを改修しましょう。
getQuestions.ts
import { Ctx } from "blitz" import db, { Prisma } from "db" type GetQuestionsInput = Pick<Prisma.FindManyQuestionArgs, "where" | "orderBy" | "skip" | "take"> export default async function getQuestions( { where, orderBy, skip = 0, take }: GetQuestionsInput, ctx: Ctx ) { ctx.session.authorize() const questions = await db.question.findMany({ where, orderBy, take, skip, include: {choices: true}, // 追加 }) const count = await db.question.count() const hasMore = typeof take === "number" ? skip + take < count : false const nextPage = hasMore ? { take, skip: skip + take! } : null return { questions, nextPage, hasMore, count, } }
getQuestion.ts
import { Ctx, NotFoundError } from "blitz" import db, { Prisma } from "db" type GetQuestionInput = Pick<Prisma.FindFirstQuestionArgs, "where"> export default async function getQuestion({ where }: GetQuestionInput, ctx: Ctx) { ctx.session.authorize() const question = await db.question.findFirst({ where, include: {choices: true} }) // includeを追加 if (!question) throw new NotFoundError() return question }
これで、choiceを含んだ状態で取得できるようになりました。question詳細ページで表示しましょう。
[questionId].tsx
return ( <div> <h1>{question.text}</h1> <ul> {question.choices.map((choice) => { return ( <li> {choice.text} - {choice.votes} votes </li> ) })} </ul>
投票機能の実装 更新処理
questionに対してアクションできるようにしていきます。選択肢の横にボタンを設置して、それをクリックするとカウントされるような形です。
[questionId].tsx
// 追加 import updateChoice from "app/choices/mutations/updateChoice" export const Question = () => { const router = useRouter() const questionId = useParam("questionId", "number") // refetchを追加 const [question, { refetch }] = useQuery(getQuestion, { where: { id: questionId } }) const [deleteQuestionMutation] = useMutation(deleteQuestion) // 更新のmutationを追加 const [updateChoiceMutation] = useMutation(updateChoice) // 更新の処理を追加 const handleVote = async (id, votes) => { try { const updated = await updateChoiceMutation({ where: { id }, data: { votes: votes + 1 }, }) refetch() } catch (error) { alert("Error creating question " + JSON.stringify(error, null, 2)) } } // ボタン押下で更新処理を呼びだすように return ( <div> <h1>{question.text}</h1> <ul> {question.choices.map((choice) => { return ( <li> {choice.text} - {choice.votes} votes <button onClick={() => handleVote(choice.id, choice.votes)}>Vote</button> </li> ) })} </ul>
ボタン押下時に更新処理が走り、完了後に最新の状態を取得することで投票数が同期されるようになっています。
ということで、完成形はこちら!
以上
チュートリアルを一通りこなしてみました。ハマりポイントは公式の記載と自動生成コードの差分くらいですかね。短いスパンでアップデートされていく時期だと思うので、ざらにありそうな気がします。
railsコマンドでいうところのdestroyコマンドがない(?)とか、db:rollbackもない…(?)(僕が見つけられてないだけ説もある)あたりがちょっと痒かったですが、ゆくゆくは実装されるんでしょうか。
とまあRailsを普段触っている身としてはかなり親近感があってかつ直感的に利用できる機能が多いです。全部jsで完結できるのもまあいいっちゃいいですね。個人的にはサーバは全然Railsでもいいんですが。
これから成長していくのが楽しみです。個人開発でも使おうと思います。
generateやmigrateで何が起きるのか & recipeを使ってtailwindを導入する記事も書いたのであわせてどうぞ。