在本教程中,我们将会指导你创建一个简易的投票系统。
我们将假设你已经 安装了 Blitz。你可以通过在终端运行以下命 令来确定 Blitz 是否已经安装或检查安装的版本号:
blitz -v
如果 Blitz 已经安装成功,你应该能看到安装的版本号。否则你会收到一条像这样 的错误提示:“command not found: blitz”。
在命令行中,cd
进入你想要创建应用的根目录文件夹后,执行以下命令:
blitz new my-blitz-app
Blitz 会在你当前的文件夹中创建一个 my-blitz-app
文件夹。你接着会收到一个
选择表单库的提示。本教程中将选择其中推荐的 React Final Form
库。
让我们看看 blitz new
命令创建了什么:
my-blitz-app
├── app/
│ ├── api/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── SignupForm.tsx
│ │ ├── mutations/
│ │ │ ├── changePassword.ts
│ │ │ ├── forgotPassword.test.ts
│ │ │ ├── forgotPassword.ts
│ │ │ ├── login.ts
│ │ │ ├── logout.ts
│ │ │ ├── resetPassword.test.ts
│ │ │ ├── resetPassword.ts
│ │ │ └── signup.ts
│ │ ├── pages/
│ │ │ ├── forgot-password.tsx
│ │ │ ├── login.tsx
│ │ │ ├── reset-password.tsx
│ │ │ └── signup.tsx
│ │ └── validations.ts
│ ├── core/
│ │ ├── components/
│ │ │ ├── Form.tsx
│ │ │ └── LabeledTextField.tsx
│ │ ├── hooks/
│ │ │ └── useCurrentUser.ts
│ │ └── layouts/
│ │ └── Layout.tsx
│ ├── pages/
│ │ ├── 404.tsx
│ │ ├── _app.tsx
│ │ ├── _document.tsx
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ └── users/
│ └── queries/
│ └── getCurrentUser.ts
├── db/
│ ├── index.ts
│ ├── schema.prisma
│ └── seeds.ts
├── integrations/
├── mailers/
│ └── forgotPasswordMailer.ts
├── public/
│ ├── favicon.ico*
│ └── logo.png
├── test/
│ ├── setup.ts
│ └── utils.tsx
├── README.md
├── babel.config.js
├── blitz.config.js
├── jest.config.js
├── package.json
├── tsconfig.json
├── types.d.ts
├── types.ts
└── yarn.lock
上述文件有:
app/
文件夹是项目中绝大多数功能的容器。你可以在这里放置任何页面或 API
路由。
app/pages/
文件夹是主要的页面文件夹。如果你使用过 Next.js 你将会立即注
意到两者的不同。在 Blitz 中,你可以有许多 pages
文件夹,它们将在构建时
合并在一起。
app/core/
文件夹是放置整个应用中使用到的通用组件、Hooks 等的主要位置。
db/
是数据库配置所在的位置。如果你正在编写模型或检查迁移情况,可以来这
里。
public/
文件夹可以让你放置任何静态资源。如果你有要在应用中使用的图像、
文件或视频等,都可以放置在其中。
.babelrc.js
、.env
等(“dotfiles 文件”)是各种 JavaScript 工具需要用
到的配置文件。
blitz.config.js
用于 Blitz 的高级自定义配置,与 next.config.js
相同
tsconfig.json
是我们推荐的 TypeScript 设置。
现在,如果你还没有在 my-blitz-app
文件夹下,确保切换到其中,并运行以下命
令:
blitz dev
你将会在命令行中看到如下输出:
✔ Compiled
Loaded env from /private/tmp/my-blitz-app/.env
warn - You have enabled experimental feature(s).
warn - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use them at your own risk.
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info - Using external babel configuration from /my-blitz-app/babel.config.js
event - compiled successfully
现在服务器已成功运行,浏览器中访问 localhost:3000。你将会看到带有 Blitz logo 的欢迎 页面。成功了!
Bliz 应用让用户登录和注册开箱即用!现在让我们来尝试一下。点击 注册 按
钮,输入任何电子邮件和密码,然后单击 创建账户 后,你将被重定向到用户主
页,并在其中可以看到你的用户 id
和 role
。
如果需要,你也可以尝试注销并重新登录。或在登录页面上单击 忘记密码,以 尝试重置密码流程。
接下来让我们创建你的第一个页面。
打开 app/pages/index.tsx
文件然后替换掉 Home
组件的所有内容为这段代码
:
//...
const Home: BlitzPage = () => {
return (
<div>
<h1>Hello, world!</h1>
<Suspense fallback="Loading...">
<UserInfo />
</Suspense>
</div>
)
}
//...
保存文件后你将会看到浏览器页面进行了更新。你可以如你所愿地添加需要的各种自 定义项, 。准备就绪后,请转到下一节。
好消息是,已经为你建立好了 SQLite 数据库!你可以在终端中运行
blitz prisma studio
来打开一个可以查看数据库数据的 Web 界面。
请注意,在开始第一个实际项目时,你可能希望使用可扩展性更高的数据库(如 PostgreSQL),以避免在将来切换数据库时产生的麻烦。有关更多信息,请参见 数据库概述 篇。目前,我们将继续使用默认的 SQLite 数据 库。
Blitz 提供了一个方便的 CLI 命令 generate
来构建样板代
码。我们将使用 generate
来创建两个模型:Question
(问题) 和 Choice
(
选择)。Question
包含问题内容和选择列表。Choice
包含选择内容、投票计数
以及相关的问题。Blitz 将为这两个模型自动生成一个 id、一个创建时间戳以及一
个最新更新的时间戳。
Question
模型有关的所有信息:blitz generate all question text:string
当出现提示框时,按 Enter 以运行 prisma migrate
,这将使用新的模型来更
新你的数据库架构。此时会要求一个名称,可以输入“add question”之类的值。
CREATE app/pages/questions/[questionId].tsx
CREATE app/pages/questions/[questionId]/edit.tsx
CREATE app/pages/questions/index.tsx
CREATE app/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
✔ Model for 'question' created in schema.prisma:
> model Question {
> id Int @default(autoincrement()) @id
> createdAt DateTime @default(now())
> updatedAt DateTime @updatedAt
> text String
> }
? Run 'prisma migrate dev' to update your database? (Y/n) › true
Environment variables loaded from .env
Prisma schema loaded from db/schema.prisma
Datasource "db": SQLite database "db.sqlite" at "file:./db.sqlite"
✔ Name of migration … add question
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20210217035805_add_question/
└─ migration.sql
✔ Generated Prisma Client (2.17.0) to ./node_modules/@prisma/client in 103ms
Everything is now in sync.
generate
命令搭配 all
类型将生成相关的模型、queries、mutation 和页面文
件。请参见 Blitz generate 页面查询更多可用的类型选项。
Choice
模型。这次我们搭配 resource
类型,因为我们不需要为 Choice
模型生成页面:
blitz generate resource choice text votes:int:default=0 belongsTo:question
如果你在执行 blitz prisma format
时遇到错误,请注意这里不需要进行数据库
迁移,因为我们还没添加 Choice
字段到 Question
模型上。 因此我们需要在
提示是否执行迁移时选择 false
:
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
✔ Model for 'choice' created in schema.prisma:
> 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
> }
? Run 'prisma migrate dev' to update your database? (Y/n) › false
Question
模型以关联到 Choice
上。打开 db/schema.prisma
并在 Question
模型中添加 choices Choice[]
。
model Question {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
text String
+ choices Choice[]
}
现在我们可以执行迁移来更新我们的数据库:
blitz prisma migrate dev
同是输入迁移的名称,比如"add choice":
Environment variables loaded from .env
Prisma schema loaded from db/schema.prisma
Datasource "db": SQLite database "db.sqlite" at "file:./db.sqlite"
✔ Name of migration … add choice
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20210412175528_add_choice/
└─ migration.sql
Your database is now in sync with your schema.
现在我们的数据库已经准备就绪,并且 Prisma 客户端也生成完毕。让我们继续入手 挑战 Prisma 客户端!
现在,让我们跳进 Blitz 交互式 Shell 中,并使用 Blitz 为你提供的 Primsa 数 据库客户端。要启动 Blitz 控制台,请使用以下命令:
blitz console
一旦你进入控制台后,浏览数据库客户端:
# No questions are in the system yet.
⚡ > await db.question.findMany()
[]
# Create a new Question:
⚡ > let q = await db.question.create({data: {text: "What's new?"}})
undefined
# See the entire object:
⚡ > q
{
id: 1,
createdAt: 2020-06-15T15:06:14.959Z,
updatedAt: 2020-06-15T15:06:14.959Z,
text: "What's new?"
}
# Or, access individual values on the object:
⚡ > q.text
"What's new?"
# Change values by using the update function:
⚡ > q = await db.question.update({where: {id: 1}, data: {text: "What's up?"}})
{
id: 1,
createdAt: 2020-06-15T15:06:14.959Z,
updatedAt: 2020-06-15T15:13:17.394Z,
text: "What's up?"
}
# db.question.findMany() now displays all the questions in the database:
⚡ > await db.question.findMany()
[
{
id: 1,
createdAt: 2020-06-15T15:06:14.959Z,
updatedAt: 2020-06-15T15:13:17.394Z,
text: "What's up?"
}
]
在再次运行该应用之前,我们需要自定义一些已生成的代码。 最终这些修复过程将 不再需要——但就目前而言,我们需要解决尚未解决的问题。
自动生成的页面,当前并未使用你在生成过程中定义的实际模型的属性。以后会支持 ,但现在,需要我们手动修复生成的页面。
进入 app/pages/questions/index.tsx
. 请注意到一个 QuestionsList
组件已
经为你生成了:
// app/pages/questions/index.tsx
export const QuestionsList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{ questions, hasMore }, { isPreviousData }] = usePaginatedQuery(
getQuestions,
{
orderBy: { id: "asc" },
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
}
)
const goToPreviousPage = () =>
router.push({ query: { page: page - 1 } })
const goToNextPage = () => {
if (!isPreviousData && hasMore) {
router.push({ query: { page: page + 1 } })
}
}
return (
<div>
<ul>
{questions.map((question) => (
<li key={question.id}>
<Link href={`/questions/${question.id}`}>
<a>{question.name}</a>
</Link>
</li>
))}
</ul>
<button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</button>
<button
disabled={isPreviousData || !hasMore}
onClick={goToNextPage}
>
Next
</button>
</div>
)
}
不过目前跑不通的!请记住我们创建的 Question
模型上面没有任何“name”字段。
要解决此问题,请替换 question.name
为 question.text
。
// app/pages/questions/index.tsx
const QuestionsList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{questions, hasMore}, {isPreviousData}] = usePaginatedQuery(
getQuestions, {
orderBy: {id: "asc"},
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
},
)
const goToPreviousPage = () => router.push({query: {page: page - 1}})
const goToNextPage = () => {
if (!isPreviousData && hasMore) {
router.push({query: {page: page + 1}})
}
}
return (
<div>
<ul>
{questions.map((question) => (
<li key={question.id}>
<Link href={`/questions/${question.id}`}>
- <a>{question.name}</a>
+ <a>{question.text}</a>
</Link>
</li>
))}
</ul>
<button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</button>
<button disabled={isPreviousData || !hasMore} onClick={goToNextPage}>
Next
</button>
</div>
)
}
接下来,我们将类似的修复方法应用于
app/questions/components/QuestionForm.tsx
中。在表单提交中,将
LabeledTextField
中 name
变为 text
。
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>
)
}
createQuestion
mutation在 app/questions/mutations/createQuestion.ts
中,我们需要更新
CreateQuestion
zod 验证模式,使用 text
而不是 name
。
// app/questions/mutations/createQuestion.ts
const CreateQuestion = z
.object({
- name: z.string(),
+ text: z.string(),
})
.nonstrict()
// ...
updateQuestion
mutation在 app/questions/mutations/updateQuestion.ts
中,我们需要更新
UpdateQuestion
zod 验证模式,使用 text
而不是 name
。
// app/questions/mutations/updateQuestion.ts
const UpdateQuestion = z
.object({
id: z.number(),
- name: z.string(),
+ text: z.string(),
})
.nonstrict()
// ...
deleteQuestion
mutationPrisma 尚不支持“级联删除”。在本教程的上下文中,这意味着它在删除 Question
时不会删除相关的 Choice
数据。我们需要临时改动生成的 deleteQuestion
mutation,以便手动做这件事。在文本编辑框中打开
app/questions/mutations/deleteQuestion.ts
并将以下内容添加到函数主体的顶
部。
await db.choice.deleteMany({ where: { questionId: id } })
最终的效果应该为:
// app/questions/mutations/deleteQuestion.ts
export default resolver.pipe(
resolver.zod(DeleteQuestion),
resolver.authorize(),
async ({id}) => {
+ await db.choice.deleteMany({where: {questionId: id}})
const question = await db.question.deleteMany({where: {id}})
return question
},
)
现在,此 mutation 将在删除问题本身之前,删除与问题相关的选择。
太棒了!现在确保你的程序正常运行。否则在你的终端中执行 blitz dev
,然后访
问 localhost:3000/questions
。尝试创建问题并编辑、删除它们。
到目前为止,你做的很棒!我们要做的下一件事是在我们的问题中添加选择。在你的
编辑器中打开 app/questions/components/QuestionForm.tsx
。
添加另外三个 <LabeledTextField>
组件作为选择。
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>
)
}
现在打开 app/pages/questions/new.tsx
并设置 initialValues
为如下:
<QuestionForm
submitText="Create Question"
- // initialValues={{ }}
+ initialValues={{choices: []}}
onSubmit={async (values) => {
try {
const question = await createQuestionMutation(values)
router.push(`/questions/${question.id}`)
} catch (error) {
console.error(error)
return {
[FORM_ERROR]: error.toString(),
}
}
}}
/>
接下来打开 app/questions/mutations/createQuestion.ts
并更新 zod 模式,来
让 mutation 接收 choice 数据。而且我们还需要更新 db.question.create
调用
,以便 choice 也可以被创建。
// app/questions/mutations/createQuestion.ts
const CreateQuestion = z
.object({
text: z.string(),
+ choices: z.array(z.object({text: z.string()})),
})
.nonstrict()
export default resolver.pipe(
resolver.zod(CreateQuestion),
resolver.authorize(),
async (input) => {
- const question = await db.question.create({data: input})
+ const question = await db.question.create({
+ data: {
+ ...input,
+ choices: {create: input.choices},
+ },
+ })
return question
},
)
现在你可以转到 localhost:3000/questions/new
并创建一个带有选择的新问题!
该轻松一下了。返回浏览器中的 localhost:3000/questions
并查看你创建的所有
问题。让我们在这些问题下列出相关的选择如何?首先,我们需要自定义问题查询函
数。在 Prisma 中,你需要手动让客户端知道你需要查询的嵌套关系,将你的
getQuestion.ts
和 getQuestions.ts
文件更改为如下所示:
// app/questions/queries/getQuestion.ts
const GetQuestion = z.object({
// This accepts type of undefined, but is required at runtime
id: z.number().optional().refine(Boolean, "Required"),
})
export default resolver.pipe(
resolver.zod(GetQuestion),
resolver.authorize(),
async ({id}) => {
- const question = await db.question.findFirst({where: {id}})
+ const question = await db.question.findFirst({
+ where: {id},
+ include: {choices: true},
+ })
if (!question) throw new NotFoundError()
return question
},
)
// app/questions/queries/getQuestions.ts
interface GetQuestionsInput
extends Pick<
Prisma.QuestionFindManyArgs,
"where" | "orderBy" | "skip" | "take"
> {}
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,
}
},
)
现在在浏览器中跳回我们主要的 Question 页面
(app/pages/questions/index.tsx
),我们可以列出每个问题的选择。并将此代码
添加到我们 QuestionsList
中的 Link
下:
// app/pages/questions/index.tsx
// ...
{
questions.map((question) => (
<li key={question.id}>
<Link href={`/questions/${question.id}`}>
<a>{question.text}</a>
</Link>
+ <ul>
+ {question.choices.map((choice) => (
+ <li key={choice.id}>
+ {choice.text} - {choice.votes} votes
+ </li>
+ ))}
+ </ul>
</li>
))
}
// ...
现在在浏览器中访问 /questions
路由。神奇吧!
在浏览器中打开 app/pages/questions/[questionId].tsx
。首先,我们将对该页
面进行一些改造。
替换 <h1>Question {question.id}</h1>
为 <h1>{question.text}</h1>
.
删除 pre
元素,并将如下复制到之前写的选择列表中:
<ul>
{question.choices.map((choice) => (
<li key={choice.id}>
{choice.text} - {choice.votes} votes
</li>
))}
</ul>
如果返回浏览器,页面目前看起来像这样!
首先我们需要打开 app/choices/mutations/updateChoice.ts
,更新 zod 模式,
添加新增投票功能。
const UpdateChoice = z
.object({
id: z.number(),
- name: z.string(),
})
.nonstrict()
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
},
)
返回到 app/pages/questions/[questionId].tsx
中进行如下更改:
在我们的 li
中,新增一个如下的 button
:
<li key={choice.id}>
{choice.text} - {choice.votes} votes
<button>Vote</button>
</li>
接下来,导入我们更新的 updateChoice
mutation,并在页面中创建
handleVote
函数。
// app/pages/questions/[questionId].tsx
+import updateChoice from "app/choices/mutations/updateChoice"
//...
const Question = () => {
const router = useRouter()
const questionId = useParam("questionId", "number")
const [deleteQuestionMutation] = useMutation(deleteQuestion)
const [question] = useQuery(getQuestion, {id: questionId})
+ const [updateChoiceMutation] = useMutation(updateChoice)
+
+ const handleVote = async (id: number) => {
+ try {
+ await updateChoiceMutation({id})
+ refetch()
+ } catch (error) {
+ alert("Error updating choice " + JSON.stringify(error, null, 2))
+ }
+ }
return (
然后我们需要更新问题相关的 useQuery
调用以返回需要在 handleVote
内部使
用的 refetch
函数。
// app/pages/questions/[questionId].tsx
//...
- const [question] = useQuery(getQuestion, {id: questionId})
+ const [question, {refetch}] = useQuery(getQuestion, {id: questionId})
//...
最后,我们将告诉新的 button
来条用该函数!
<button onClick={() => handleVote(choice.id)}>Vote</button>
最终的 Question
组件应该是这个样子:
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 })
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={`/questions/${question.id}/edit`}>
<a>Edit</a>
</Link>
<button
type="button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await deleteQuestionMutation({ id: question.id })
router.push("/questions")
}
}}
style={{ marginLeft: "0.5rem" }}
>
Delete
</button>
</div>
</>
)
}
如果单击现有问题之一上的“编辑”按钮,你将看到它使用与创建问题相同的形式。至 此,该部分已经完成!我们只需要更新我们的 mutation。
打开 app/questions/mutations/updateQuestion.ts
并进行如下改动:
// app/questions/mutations/updateQuestion.ts
import {resolver} from "blitz"
import db from "db"
import * as 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()}),
+ ),
})
.nonstrict()
export default resolver.pipe(
resolver.zod(UpdateQuestion),
resolver.authorize(),
async ({id, ...data}) => {
- const question = await db.question.update({where: {id}, data})
+ const question = await db.question.update({
+ where: {id},
+ data: {
+ ...data,
+ choices: {
+ upsert: data.choices.map((choice) => ({
+ // Appears to be a prisma bug,
+ // because `|| 0` shouldn't be needed
+ where: {id: choice.id || 0},
+ create: {text: choice.text},
+ update: {text: choice.text},
+ })),
+ },
+ },
+ include: {
+ choices: true,
+ },
+ })
return question
},
)
upsert
是一种特殊的操作,表示“如果存在此项目,请对其进行更新。否则创建它”。这对于
当前情况是完美的,因为我们不需要用户在创建问题时同时添加三个选择。所以如果
用户通过编辑问题添加另一个选择,则将在此处创建它。
为了让 yarn tsc
和 git push
能成功,你需要删除
app/choices/mutations/createChoice.ts
(没有用到)或更新 CreateChoice
zod schema 来包含必须的字段。
🥳 恭喜!你创建了自己的 Blitz 应用!祝你玩得开心,也欢迎与你的朋友分享。现 在,你已经完成了本教程,为什么不尝试使你的投票应用变得更好呢?你可以尝试:
如果你想与全球 Blitz 社区分享你的项目,没有比 Discord 更好的地方了。
访问 discord.blitzjs.com。然后,将连接发布 到 #built-with-blitz 频道来与所有人共享!