94日目: ReactでCRUD構築 12

スキーマのレスポンス定義どうするんだろうの続き。

その前にリクエスト時の定義は dto に ApiProperty デコレータを書くだけだった。

diff --git a/src/todo/create-task.dto.ts b/src/todo/create-task.dto.ts
index 7ca7cb7..7211b7a 100644
--- a/src/todo/create-task.dto.ts
+++ b/src/todo/create-task.dto.ts
@@ -1,6 +1,8 @@
 import { IsNotEmpty } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';

 export class CreateTaskDto {
+  @ApiProperty()
   @IsNotEmpty()
   title: string;
 }

以下のようになる。

レスポンスについては Prisma の型定義からそのまま設定したかったものの、以下で設定できた。

diff --git a/src/posts/posts.controller.ts b/src/posts/posts.controller.ts
index ea259d2..3f11e68 100644
--- a/src/posts/posts.controller.ts
+++ b/src/posts/posts.controller.ts
@@ -1,5 +1,6 @@
 import { Controller, Get } from '@nestjs/common';
 import { PrismaService } from 'src/prisma/prisma.service';
+import type { Post } from '@prisma/client';
 import { ApiTags, ApiResponse } from '@nestjs/swagger';

 @ApiTags('Posts')
@@ -9,9 +10,23 @@ export class PostsController {
     private readonly prisma: PrismaService
   ) {}

-  @Get('')
-  @ApiResponse({ status: 200, description: 'Get all posts' })
-  async findAll() {
+  @Get()
+  @ApiResponse({
+    status: 200,
+    description: 'Get all posts',
+    schema: {
+      type: 'array',
+      items: {
+        type: 'object',
+        properties: {
+          id: { type: 'number' },
+          title: { type: 'string' },
+          body: { type: 'string' },
+        },
+      },
+    },
+  })
+  async findAll(): Promise<Post[]> {
     const result = await this.prisma.post.findMany();

     return [

続きは明日。

93日目: ReactでCRUD構築 11

CRUDの続き。NestJSのエンドポイントに Open API 対応をしていなかったのでスキーマを書いていく。

本当は順番逆なんだけども。

docs.nestjs.com

まずはパッケージをインストール。

$ npm i @nestjs/swagger

セットアップ。

diff --git a/src/main.ts b/src/main.ts
index 68db530..1711a5b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,10 +1,22 @@
 import { NestFactory } from '@nestjs/core';
+import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
 import { ValidationPipe } from '@nestjs/common';
 import { AppModule } from './app.module';

 async function bootstrap() {
   const app = await NestFactory.create(AppModule);

+  const config = new DocumentBuilder()
+    .setTitle('Cats example')
+    .setDescription('The cats API description')
+    .setVersion('1.0')
+    .addTag('cats')
+    .build();
+
+  const document = SwaggerModule.createDocument(app, config);
+
+  SwaggerModule.setup('api', app, document);
+
   app.useGlobalPipes(
     new ValidationPipe({
       transform: true,
@@ -14,4 +26,5 @@ async function bootstrap() {
   app.enableCors();
   await app.listen(3000);
 }
+
 bootstrap();

npm run start でサーバーを起動して http://localhost:3000/api にアクセスすると Swagger が表示される。

Cats example とはなんだろうか?

コントローラにタグや説明文を追加されると反映された。

diff --git a/src/posts/posts.controller.ts b/src/posts/posts.controller.ts
index e4f0f08..ea259d2 100644
--- a/src/posts/posts.controller.ts
+++ b/src/posts/posts.controller.ts
@@ -1,6 +1,8 @@
 import { Controller, Get } from '@nestjs/common';
 import { PrismaService } from 'src/prisma/prisma.service';
+import { ApiTags, ApiResponse } from '@nestjs/swagger';

+@ApiTags('Posts')
 @Controller('posts')
 export class PostsController {
   constructor(
@@ -8,6 +10,7 @@ export class PostsController {
   ) {}

   @Get('')
+  @ApiResponse({ status: 200, description: 'Get all posts' })
   async findAll() {
     const result = await this.prisma.post.findMany();

レスポンスの設定もしてバリデーションもできるか調べてみたいが続きはまた明日。

とはいえ本当はやりたいこととしては、

  1. スキーマを定義する
  2. スキーマに沿った実装をする

という、いわゆるスキーマ駆動開発なんだけども、これだとコントローラの実装を基に Swagger のドキュメントを自動生成するので、 依存関係としては逆じゃないかなという気もするが、Ruby on Rails でも spec から生成する gem もあるし、こういうもんなのだろうか。

まあ気に入らなければ使わなきゃいいだけなので、とりあえずどんなものか触ってみたらいいだけなんだけども。

92日目: Expo で Storybook をセットアップ

CRUDアプリの開発途中だが React Native を触る機会があったのでちょっと素振り。

まずはアプリケーションの雛形をドキュメント通りに作る。

docs.expo.dev

$ npx create-expo-app hello-expo -t expo-template-blank-typescript
$ cd hello-expo
$ npx expo install react-native-web react-dom @expo/metro-runtime

tsconfig.json を以下のように修正。

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true
  },
  "include": [
    "**/*.ts",
    "**/*.tsx"
  ]
}

ts:check スクリプトも追加。

"ts:check": "tsc"

npm run web でアプリケーションを起動。

動いた。

続いて Storybook のセットアップ。

$ npx sb@latest init

でインストール。通常の Web 開発であればこの時点でStorybookが起動するのだが、

NOTE: installation is not 100% automated.

To run Storybook, you will need to:

  1. Replace the contents of your app entry with the following

export {default} from './.storybook';

  1. Enable transformer.unstable_allowRequireContext in your metro config

For a more detailed guide go to: https://github.com/storybookjs/react-native#existing-project

Then to run your Storybook, type:

npm run start

というアラートが出現するので設定をしていく。

まずは設定ファイルを生成する。

$ npx expo customize metro.config.js

次のように書き換える。

// Learn more https://docs.expo.io/guides/customizing-metro
const path = require('path');
const { getDefaultConfig } = require('expo/metro-config');
const { generate } = require('@storybook/react-native/scripts/generate');

generate({
  configPath: path.resolve(__dirname, '.storybook'),
});

/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);

config.transformer.unstable_allowRequireContext = true;

config.resolver.sourceExts.push('mjs');

module.exports = config;

エントリポイントの App.tsx で Storybook を起動する場合としない場合とで、環境変数で切り替えられるようにする。

diff --git a/App.tsx b/App.tsx
index 0329d0c..931fa43 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,7 +1,7 @@
 import { StatusBar } from 'expo-status-bar';
 import { StyleSheet, Text, View } from 'react-native';

-export default function App() {
+function App() {
   return (
     <View style={styles.container}>
       <Text>Open up App.tsx to start working on your app!</Text>
@@ -10,6 +10,12 @@ export default function App() {
   );
 }

+let AppEntryPoint = App;
+
+if (process.env.EXPO_PUBLIC_STORYBOOK_ENABLED) {
+  AppEntryPoint = require('./.storybook').default;
+}
+
 const styles = StyleSheet.create({
   container: {
     flex: 1,
@@ -18,3 +24,5 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
   },
 });
+
+export default AppEntryPoint;

storybook スクリプトを追加。

diff --git a/package.json b/package.json
index 603fa13..ab825ad 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
     "ios": "expo start --ios",
     "web": "expo start --web",
     "ts:check": "tsc",
+    "storybook": "EXPO_PUBLIC_STORYBOOK_ENABLED=1 expo start",
     "storybook-generate": "sb-rn-get-stories"
   },
   "dependencies": {

npm run storybook を実行。

動いた。通常の起動も確認 npm run web を実行。

動いた。以上で Storybook で React Native の開発ができるようになる。

環境変数ではなく定数を使っているが以下を参考にした。

dev.to

91日目: ReactでCRUD構築 10

昨日の続き。以前NestJSのサンプル実装をしたときに組んだサンプルアプリを使って、REST APIを実装していく。

furikake555.hatenablog.com

API サーバー

まずは Post テーブルの追加から。ユーザーはいないので titledescription のみ。

diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2dc51e9..dcaeeae 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -16,3 +16,9 @@ model Task {
   due_on DateTime?
   is_done Boolean @default(false)
 }
+
+model Post {
+  id Int @id @default(autoincrement())
+  title String
+  body String
+}

マイグレーション実行。

$ npx prisma migrate dev --name post

シードの追加。

$ touch src/seeding/post.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  const posts = [
    { title: 'Post 1', body: 'Content 1' },
    { title: 'Post 2', body: 'Content 2' },
    { title: 'Post 3', body: 'Content 3' },
  ];

  await Promise.all(
    posts.map(data => prisma.post.create({ data }))
  )
}

main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  });

シードの実行。

$ npx ts-node src/seeding/post.ts

コントローラの作成

$ npx nest g controller posts
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';

@Controller('posts')
export class PostsController {
  constructor(
    private readonly prisma: PrismaService
  ) {}

  @Get('')
  async findAll() {
    const result = await this.prisma.post.findMany();

    return [
      ...result
    ];
  }
}

npm run start:dev でサーバーを起動して curl で動作確認。

$ curl http://localhost:3000/posts -s | jq
[
  {
    "id": 1,
    "title": "Post 2",
    "body": "Content 2"
  },
  {
    "id": 2,
    "title": "Post 1",
    "body": "Content 1"
  },
  {
    "id": 3,
    "title": "Post 3",
    "body": "Content 3"
  }
]

問題なし。最後にクライアントから接続できるよう CORS を有効にする。

diff --git a/src/main.ts b/src/main.ts
index ba05580..68db530 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -11,6 +11,7 @@ async function bootstrap() {
     })
   );

+  app.enableCors();
   await app.listen(3000);
 }
 bootstrap();

クライアント

エンドポイントをさしかえて動作確認する。

diff --git a/src/components/pages/PostsIndexPage.tsx b/src/components/pages/PostsIndexPage.tsx
index 21cc322..5aad493 100644
--- a/src/components/pages/PostsIndexPage.tsx
+++ b/src/components/pages/PostsIndexPage.tsx
@@ -4,7 +4,7 @@ import { Posts } from '@/components/templates/Posts';
 export const loader = async ({ request }) => {
   const url = new URL(request.url);
   const query = url.searchParams;
-  const response = await fetch(`https://jsonplaceholder.typicode.com/posts?${query}`);
+  const response = await fetch(`http://localhost:3000/posts?${query}`);
   if (!response.ok) {
     throw new Response('', {
       status: response.status,

サーバー起動後、表示が確認できた。

明日は詳細画面などの続きから。

90日目: ReactでCRUD構築 9

昨日の続き。検索を実装する。まずは検索フォームから。

$ touch src/components/views/PostSearchForm.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

import { Button } from '@/components/ui/button';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';

const formSchema = z.object({
  q: z.string(),
});

export type onSubmitFunction = (data: z.infer<typeof formSchema>) => void;

type PostSearchFormProps = {
  onSubmit: onSubmitFunction;
};

export const PostSearchForm = ({ onSubmit }: PostSearchFormProps) => {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      q: '',
    },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-3">
        <FormField
          control={form.control}
          name="q"
          render={({ field }) => (
            <FormItem>
              <FormLabel>キーワード</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">検索</Button>
      </form>
    </Form>
  );
};

一覧ページのコンポーネント src/components/templates/Posts.tsx に検索フォームを組み込む。

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Link } from 'react-router-dom';
import { PostSearchForm, onSubmitFunction } from '@/components/views/PostSearchForm';

type PostsProps = {
  posts: Post[];
  onSubmit: onSubmitFunction;
}

export const Posts = ({ posts, onSubmit }: PostsProps) => {
  return (
    <div className="grid grid-cols-12 gap-3">
      <div className="col-span-8 grid gap-3">
        {posts.map(post => (
          <Card key={post.id}>
            <CardHeader>
              <CardTitle>{post.title}</CardTitle>
            </CardHeader>
            <CardContent>
              <CardDescription>{post.body}</CardDescription>
            </CardContent>
            <CardFooter className="flex justify-end">
              <Button asChild variant="link">
                <Link to={`/posts/${post.id}`}>Read more</Link>
              </Button>
            </CardFooter>
          </Card>
        ))}
      </div>
      <div className="col-span-4">
        <Card>
          <CardHeader>
            <CardTitle>検索</CardTitle>
          </CardHeader>
          <CardContent>
            <PostSearchForm onSubmit={onSubmit} />
          </CardContent>
        </Card>
      </div>
    </div>
  );
};

Storybook も修正。

diff --git a/src/components/templates/Posts.stories.tsx b/src/components/templates/Posts.stories.tsx
index fb1131d..8be9802 100644
--- a/src/components/templates/Posts.stories.tsx
+++ b/src/components/templates/Posts.stories.tsx
@@ -40,11 +40,13 @@ export const Default: Story = {
         userId: 2,
       },
     ],
+    onSubmit: (data) => {},
   },
 };

 export const Empty: Story = {
   args: {
     posts: [],
+    onSubmit: (data) => {},
   },
 };

検索ロジックをページから渡すよう調整。

diff --git a/src/components/pages/PostsIndexPage.tsx b/src/components/pages/PostsIndexPage.tsx
index 3750cba..53fe372 100644
--- a/src/components/pages/PostsIndexPage.tsx
+++ b/src/components/pages/PostsIndexPage.tsx
@@ -1,4 +1,4 @@
-import { useLoaderData } from "react-router-dom";
+import { useLoaderData, useSubmit } from "react-router-dom";
 import { Posts } from '@/components/templates/Posts';

 export const loader = async () => {
@@ -14,6 +14,13 @@ export const loader = async () => {
 };

 export const PostsIndexPage = () => {
+  const submit = useSubmit();
   const { posts } = useLoaderData() as { posts: Post[] };
-  return <Posts posts={posts} />;
+  const handleSubmit = (data) => {
+    const formData = new FormData();
+    formData.append('q', data.q);
+    submit(formData, { method: 'GET' });
+  };
+
+  return <Posts posts={posts} onSubmit={handleSubmit} />;
 }

これで検索フォームから送信すると、クエリストリング q に検索語句が付与されるようになった。

最後にサーバーにGETリクエストする際に、クエリストリングを付与するよう調整。

diff --git a/src/components/pages/PostsIndexPage.tsx b/src/components/pages/PostsIndexPage.tsx
index 53fe372..21cc322 100644
--- a/src/components/pages/PostsIndexPage.tsx
+++ b/src/components/pages/PostsIndexPage.tsx
@@ -1,8 +1,10 @@
 import { useLoaderData, useSubmit } from "react-router-dom";
 import { Posts } from '@/components/templates/Posts';

-export const loader = async () => {
-  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
+export const loader = async ({ request }) => {
+  const url = new URL(request.url);
+  const query = url.searchParams;
+  const response = await fetch(`https://jsonplaceholder.typicode.com/posts?${query}`);
   if (!response.ok) {
     throw new Response('', {
       status: response.status,

これで検索が実装できた。スタブを使っているが検索できている模様。

明日はページネーションを実装したいがスタブを使っているので、expressかnestjsでAPIサーバーを実装していく予定。

89日目: ReactでCRUD構築 8

昨日の続き。削除を実装する。

削除は画面を持たないので、 components/pages/PostPage.tsx にロジックを書いて、ルーティングに設定する。

まずはコンポーネントに削除ボタンを追加。

diff --git a/src/components/templates/Post.tsx b/src/components/templates/Post.tsx
diff --git a/src/components/templates/Post.tsx b/src/components/templates/Post.tsx
index dfd7307..0d213d3 100644
--- a/src/components/templates/Post.tsx
+++ b/src/components/templates/Post.tsx
@@ -2,15 +2,25 @@ import {
   Card,
   CardContent,
   CardDescription,
+  CardFooter,
   CardHeader,
   CardTitle,
 } from "@/components/ui/card"
+import {
+  Button,
+} from "@/components/ui/button"

 type PostProps = {
   post: Post;
+  onDelete: () => void;
 }

-export const Post = ({ post }: PostProps) => {
+export const Post = ({ post, onDelete }: PostProps) => {
+  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    onDelete();
+  };
+
   return (
     <Card key={post.id}>
       <CardHeader>
@@ -19,6 +29,11 @@ export const Post = ({ post }: PostProps) => {
       <CardContent>
         <CardDescription>{post.body}</CardDescription>
       </CardContent>
+      <CardFooter>
+        <form method="post" onSubmit={handleSubmit}>
+          <Button type="submit">削除</Button>
+        </form>
+      </CardFooter>
     </Card>
   );
 };

Story も調整する。

diff --git a/src/components/templates/Post.stories.tsx b/src/components/templates/Post.stories.tsx
index 2cae062..ffa0c6e 100644
--- a/src/components/templates/Post.stories.tsx
+++ b/src/components/templates/Post.stories.tsx
@@ -24,5 +24,6 @@ export const Default: Story = {
       body: 'This is the body of post 1',
       userId: 1,
     },
+    onDelete: () => {},
   },
 };

削除処理を実装。

diff --git a/src/components/pages/PostPage.tsx b/src/components/pages/PostPage.tsx
index 4901722..aeeb999 100644
--- a/src/components/pages/PostPage.tsx
+++ b/src/components/pages/PostPage.tsx
@@ -1,5 +1,5 @@
-import { useLoaderData } from "react-router-dom";
-import type { LoaderFunctionArgs } from 'react-router-dom';
+import { redirect, useLoaderData, useSubmit } from "react-router-dom";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router-dom';
 import { Post } from '@/components/templates/Post';

 export const loader = async ({ params }: LoaderFunctionArgs) => {
@@ -14,7 +14,24 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
   return { post };
 };

+export const destroyAction = async ({ params }: ActionFunctionArgs) => {
+  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, {
+    method: 'DELETE',
+  });
+  if (!response.ok) {
+    throw new Response('', {
+      status: response.status,
+      statusText: response.statusText,
+    });
+  }
+  return redirect('/posts');
+};
+
 export const PostPage = () => {
   const { post } = useLoaderData() as { post: Post }
-  return <Post post={post} />;
+  const submit = useSubmit();
+  const handleDelete = () => {
+    submit(null, { method: 'post', action: 'destroy' });
+  };
+  return <Post post={post} onDelete={handleDelete} />;
 }

ルーティングに組み込む。

diff --git a/src/main.tsx b/src/main.tsx
index 5e6054c..bf1cbc7 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -15,6 +15,7 @@ import {
 import {
   PostPage,
   loader as postPageLoader,
+  destroyAction as destroyPostAction,
 } from './components/pages/PostPage';
 import {
   NewPostPage,
@@ -80,6 +81,10 @@ const router = createBrowserRouter([
             loader: editPostPageLoader,
             action: updatePostAction,
           },
+          {
+            path: ':id/destroy',
+            action: destroyPostAction,
+          },
         ],
       },
     ],

これで削除は実装完了で、CRUD自体の目的は達成した。

しかしついでに以下も対応していく予定。

  • ページネーション
  • 検索
  • APIをモックせずに REST APIを使う

ということで続きは明日。

88日目: ReactでCRUD構築 7

昨日の続き。編集フォームのコンポーネントを追加する。

$ touch src/components/pages/EditPostPage.tsx
import { useLoaderData, useSubmit, redirect } from "react-router-dom";
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router-dom';
import { PostForm, onSubmitFunction } from '@/components/views/PostForm';

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`);
  if (!response.ok) {
    throw new Response('', {
      status: response.status,
      statusText: response.statusText,
    });
  }
  const post = await response.json();
  return { post };
};

export const action = async ({ params, request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, {
    method: 'PUT',
    body: formData,
  });

  if (!response.ok) {
    throw new Response('', {
      status: response.status,
      statusText: response.statusText,
    });
  }

  // ダミーのレスポンスが返ってくるので id 1 にリダイレクト
  // return redirect(`/posts/${params.id}`);
  return redirect('/posts/1');
};

export const EditPostPage = () => {
  const { post } = useLoaderData() as { post: Post }
  const submit = useSubmit();
  const onSubmit: onSubmitFunction = (data) => {
    const formData = new FormData();
    formData.append('post[title]', data.title);
    formData.append('post[body]', data.body);
    formData.append('post[userId]', '12345');
    submit(formData, { method: 'PUT' });
  };
  return (
    <PostForm
      defaultValues={{ title: post.title, body: post.body }}
      onSubmit={onSubmit}
    />
  );
}

ルーティングに組み込む。

diff --git a/src/main.tsx b/src/main.tsx
index 1a87400..5e6054c 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -20,6 +20,11 @@ import {
   NewPostPage,
   action as createPostAction,
 } from './components/pages/NewPostPage';
+import {
+  EditPostPage,
+  loader as editPostPageLoader,
+  action as updatePostAction,
+} from './components/pages/EditPostPage';

 const RootLayout = () => {
   return (
@@ -69,6 +74,12 @@ const router = createBrowserRouter([
             element: <PostPage />,
             loader: postPageLoader,
           },
+          {
+            path: ':id/edit',
+            element: <EditPostPage />,
+            loader: editPostPageLoader,
+            action: updatePostAction,
+          },
         ],
       },
     ],

リンクはないので直接ブラウザに /posts/1/edit などのパスを入力して確認。

明日はリンクを整えて、削除アクションを追加する予定。