Skip to content

Commit

Permalink
Merge pull request #13 from toririm/feature/type-safe
Browse files Browse the repository at this point in the history
モデルのスキーマに基づいた統一的なバリデーションを実現
  • Loading branch information
toririm authored Aug 8, 2024
2 parents df3dcd7 + f3e34cd commit 1063883
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 58 deletions.
21 changes: 21 additions & 0 deletions app/firebase/converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type {
DocumentData,
QueryDocumentSnapshot,
SnapshotOptions,
} from "firebase/firestore";
import type { ZodSchema } from "zod";

export const converter = <T>(schema: ZodSchema<T>) => {
return {
toFirestore: (data: T) => {
return data as DocumentData;
},
fromFirestore: (
snapshot: QueryDocumentSnapshot,
options: SnapshotOptions,
) => {
const data = snapshot.data(options);
return schema.parse(data);
},
};
};
46 changes: 21 additions & 25 deletions app/firebase/subscription.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import {
collection,
type DocumentData,
onSnapshot,
query,
} from "firebase/firestore";
import { type SWRSubscription } from "swr/subscription";
import { collection, onSnapshot, query } from "firebase/firestore";
import type { SWRSubscription } from "swr/subscription";
import { type ZodSchema } from "zod";
import { converter } from "./converter";
import { db } from "./firestore";

// データの型はあとでマシなものにする
export const collectionSub: SWRSubscription<string, DocumentData[], Error> = (
key,
{ next },
) => {
const unsub = onSnapshot(
query(collection(db, key)),
(snapshot) => {
next(
null,
snapshot.docs.map((doc) => doc.data()),
);
},
(err) => {
next(err);
},
);
return unsub;
export const collectionSub = <T>(schema: ZodSchema<T>) => {
const sub: SWRSubscription<string, T[], Error> = (key, { next }) => {
const unsub = onSnapshot(
query(collection(db, key)).withConverter(converter(schema)),
(snapshot) => {
next(
null,
snapshot.docs.map((doc) => doc.data()),
);
},
(err) => {
next(err);
},
);
return unsub;
};
return sub;
};
16 changes: 16 additions & 0 deletions app/models/item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from "zod";

export const itemtypes = ["hot", "ice", "ore", "milk"] as const;

export const itemSchema = z.object({
name: z.string({ required_error: "名前が未入力です" }),
price: z.number({ required_error: "価格が未入力です" }),
type: z.enum(itemtypes, {
required_error: "種類が未選択です",
invalid_type_error: "不正な種類です",
}),
});

export type Item = z.infer<typeof itemSchema>;

export type ItemType = Pick<Item, "type">["type"];
14 changes: 14 additions & 0 deletions app/models/order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from "zod";
import { itemSchema } from "./item";

export const orderSchema = z.object({
orderId: z.number(),
createdAt: z.date(),
servedAt: z.date().nullable(),
items: z.array(itemSchema),
assignee: z.string().nullable(),
total: z.number(),
orderReady: z.boolean(),
});

export type Order = z.infer<typeof orderSchema>;
6 changes: 4 additions & 2 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { collection, getDocs } from "firebase/firestore";
import { Button } from "~/components/ui/button";
import { converter } from "~/firebase/converter";
import { db } from "~/firebase/firestore";
import { itemSchema } from "~/models/item";

export const meta: MetaFunction = () => {
return [
Expand All @@ -14,7 +16,7 @@ export const meta: MetaFunction = () => {
export const clientLoader = async () => {
// clientLoader は Remix SPA 特有の関数で、ページロード時にクライアント側で実行される
// したがって現時点ではリアルタイムデータの取得はできない
const itemsRef = collection(db, "items");
const itemsRef = collection(db, "items").withConverter(converter(itemSchema));
const docSnap = await getDocs(itemsRef);
const items = docSnap.docs.map((doc) => doc.data());
console.log(items);
Expand Down Expand Up @@ -52,7 +54,7 @@ export default function Index() {
<Button className="mt-4 bg-sky-900 text-white">Click me</Button>
<ul>
{items.map((item) => (
<li key={item.id} className="mt-4">
<li key={item.name} className="mt-4">
<h2 className="text-xl">{item.name}</h2>
<p>{item.price}</p>
<p>{item.type}</p>
Expand Down
98 changes: 68 additions & 30 deletions app/routes/items.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import type { ActionFunction, MetaFunction } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { Form, json, useActionData } from "@remix-run/react";
import { addDoc, collection } from "firebase/firestore";
import useSWRSubscription from "swr/subscription";
import { Button } from "~/components/ui/button";
Expand All @@ -8,47 +10,84 @@ import { Label } from "~/components/ui/label";
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
import { db } from "~/firebase/firestore";
import { collectionSub } from "~/firebase/subscription";
import { itemSchema, itemtypes } from "~/models/item";

export const meta: MetaFunction = () => {
return [{ title: "アイテム" }];
};

const type2label = {
hot: "ホット",
ice: "アイス",
ore: "オレ",
milk: "ミルク",
};

export default function Item() {
const { data: items } = useSWRSubscription("items", collectionSub);
const { data: items } = useSWRSubscription(
"items",
collectionSub(itemSchema),
);
const lastResult = useActionData<typeof clientAction>();
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: itemSchema });
},
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
});

return (
<div className="font-sans p-4">
<h1 className="text-3xl">アイテム</h1>
<ul>
{items?.map((item) => (
<li key={item.id}>
<li key={item.name}>
<h2>{item.name}</h2>
<p>{item.price}</p>
<p>{item.type}</p>
</li>
))}
</ul>
<Form method="post">
<Input type="text" name="name" placeholder="名前" />
<Input type="number" name="price" placeholder="価格" />
<RadioGroup name="type">
<div className="flex items-center space-x-2">
<RadioGroupItem value="hot" id="hot" />
<Label htmlFor="hot">ホット</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ice" id="ice" />
<Label htmlFor="ice">アイス</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ore" id="ore" />
<Label htmlFor="ore">オレ</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="milk" id="milk" />
<Label htmlFor="milk">ミルク</Label>
</div>
</RadioGroup>
<Form method="post" id={form.id} onSubmit={form.onSubmit}>
<div>
<Input
type="text"
key={fields.name.key}
name={fields.name.name}
defaultValue={fields.name.initialValue}
required
placeholder="名前"
/>
<span>{fields.name.errors}</span>
</div>
<div>
<Input
type="number"
key={fields.price.key}
name={fields.price.name}
defaultValue={fields.price.initialValue}
required
placeholder="価格"
/>
<span>{fields.price.errors}</span>
</div>
<div>
<RadioGroup
key={fields.type.key}
name={fields.type.name}
defaultValue={fields.type.initialValue}
>
{itemtypes.map((type) => (
<div key={type} className="flex items-center space-x-2">
<RadioGroupItem value={type} id={type} />
<Label htmlFor={type}>{type2label[type]}</Label>
</div>
))}
</RadioGroup>
<span>{fields.type.errors}</span>
</div>
<Button type="submit">登録</Button>
</Form>
</div>
Expand All @@ -57,15 +96,14 @@ export default function Item() {

export const clientAction: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const name = formData.get("name");
const price = formData.get("price");
const type = formData.get("type");
const submission = parseWithZod(formData, { schema: itemSchema });

// あとでマシなバリデーションにする e.g. zod, conform
if (!(name && price && type)) {
return new Response("Bad Request", { status: 400 });
if (submission.status !== "success") {
return json(submission.reply());
}

const { name, price, type } = submission.value;

// あとでマシなエラーハンドリングにする & 処理を別ファイルに切り分ける
const docRef = await addDoc(collection(db, "items"), {
name,
Expand Down
Binary file modified bun.lockb
Binary file not shown.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"typecheck": "tsc"
},
"dependencies": {
"@conform-to/react": "^1.1.5",
"@conform-to/zod": "^1.1.5",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
Expand All @@ -25,7 +27,8 @@
"react-dom": "^18.2.0",
"swr": "^2.2.5",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.11.0",
Expand Down

0 comments on commit 1063883

Please sign in to comment.