Closed39

Blitz.jsの公式チュートリアルをやってみる

higakijinhigakijin

Blitz.jsを触ってみる。
理由

  • フロントエンドだけでなく、バックエンドもTypescriptで開発したいと思ったため。
  • redwoodjsは権限周りが理解できず、挫折。

触りということで、公式チュートリアルに従って投票アプリを開発してみる。

higakijinhigakijin

$ blitz -v
Blitz version: 2.0.0-beta.20 (global)
Blitz version: 2.0.0-beta.20 (local)
macOS Ventura | darwin-arm64 | Node: v19.3.0

higakijinhigakijin

アプリの作成コマンド

$ blitz new firstBlitzApp
$ cd firstBlitzApp

higakijinhigakijin

$ blitz dev

でサーバー起動。

ログイン機能ついているのか!
めちゃくちゃ便利だな。
(Next.jsは自分でNextAuth入れなきゃだからね...)

higakijinhigakijin

最初のページを書く

src/pages/index.tsx
// 56行目
const Home: BlitzPage = () => {
  return (
    <div>
      <h1>Hello, world!</h1>
      <Suspense fallback="Loading...">
        <UserInfo />
      </Suspense>
    </div>
  )
}
higakijinhigakijin

dbはデフォルトでsqliteが使われている模様。
postgresqlに変えたければここを見ろとのこと。

db/schema.prismaを変更して、環境変数をセットして、といった感じか。

higakijinhigakijin

Scaffolding code for our models

questionモデルとchoiseモデルを作成していく方針。

カラムは、

  • questionモデル:textカラムとchoisesカラム(外部キー)
  • choiseモデル :textカラム

question : choise = 1 : 多 ってことか。
シンプルで助かる

higakijinhigakijin

まずはquestionモデルの作成から

$ blitz generate all question text:string

「all」ってなんだ?

higakijinhigakijin

The generate command with a type of all generates a model and queries, mutation and page files. See the Blitz generate page for a list of available type options.

あ、書いてあった笑

higakijinhigakijin

次にchoiseモデルの作成。

$ blitz generate resource choice text votes:int:default=0 belongsTo:question

choiseモデルは「pages」を作成する必要がないから、allではなくresourceを使うんか。
この辺の使い分けが慣れるまで難しそう。

prisma migrate dev は falseにする。

higakijinhigakijin
schema.prisma
model Question {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  text      String
+ choices   Choice[]
}

$ blitz prisma migrate dev

higakijinhigakijin

ページの修正をしていく

questionモデルにnameカラムは存在しないので、textカラムを代わりに表示させる

src/pages/questions/index.tsx
export const QuestionsList = () => {
  const router = useRouter();
  const page = Number(router.query.page) || 0;
  const [{ questions, hasMore }] = usePaginatedQuery(getQuestions, {
    orderBy: { id: "asc" },
    skip: ITEMS_PER_PAGE * page,
    take: ITEMS_PER_PAGE,
  });

  const goToPreviousPage = () => router.push({ query: { page: page - 1 } });
  const goToNextPage = () => router.push({ query: { page: page + 1 } });

  return (
    <div>
      <ul>
        {questions.map((question) => (
          <li key={question.id}>
            <Link href={Routes.ShowQuestionPage({ questionId: question.id })}>
-              <a>{question.name}</a>
+              <a>{question.text}</a>
            </Link>
          </li>
        ))}
      </ul>

      <button disabled={page === 0} onClick={goToPreviousPage}>
        Previous
      </button>
      <button disabled={!hasMore} onClick={goToNextPage}>
        Next
      </button>
    </div>
  );
};
src/questions/components/QuestionForm.tsx
export function QuestionForm<S extends z.ZodType<any, any>>(
  props: FormProps<S>
) {
  return (
    <Form<S> {...props}>
-     <LabeledTextField name="name" label="Name" placeholder="Name" />
+     <LabeledTextField name="text" label="Text" placeholder="Text" />
    </Form>
  );
}

↑このコンポーネントどこで使われているか全くわからん。。。

src/questions/mutations/createQuestion.ts
const CreateQuestion = z.object({
-  name: z.string(),
+  text: z.string(),
});
src/questions/mutations/updateQuestion.ts
const UpdateQuestion = z.object({
  id: z.number(),
-  name: z.string(),
+  text: z.string(),
});
src/choices/mutations/updateChoice.ts
const UpdateChoice = z.object({
  id: z.number(),
-  name: z.string(),
+  text: z.string(),
});

higakijinhigakijin

questionの削除時に、関連するchoiceを削除

src/questions/mutations/deleteQuestion.ts
export default resolver.pipe(
  resolver.zod(DeleteQuestion),
  resolver.authorize(),
  async ({ id }) => {
+   await db.choice.deleteMany({where: {questionId: id}})
    // TODO: in multi-tenant app, you must add validation to ensure correct tenant
    const question = await db.question.deleteMany({ where: { id } });

    return question;
  }
);
higakijinhigakijin

createChoice.ts は使わないので削除。
これでyarn tscを実行したときにエラーが起きずにDoneする!

blitz devでサーバーを起動し、/questionsに移動して質問を作成してみよう。

higakijinhigakijin

Adding choices to the question form

The next thing we’ll do is add choices to our question form.

なるほど。
思ってたこと全部やってくれるなこいつ(褒め言葉)。

higakijinhigakijin

questionの作成フォームに、choiceに関するフォームを追加

src/questions/components/QuestionForm.tsx
export function QuestionForm<S extends z.ZodType<any, any>>(
  props: FormProps<S>
) {
  return (
    <Form<S> {...props}>
      <LabeledTextField name="text" label="Text" placeholder="Text" />
+     <LabeledTextField name="choices.0.text" label="Choice 1" />
+     <LabeledTextField name="choices.1.text" label="Choice 2" />
+     <LabeledTextField name="choices.2.text" label="Choice 3" />
    </Form>
  );
}

なんかlabelもnameも見慣れない書き方をしているような...?

higakijinhigakijin

mutationも変更する。

src/questions/mutations/createQuestion.ts
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { z } from "zod"

export const CreateQuestion = z.object({
  text: z.string(),
  choices: z.array(z.object({ text: z.string() })),
})

export default resolver.pipe(resolver.zod(CreateQuestion), resolver.authorize(), async (input) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const question = await db.question.create({
    data: {
      ...input,
      choices: { create: input.choices },
    },
  })

  return question
})

やばい、この辺訳わかんなくなってきた。

  • zodって何?
  • inputの部分の構文は、スプレッド構文というやつ?
  • choices: { create: input.choices }createというキーを使うのか...。なぜ。
higakijinhigakijin

バリデーションをまとめるらしい

ファイルを作成

src/questions/validations.ts
import * as z from "zod"

export const CreateQuestion = z.object({
  text: z.string(),
  choices: z.array(z.object({ text: z.string() })),
})

こちらでインポート

src/questions/mutations/createQuestion.ts
import { resolver } from "@blitzjs/rpc"
import db from "db"
- import { z } from 'zod';
+ import { CreateQuestion } from "../validations"

- const CreateQuestion = z.object({
- 	text: z.string(),
- 	choices: z.array(z.object({ text: z.string() }))
- });
export default resolver.pipe(resolver.zod(CreateQuestion), resolver.authorize(), async (input) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const question = await db.question.create({
    data: {
      ...input,
      choices: { create: input.choices },
    },
  })

  return question
})

そうする理由

[意訳]クライアントで使えないものをわざわざcreateQuestion.tsに書くのはおかしいよね?

higakijinhigakijin
src/pages/questions/new.tsx
const NewQuestionPage = () => {
  const router = useRouter()
  const [createQuestionMutation] = useMutation(createQuestion)

  return (
    <Layout title={"Create New Question"}>
      <h1>Create New Question</h1>

      <QuestionForm
+      submitText="Create Question"
+      schema={CreateQuestion}
        initialValues={{ text: "", choices: [] }}
        onSubmit={async (values) => {
          try {
            const question = await createQuestionMutation(values)
            await router.push(Routes.ShowQuestionPage({ questionId: question.id }))
          } catch (error: any) {
            console.error(error)
            return {
              [FORM_ERROR]: error.toString(),
            }
          }
        }}
      />

      <p>
        <Link href={Routes.QuestionsPage()}>
          <a>Questions</a>
        </Link>
      </p>
    </Layout>
  )
}

NewQuestionPage.authenticate = true

export default NewQuestionPage

あー、バリデーションがクライアントでも使えるのか!
これは便利だな

higakijinhigakijin

次はquestion一覧・詳細ページに、choiceを表示していく。

src/questions/queries/getQuestion.ts
export default resolver.pipe(resolver.zod(GetQuestion), resolver.authorize(), async ({ id }) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const question = await db.question.findFirst({
     where: { id },
+    include: { choices: true },
  })

  if (!question) throw new NotFoundError()

  return question
})
src/questions/queries/getQuestions.ts
export default resolver.pipe(
  resolver.authorize(),
  async ({where, orderBy, skip = 0, take = 100}: GetQuestionsInput) => {
    const {items: questions, hasMore, nextPage, count} = await paginate({
      skip,
      take,
      count: () => db.question.count({where}),
      query: (paginateArgs) =>
        db.question.findMany({
          ...paginateArgs,
          where,
          orderBy,
+         include: {choices: true},
        }),
    })

    return {
      questions,
      nextPage,
      hasMore,
      count,
    }
  },
)

prismaらしい書き方ですな。

higakijinhigakijin
src/pages/questions/index.tsx
export const QuestionsList = () => {
  const router = useRouter();
  const page = Number(router.query.page) || 0;
  const [{ questions, hasMore }] = usePaginatedQuery(getQuestions, {
    orderBy: { id: "asc" },
    skip: ITEMS_PER_PAGE * page,
    take: ITEMS_PER_PAGE,
  });

  const goToPreviousPage = () => router.push({ query: { page: page - 1 } });
  const goToNextPage = () => router.push({ query: { page: page + 1 } });

  return (
    <div>
      <ul>
        {questions.map((question) => (
          <li key={question.id}>
            <Link href={Routes.ShowQuestionPage({ questionId: question.id })}>
              <a>{question.text}</a>
            </Link>
+          <ul>
+            { question.choices.map((choice) => (
+              <li key={choice.id}>
+                {choice.text} - {choice.votes} votes
+              </li>
+            ))}
+          </ul>
          </li>
        ))}
      </ul>

      <button disabled={page === 0} onClick={goToPreviousPage}>
        Previous
      </button>
      <button disabled={!hasMore} onClick={goToNextPage}>
        Next
      </button>
    </div>
  );
};
higakijinhigakijin

まず、質問詳細ページの改修から。

src/pages/questions/[questionId].tsx
import { Suspense } from "react"
import { Routes } from "@blitzjs/next"
import Head from "next/head"
import Link from "next/link"
import { useRouter } from "next/router"
import { useQuery, useMutation } from "@blitzjs/rpc"
import { useParam } from "@blitzjs/next"

import Layout from "src/core/layouts/Layout"
import getQuestion from "src/questions/queries/getQuestion"
import deleteQuestion from "src/questions/mutations/deleteQuestion"

export const Question = () => {
  const router = useRouter()
  const questionId = useParam("questionId", "number")
  const [deleteQuestionMutation] = useMutation(deleteQuestion)
  const [question] = useQuery(getQuestion, { id: questionId })

  return (
    <>
      <Head>
-      <title>Question {question.id}</title>
+      <title>Question {question.text}</title>
      </Head>

      <div>
-      <h1>Question {question.id}</h1>
+      <h1>Question {question.text}</h1>
-      <pre>{JSON.stringify(question, null, 2)}</pre>
+      <ul>
+        {question.choices.map((choice) => (
+          <li key={choice.id}>
+            {choice.text} - {choice.votes} votes
+          </li>
+        ))}
+      </ul>

        <Link href={Routes.EditQuestionPage({ questionId: question.id })}>
          <a>Edit</a>
        </Link>

        <button
          type="button"
          onClick={async () => {
            if (window.confirm("This will be deleted")) {
              await deleteQuestionMutation({ id: question.id })
              await router.push(Routes.QuestionsPage())
            }
          }}
          style={{ marginLeft: "0.5rem" }}
        >
          Delete
        </button>
      </div>
    </>
  )
}

const ShowQuestionPage = () => {
  return (
    <div>
      <p>
        <Link href={Routes.QuestionsPage()}>
          <a>Questions</a>
        </Link>
      </p>

      <Suspense fallback={<div>Loading...</div>}>
        <Question />
      </Suspense>
    </div>
  )
}

ShowQuestionPage.authenticate = true
ShowQuestionPage.getLayout = (page) => <Layout>{page}</Layout>

export default ShowQuestionPage
higakijinhigakijin
src/choices/mutations/updateChoice.ts
const UpdateChoice = z
  .object({
   id: z.number(),
-  text: z.string(),
  })


export default resolver.pipe(
  resolver.zod(UpdateChoice),
  resolver.authorize(),
  async ({id, ...data}) => {
   const choice = await db.choice.update({where: {id}, data})
   const choice = await db.choice.update({
    where: {id},
+   data: {votes: {increment: 1}},
   })

    return choice
  },
)
higakijinhigakijin

最終的な質問詳細ページ。

src/pages/questions/[questionId].tsx
import { Suspense } from "react"
import { Routes } from "@blitzjs/next"
import Head from "next/head"
import Link from "next/link"
import { useRouter } from "next/router"
import { useQuery, useMutation } from "@blitzjs/rpc"
import { useParam } from "@blitzjs/next"

import Layout from "src/core/layouts/Layout"
import getQuestion from "src/questions/queries/getQuestion"
import deleteQuestion from "src/questions/mutations/deleteQuestion"
import updateChoice from "src/choices/mutations/updateChoice"

export const Question = () => {
  const router = useRouter()
  const questionId = useParam("questionId", "number")
  const [deleteQuestionMutation] = useMutation(deleteQuestion)
  const [question, { refetch }] = useQuery(getQuestion, {
    id: questionId,
  })
  const [updateChoiceMutation] = useMutation(updateChoice)

  const handleVote = async (id: number) => {
    try {
      await updateChoiceMutation({ id })
      void refetch()
    } catch (error) {
      alert("Error updating choice " + JSON.stringify(error, null, 2))
    }
  }

  return (
    <>
      <Head>
        <title>Question {question.id}</title>
      </Head>

      <div>
        <h1>{question.text}</h1>
        <ul>
          {question.choices.map((choice) => (
            <li key={choice.id}>
              {choice.text} - {choice.votes} votes
              <button onClick={() => handleVote(choice.id)}>Vote</button>
            </li>
          ))}
        </ul>

        <Link href={Routes.EditQuestionPage({ questionId: question.id })}>
          <a>Edit</a>
        </Link>

        <button
          type="button"
          onClick={async () => {
            if (window.confirm("This will be deleted")) {
              await deleteQuestionMutation({ id: question.id })
              router.push(Routes.QuestionsPage())
            }
          }}
          style={{ marginLeft: "0.5rem" }}
        >
          Delete
        </button>
      </div>
    </>
  )
}

const ShowQuestionPage = () => {
  return (
    <div>
      <p>
        <Link href={Routes.QuestionsPage()}>
          <a>Questions</a>
        </Link>
      </p>

      <Suspense fallback={<div>Loading...</div>}>
        <Question />
      </Suspense>
    </div>
  )
}

ShowQuestionPage.authenticate = true
ShowQuestionPage.getLayout = (page) => <Layout>{page}</Layout>

export default ShowQuestionPage
higakijinhigakijin

最後に、既存の質問に対する選択肢を編集できるようにする。

src/questions/mutations/updateQuestion.ts
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { z } from "zod"

const UpdateQuestion = z.object({
  id: z.number(),
  text: z.string(),
  choices: z.array(z.object({ id: z.number().optional(), text: z.string() })),
})

export default resolver.pipe(
  resolver.zod(UpdateQuestion),
  resolver.authorize(),
  async ({ id, ...data }) => {
    // TODO: in multi-tenant app, you must add validation to ensure correct tenant
    const question = await db.question.update({ where: { id }, data: {
      ...data,
      choices: {
        upsert: data.choices.map((choice) => ({
          where: {id: choice.id || 0 },
          create: { text: choice.text},
          update: {text:choice.text}
        }))
      }
    } })

    return question
  }
)
higakijinhigakijin

(editページのスタイルが完全に放置されている気がするが、見なかったことにしておこう。)

higakijinhigakijin

[Blitz.jsに触れてみた感想]

  • よかった点
    • ドキュメントがわかりやすい。(ぱっと見で何しているかが分かる)
    • validationがclientのフォームでも使えるっぽい?のが良さそう(要確認)
  • 気になった点
    • ファイルが多すぎて構造を把握するのに慣れが必要
    • ドキュメントでuserモデルを用いたアプリを作成して欲しかった。より実践的な内容になると思う。

全体的にはBlitz.jsは「アリ」だと感じました!
どこかの記事でEコマースアプリを開発するチュートリアルがあった気がするので、またスクラップに掲載しながら開発したいですね!

higakijinhigakijin

初スクラップだったけど、、、いいなこれ。
自分が今何をしているのかが分かり易いな。
今後も使っていこう。

このスクラップは2022/12/28にクローズされました