Blitz.jsの公式チュートリアルをやってみる
Blitz.jsを触ってみる。
理由
- フロントエンドだけでなく、バックエンドもTypescriptで開発したいと思ったため。
- redwoodjsは権限周りが理解できず、挫折。
触りということで、公式チュートリアルに従って投票アプリを開発してみる。
$ 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
アプリの作成コマンド
$ blitz new firstBlitzApp
$ cd firstBlitzApp
$ blitz dev
でサーバー起動。
ログイン機能ついているのか!
めちゃくちゃ便利だな。
(Next.jsは自分でNextAuth入れなきゃだからね...)
最初のページを書く
// 56行目
const Home: BlitzPage = () => {
return (
<div>
<h1>Hello, world!</h1>
<Suspense fallback="Loading...">
<UserInfo />
</Suspense>
</div>
)
}
こうなった。
dbはデフォルトでsqliteが使われている模様。
postgresqlに変えたければここを見ろとのこと。
db/schema.prisma
を変更して、環境変数をセットして、といった感じか。
$ blitz prisma studio
これでprismaが見れる。
Scaffolding code for our models
questionモデルとchoiseモデルを作成していく方針。
カラムは、
- questionモデル:textカラムとchoisesカラム(外部キー)
- choiseモデル :textカラム
question : choise = 1 : 多 ってことか。
シンプルで助かる
まずはquestionモデルの作成から
$ blitz generate all question text:string
「all」ってなんだ?
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.
あ、書いてあった笑
なるほど。
次にchoiseモデルの作成。
$ blitz generate resource choice text votes:int:default=0 belongsTo:question
choiseモデルは「pages」を作成する必要がないから、allではなくresourceを使うんか。
この辺の使い分けが慣れるまで難しそう。
prisma migrate dev
は falseにする。
model Question {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
text String
+ choices Choice[]
}
$ blitz prisma migrate dev
モデル属性の生成コードを更新する
怖いよ...
ページの修正をしていく
questionモデルにnameカラムは存在しないので、textカラムを代わりに表示させる
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>
);
};
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>
);
}
↑このコンポーネントどこで使われているか全くわからん。。。
const CreateQuestion = z.object({
- name: z.string(),
+ text: z.string(),
});
const UpdateQuestion = z.object({
id: z.number(),
- name: z.string(),
+ text: z.string(),
});
const UpdateChoice = z.object({
id: z.number(),
- name: z.string(),
+ text: z.string(),
});
questionの削除時に、関連するchoiceを削除
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;
}
);
createChoice.ts
は使わないので削除。
これでyarn tsc
を実行したときにエラーが起きずにDoneする!
blitz dev
でサーバーを起動し、/questions
に移動して質問を作成してみよう。
Adding choices to the question form
The next thing we’ll do is add choices to our question form.
なるほど。
思ってたこと全部やってくれるなこいつ(褒め言葉)。
questionの作成フォームに、choiceに関するフォームを追加
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も見慣れない書き方をしているような...?
mutationも変更する。
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
というキーを使うのか...。なぜ。
バリデーションをまとめるらしい
ファイルを作成
import * as z from "zod"
export const CreateQuestion = z.object({
text: z.string(),
choices: z.array(z.object({ text: z.string() })),
})
こちらでインポート
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
に書くのはおかしいよね?
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
あー、バリデーションがクライアントでも使えるのか!
これは便利だな
追加されてますね!
次はquestion一覧・詳細ページに、choiceを表示していく。
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
})
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らしい書き方ですな。
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>
);
};
うまくいってる!
次は投票機能の実装だ!
まず、質問詳細ページの改修から。
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
いい感じ!
(やばい、集中力が切れてきた)
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
},
)
最終的な質問詳細ページ。
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
できてきた。
最後に、既存の質問に対する選択肢を編集できるようにする。
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
}
)
完成。
(editページのスタイルが完全に放置されている気がするが、見なかったことにしておこう。)
[Blitz.jsに触れてみた感想]
- よかった点
- ドキュメントがわかりやすい。(ぱっと見で何しているかが分かる)
- validationがclientのフォームでも使えるっぽい?のが良さそう(要確認)
- 気になった点
- ファイルが多すぎて構造を把握するのに慣れが必要
- ドキュメントでuserモデルを用いたアプリを作成して欲しかった。より実践的な内容になると思う。
全体的にはBlitz.jsは「アリ」だと感じました!
どこかの記事でEコマースアプリを開発するチュートリアルがあった気がするので、またスクラップに掲載しながら開発したいですね!
初スクラップだったけど、、、いいなこれ。
自分が今何をしているのかが分かり易いな。
今後も使っていこう。