📚

React入門 - TODOアプリの実装 -

に公開

はじめに

今回はReactでTODOアプリを作ってみようと思います。
データの操作は、前回の記事で作成したGoのREST APIを使用します。

API修正

前回作成したAPIには、1件だけタスク情報を取得するものがありませんので、
画面作成していく前に「FindById」を追加します。
※編集画面で使います。

APIエンドポイント

GETメソッドでパスパラメータにidを指定するパターンを追加します。

メソッド エンドポイント 機能
GET /tasks/ 全件取得
GET /tasks/{id} 取得
POST /tasks/ 新規登録
PATCH /tasks/{id} 更新
DELETE /tasks/{id} 論理削除

インターフェース定義を追加

インターフェース定義にFindByIdを追加します。

backend/domain/repository/task.go
 type ITaskRepository interface {
 	FindAll() ([]entities.Task, error)
+	FindById(id int) (entities.Task, error)
 	Create(task entities.Task) error
 	Update(task entities.Task) error
 	Delete(id int) error
 }

FindByIdの実装を追加

インターフェース定義を追加したので、実装を追加します。

backend/infrastructure/database/repository/task.go
// 取得
func (r *taskRepository) FindById(id int) (entities.Task, error) {
	sql := `
	SELECT * FROM tasks
	WHERE
		id = ?
	AND
		deleted_at IS NULL
	`
	row := r.db.QueryRow(sql, id)

	var task entities.Task
	err := row.Scan(
		&task.Id,
		&task.Task,
		&task.Content,
		&task.Deadline,
		&task.Status,
		&task.CreatedAt,
		&task.UpdatedAt,
		&task.DeletedAt,
	)
	if err != nil {
		return entities.Task{}, err
	}

	return task, nil
}

ユースケースに処理を追加

インターフェース定義にFindByIdを追加し、FindByIdの実装を呼ぶ処理を追加します。

backend/usecase/task.go
 type ITaskUseCase interface {
 	FindAll() ([]entities.Task, error)
 	Create(task entities.Task) error
+	FindById(id int) (entities.Task, error)
 	Update(task entities.Task) error
 	Delete(id int) error
 }

+ // 取得
+ func (i *TaskUseCaseImpl) FindById(id int) (entities.Task, error) {
+ 	return i.TaskRepository.FindById(id)
+ }

共通処理の作成

共通処理や定数定義をまとめておくディレクトリを作成していなかったため、
backendディレクトリ直下に「common」ディレクトリを追加します。

common/utils/string_utils.go

文字列操作に関連する共通処理ファイルを追加し、パスパラメータを取得する関数を作成します。

backend/common/utils/string_utils.go
package utils

import "strings"

// パスパラメータの取得
func GetPathParameter(path string, endpoint string) string {
	return strings.TrimPrefix(path, endpoint)
}

common/constants/constants.go

APIのエンドポイントが直書きされていたので、
定数定義に関するファイルを追加し、APIのエンドポイントを定数化しました。

backend/common/constants/constants.go
package constants

// タスクAPIのエンドポイント
const TaskApiEndpoint = "/tasks/"

コントローラーに処理を追加

backend/controller/task.go
 import (
+	"backend/common/constants"
+	"backend/common/utils"
 	"backend/domain/entities"
 	"backend/types"
 	"backend/usecase"
 	"encoding/json"
 	"fmt"
 	"net/http"
 	"strconv"
-	"strings"
 	"time"
 )

+// 取得
+func (c *TaskController) FindById(w http.ResponseWriter, r *http.Request) {
+	param := utils.GetPathParameter(r.URL.Path, constants.TaskApiEndpoint)
+	taskId, err := strconv.Atoi(param)
+	if err != nil {
+		http.Error(w, "Invalid path", http.StatusBadRequest)
+		return
+	}
+
+	task, err := c.TaskUseCase.FindById(taskId)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Failed to fetch tasks: %s", err.Error()), http.StatusInternalServerError)
+		return
+	}
+
+	res := TaskResponse{
+		Id:        task.Id,
+		Task:      task.Task,
+		Content:   task.Content,
+		Deadline:  task.Deadline,
+		Status:    task.Status,
+		CreatedAt: task.CreatedAt,
+		UpdatedAt: task.UpdatedAt,
+		DeletedAt: task.DeletedAt,
+	}
+
+	json.NewEncoder(w).Encode(res)
+}

 // 更新
 func (c *TaskController) Update(w http.ResponseWriter, r *http.Request) {
+	param := utils.GetPathParameter(r.URL.Path, constants.TaskApiEndpoint)
+	taskId, err := strconv.Atoi(param)
-	taskId, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/tasks/"))
 	if err != nil {
 		http.Error(w, "Invalid path", http.StatusBadRequest)
 		return
 	}

 	var req TaskRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, "Invalid request body", http.StatusBadRequest)
 		return
 	}

 	task := entities.Task{
 		Id:       taskId,
 		Task:     req.Task,
 		Content:  req.Content,
 		Deadline: types.NewNullString(req.Deadline),
 		Status:   req.Status,
 	}

 	if err := c.TaskUseCase.Update(task); err != nil {
 		http.Error(w, fmt.Sprintf("Failed to update tasks: %s", err.Error()), http.StatusInternalServerError)
 		return
 	}
 }

 // 削除
 func (c *TaskController) Delete(w http.ResponseWriter, r *http.Request) {
+	param := utils.GetPathParameter(r.URL.Path, constants.TaskApiEndpoint)
+	taskId, err := strconv.Atoi(param)
-	taskId, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/tasks/"))
 	if err != nil {
 		http.Error(w, "Invalid path", http.StatusBadRequest)
 		return
 	}

 	if err := c.TaskUseCase.Delete(taskId); err != nil {
 		http.Error(w, fmt.Sprintf("Failed to delete tasks: %s", err.Error()), http.StatusInternalServerError)
 		return
 	}
 }

ルーティングの設定を修正

ルーティングの設定を修正します。
GETの場合にパスパラメータの有無でFindAllかFindByIdか分岐させます。

また、今回は新しく画面を作ってAPIを呼び出すということで、
異なるオリジン間での通信になるため、
CORS(Cross-Origin Resource Sharing)の設定も必要なので、そちらも追加しています。

参考:Go言語におけるCORS設定の実装方法

backend/infrastructure/router/router.go
 import (
+	"backend/common/constants"
+	"backend/common/utils"
 	"backend/controller"
 	"net/http"
 )

 // ルーティング設定
 func (router *Router) SetupRoutes() {
+	http.HandleFunc(constants.TaskApiEndpoint, func(w http.ResponseWriter, r *http.Request) {
-	http.HandleFunc("/tasks/", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Access-Control-Allow-Origin", "*")
+		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE")
+		w.Header().Set("Access-Control-Allow-Headers", "*")

 		switch r.Method {
 		case http.MethodGet:
+			param := utils.GetPathParameter(r.URL.Path, constants.TaskApiEndpoint)
+			if param != "" {
+				router.TaskController.FindById(w, r)
+			} else {
 				router.TaskController.FindAll(w, r)
+			}
 		case http.MethodPost:
 			router.TaskController.Create(w, r)
 		case http.MethodPatch:
 			router.TaskController.Update(w, r)
 		case http.MethodDelete:
 			router.TaskController.Delete(w, r)
 		}
 	})
 }

動作確認

DB登録状況

パスパラメータなしで全件取得されることが確認できました。

パスパラメータで1件だけ取得されることが確認できました。

Reactの環境構築

Reactの環境構築を行います。
環境構築の記事でよく見る「Create React App」はサポートを終了しているので、
今回は「Vite」を使って環境作っていきます。
「Vite」は起動速度がとても速く、個人学習などの小規模に向いているらしいです。
「webpack」というのもあるみたいですが、
「webpack」は大規模向きで設定が複雑のようで初心者には向いていないとのこと。

Node.js

ライブラリをインストールする際に「npm」コマンドを使いますが、
Node.jsが入ってなかったので、まずはNode.jsをインストールします。
手順は下記のとおりです。

  1. 公式サイトからインストーラーをダウンロード
  2. インストーラーを起動し、インストール実施

参考:【Windows】Node.jsのインストール手順

React(プロジェクト作成)

  1. Reactのプロジェクト用に空のディレクトリを作成
    今回は「frontend」としておきます。

  2. コマンドプロンプト起動

  3. 作成した空のディレクトリに移動し、プロジェクト作成コマンドを実行

    npm create vite@latest
    

    実行すると対話型で下記の内容を聞かれる

    • Project name ⇒ 現在開いているディレクトリに作ってほしいので「ドット(.)」を入力
    • Select a framework ⇒ 上下の矢印キーで移動して「React」を選択
    • Select a variant ⇒ 上下の矢印キーで移動して「TypeScript」を選択
    • Done. Now run: ⇒ 「Done」が表示されていれば、作成完了
  4. npmインストール実施

    npm install
    
  5. 実行確認

    npm run dev
    

    上記コマンドを実行すると、ローカルサーバが立ち上がります。
    コマンドラインにリンクが表示されますので、
    リンクにアクセスして、下記のような画面が表示されればOKです。

    停止させたい場合は、「Ctrl + C」を押すと停止確認されるので、Yを入力して停止してください。

参考:Vite公式サイト

ライブラリのインストール

まず、API通信を行うので「axios」を入れます。

npm install axios

続いて、画面は綺麗に作りたいので、UIライブラリも入れます。
今回は過去に経験があった「bootstrap」を使用することにしました。
アイコンも使いたいので「react-icons」も入れます。

React Bootstrap公式サイト
React Icons公式サイト

npm install react-bootstrap bootstrap
npm install react-icons

最後に「react-router-dom」を入れます。
「react-router-dom」はルーティング機能を提供するライブラリで、
シングルページアプリケーション(SPA)のページ遷移を簡単に実装できます。

npm install react-router-dom

実装

画面としては下記の4画面を作成していきます。

  • 一覧画面
  • ボード画面
  • 新規登録画面
  • 編集画面

ディレクトリ構成

ディレクトリ構成は下記のようにしました。
今回はあまり難しく考えず、
私が作りやすいようにディレクトリ分けしています。

frontend
├─ .gitignore
├─ eslint.config.js
├─ index.html               ← URLアクセス時に開くファイル
├─ package-lock.json
├─ package.json
├─ README.md
├─ tsconfig.app.json
├─ tsconfig.json
├─ tsconfig.node.json
├─ vite.config.ts           ← Viteの設定ファイル(触る必要なし)
├─ node_modules
├─ public                   ← 静的ファイル用のディレクトリ(画像等)
│   └─ vite.svg
└─ src
    ├─ App.css
    ├─ App.tsx              ← トップレベルのコンポーネント
    ├─ index.css
    ├─ main.tsx             ← Reactのエントリーポイント
    ├─ vite-env.d.ts
    ├─ assets               ← JavaScriptで読み込む外部ファイル用のディレクトリ
    │   └─react.svg
    ├─ common
    │   ├─ constants
    │   │   └─ constants.tsx
    │   ├─ entities
    │   │   └─ radio.tsx
    │   └─ utils
    │       └─ dateUtil.tsx
    ├─ components
    │   ├─ DeleteModal
    │   │   └─ DeleteModal.tsx
    │   ├─ Form
    │   │   └─ TaskInputForm.tsx
    │   ├─ Header
    │   │   └─ Header.tsx
    │   ├─ RadioButton
    │   │   └─ RadioButton.tsx
    │   └─ TaskCards
    │       ├─ TaskCards.module.css
    │       └─ TaskCards.tsx
    ├─ entities
    │   └─ Task.tsx
    ├─ functions
    │   ├─ RadioFunctions.tsx
    │   └─ TaskFunctions.tsx
    └─ pages
        ├─ AddPage.tsx
        ├─ BoardPage.tsx
        ├─ EditPage.tsx
        └─ GridPage.tsx

common: 共通処理/定義

定数定義

ルーティングとAPIのアドレスを定義しています。

ソースコード
frontend/src/common/constants/constants.tsx
/** 定数クラス */
export class Constants {

    /** ルーティング */
    static Routes = class {
        public static readonly HOME = "/";
        public static readonly BOARD = "/board";
        public static readonly ADD = "/add";
        public static readonly EDIT = "/edit";
    }

    /** APIエンドポイント */
    static ApiEndpoint = class {
        public static readonly TASK = "http://localhost:8080/tasks/";
    }

}

エンティティ定義

ラジオボタンの表示名と値をObjectの型として定義しています。

ソースコード
frontend/src/common/entities/radio.tsx
/** ラジオボタン用エンティティ */
export type Radio = {
    name: string;
    value: string;
}

日付操作の共通処理

指定のフォーマットで画面表示するために、フォーマット処理を実装しています。

ソースコード
frontend/src/common/utils/dateUtil.tsx
/**
 * 日付文字列のフォーマット
 * @param date 
 * @returns フォーマット後の日付文字列(yyyy-MM-dd HH:mm)
 */
export function formatDate(date: string) {
    if (date === null || date === undefined || date === "") {
        return "";
    }

    return new Date(date).toISOString().slice(0, 16).replace("T", " ");
}

components: 部品(コンポーネント)

componentsディレクトリには、各部品ごとにディレクトリを作るようにしています。
下記を格納するイメージです。

  • コンポーネント本体(例:Hoge.tsx)
  • コンポーネント固有のスタイル(例:Hoge.module.css)

今回はBootstrapを導入しているので、
固有スタイルを定義しなくても大体実装できてしまいますが、
上記のような、CSS Modulesを使って書くことで、
クラス名が一意になり名前衝突を回避できるし、スタイルの変更がしやすくなります。

Reactは、親コンポーネントから子コンポーネントへ値を渡すための仕組みとして
「Props」というものがあります。
「Props」の型定義は、各コンポーネントファイルの中に書くようにしました。

削除確認モーダル

削除ボタンを押下した際の削除確認は、
React Bootstrapの「Modal」を使うようにしました。
一覧画面とボード画面で利用するので、コンポーネント化しています。

Propsとしては下記を受け取ります。

  • id: 削除するタスクのID
  • show: 表示するか否か
  • onHide: モーダルを閉じた際に実行する関数
  • onDelete: 削除処理を実行する関数

参考:React Bootstrap - Modals -

ソースコード
frontend/src/components/DeleteModal/DeleteModal.tsx
import { Button, Modal } from 'react-bootstrap';

type DeleteModalProps = {
    id: number;
    show: boolean;
    onHide: () => void;
    onDelete: (id: number) => Promise<void>;
}

function DeleteModal(props: DeleteModalProps) {
    return (
        <Modal show={props.show} onHide={props.onHide}>
            <Modal.Header>
                <Modal.Title className="fs-6">削除確認</Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <p className="fs-6">削除してもよろしいですか?</p>
            </Modal.Body>
            <Modal.Footer>
                <Button size="sm" className="w-100px" variant="secondary" onClick={props.onHide}>
                    キャンセル
                </Button>
                <Button size="sm" className="w-100px" variant="danger" onClick={() => props.onDelete(props.id)}>
                    削除
                </Button>
            </Modal.Footer>
        </Modal>
    )
}

export default DeleteModal

入力フォーム

新規登録と編集で入力する内容は同じのため、
入力フォームをコンポーネント化して読み込むようにしています。

Propsとしては下記を受け取ります。

  • title: 画面上部のタイトル表示文字
  • initializeFunction: 初期処理(省略可能)
  • callApiFunction: 登録 or 更新ボタンを押下した際に実行するAPIの呼び出し関数
  • displayButtonName: 画面下部のAPI呼び出しを行うボタンの表示名(登録 or 更新)
  • defaultValues: デフォルトの入力値(省略可能)
ソースコード
frontend/src/components/Form/TaskInputForm.tsx
import { useEffect, useState } from "react";
import { Button, Card, Col, Form, Row } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { Constants } from "../../common/constants/constants";
import type { TaskRequestEntity } from "../../entities/Task";
import { RadioFuncitons } from "../../functions/RadioFunctions";
import RadioButton from "../RadioButton/RadioButton";

export class TaskInputFormDefaultValues {
    id: string;
    task: string;
    content: string;
    status: string;
    deadlineDate: string;
    deadlineTime: string;

    constructor(id: string, task: string, content: string, status: string, deadlineDate: string, deadlineTime: string) {
        this.id = id;
        this.task = task;
        this.content = content;
        this.status = status;
        this.deadlineDate = deadlineDate;
        this.deadlineTime = deadlineTime;
    }
}

type TaskInputFormProps = {
    title: string;
    initializeFunction?: () => Promise<void>;
    callApiFunction: (entity: TaskRequestEntity, id: string) => Promise<void>;
    displayButtonName: string;
    defaultValues?: TaskInputFormDefaultValues;
}

function TaskInputForm(props: TaskInputFormProps) {
    const [id, setId] = useState("");
    const [task, setTask] = useState("");
    const [content, setContent] = useState("");
    const [deadlineDate, setDeadlineDate] = useState("");
    const [deadlineTime, setDeadlineTime] = useState("");
    const [status, setStatus] = useState("0");

    const navigate = useNavigate();

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();  // ページリロードを防ぐ

        let deadline: string = "";
        if (deadlineDate !== "") {
            const time = deadlineTime || "00:00";
            deadline = deadlineDate + " " + time;
        }
        const entity: TaskRequestEntity = {
            task: task,
            content: content,
            deadline: deadline,
            status: parseInt(status)
        };

        await props.callApiFunction(entity, id);
        navigate(Constants.Routes.HOME);
    };

    useEffect(() => {
        (async function () {
            // 初期処理呼び出し
            if (props.initializeFunction !== undefined) {
                await props.initializeFunction();
            }

            // デフォルト値の設定
            if (props.defaultValues !== undefined) {
                setId(props.defaultValues.id);
                setTask(props.defaultValues.task);
                setContent(props.defaultValues.content);
                setStatus(props.defaultValues.status);
                setDeadlineDate(props.defaultValues.deadlineDate);
                setDeadlineTime(props.defaultValues.deadlineTime);
            }
        })();
    }, []);

    return (
        <>
            <Row className="gx-0">
                <Col className="col-12 col-lg-11 col-xl-10 col-xxl-9 mx-auto">
                    <Card>
                        <Card.Body>
                            <h3 className="text-start mb-5 ps-3" style={{ borderLeft: "solid 10px #0d6efd" }}>{props.title}</h3>
                            <Form onSubmit={handleSubmit}>
                                <Form.Group as={Row} className="mb-3">
                                    <Form.Label className="col-2">タスク</Form.Label>
                                    <Col className="col-10">
                                        <Form.Control size="sm" type="text" value={task} onChange={(e) => setTask(e.target.value)} />
                                    </Col>
                                </Form.Group>

                                <Form.Group as={Row} className="mb-3">
                                    <Form.Label className="col-2">内容</Form.Label>
                                    <Col className="col-10">
                                        <Form.Control size="sm" as="textarea" rows={20} value={content} onChange={(e) => setContent(e.target.value)} />
                                    </Col>
                                </Form.Group>

                                <Form.Group as={Row} className="mb-3">
                                    <Form.Label className="col-2">ステータス</Form.Label>
                                    <Col className="col-10">
                                        <RadioButton radios={RadioFuncitons.createTaskStatusEntities()} defaultValue={status} setRadioValue={setStatus} />
                                    </Col>
                                </Form.Group>

                                <Form.Group as={Row} className="mb-5">
                                    <Form.Label className="col-2">期限</Form.Label>
                                    <Col className="col-10">
                                        <div className="d-flex">
                                            <Form.Control size="sm" className="me-2" type="date" value={deadlineDate} onChange={(e) => setDeadlineDate(e.target.value)} />
                                            <Form.Control size="sm" type="time" value={deadlineTime} onChange={(e) => setDeadlineTime(e.target.value)} />
                                        </div>
                                    </Col>
                                </Form.Group>

                                <Form.Group as={Row}>
                                    <Col className="text-end">
                                        <Button size="sm" className="me-2 w-100px" variant="secondary" onClick={() => navigate(Constants.Routes.HOME)}>キャンセル</Button>
                                        <Button size="sm" className="w-100px" variant="primary" type="submit">{props.displayButtonName}</Button>
                                    </Col>
                                </Form.Group>
                            </Form>
                        </Card.Body>
                    </Card>
                </Col>
            </Row>
        </>
    );
}

export default TaskInputForm

ヘッダ

ヘッダは全画面に表示するため、
トップレベルのコンポーネントである「App.tsx」にて読み込んでいます。
React Bootstrapに「Navbar」というのが用意されているので、
公式を参考に実装してます。
<Navbar.Brand>と<Nav.Link>は画面の読み込みが入ってしまうので、
今回はreact-router-domの<Link>を使用しています。

参考:React Bootstrap - Navbars -

ソースコード
frontend/src/components/Header/Header.tsx
import { Nav, Navbar } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { Constants } from '../../common/constants/constants';

function Header() {
    return (
        <Navbar className="px-2 fixed-top" collapseOnSelect expand="lg" bg="dark" variant="dark">
            <Link to={Constants.Routes.HOME} className="navbar-brand">TodoApp</Link>
            <Navbar.Toggle aria-controls="responsive-navbar-nav" />
            <Navbar.Collapse id="responsive-navbar-nav">
                <Nav className="mr-auto">
                    <Link to={Constants.Routes.HOME} className="nav-link">一覧</Link>
                    <Link to={Constants.Routes.BOARD} className="nav-link">ボード</Link>
                    <Link to={Constants.Routes.ADD} className="nav-link">新規登録</Link>
                </Nav>
            </Navbar.Collapse>
        </Navbar >
    );
}

export default Header

ラジオボタンコンポーネント

現状は、ステータスしかラジオボタンで入力する項目がないので、
入力フォームのコンポーネントに直接実装しても良かったのですが、
今後増えてきた場合に作りやすいようにコンポーネント化しました。

Propsとしては下記を受け取ります。

  • radios: ラジオボタンエンティティ
  • defaultValue: 初期値
  • setRadioValue: ラジオボタンの選択を変更した際に実行する関数

参考:React Bootstrap - Radio -

ソースコード
frontend/src/components/RadioButton/RadioButton.tsx
import { ButtonGroup, ToggleButton } from "react-bootstrap";
import type { Radio } from "../../common/entities/radio";

type RadioProps = {
    radios: Radio[];
    defaultValue: string;
    setRadioValue: (value: string) => void;
}

function RadioButton(props: RadioProps) {
    return (
        <ButtonGroup className="col-12" size="sm">
            {props.radios.map((radio, index) => (
                <ToggleButton
                    key={index}
                    id={`radio-${index}`}
                    type="radio"
                    variant="outline-primary"
                    name="radio"
                    value={radio.value}
                    checked={props.defaultValue === radio.value}
                    onChange={(e) => props.setRadioValue(e.currentTarget.value)}
                >
                    {radio.name}
                </ToggleButton>
            ))}
        </ButtonGroup>
    );
}

export default RadioButton

ボード画面のタスク表示用コンポーネント

ボード画面で各タスクを表示するためのコンポーネントになります。
React Bootstrapの「Card」を使用しており、
ステータスごとに微妙に表示が異なりますが、
作りは同じなのでコンポーネント化しました。
渡されたステータスに該当するタスクのみをグループ化して表示します。

Propsとしては下記を受け取ります。

  • variant: Cardのスタイル(React BootstrapのButtonなどを真似しました)
  • status: ステータス
  • statusText: ステータスの表示文言
  • tasks: タスク情報
  • openDeleteModal: 削除ボタン押下した際のモーダル表示関数

参考:React Bootstrap - Cards -

ソースコード

■ コンポーネント本体

frontend/src/components/TaskCards/TaskCards.tsx
import { Badge, Button, Card } from "react-bootstrap";
import { MdDelete } from "react-icons/md";
import { Link } from "react-router-dom";
import { Constants } from "../../common/constants/constants";
import { formatDate } from "../../common/utils/dateUtil";
import type { TaskResponseEntity } from "../../entities/Task";
import styles from "./TaskCards.module.css";

type TaskCardsProps = {
    variant: "Primary" | "Secondary" | "Success" | "Warning" | "Danger" | "Info"
    status: number;
    statusText: string;
    tasks: TaskResponseEntity[];
    openDeleteModal: (id: number) => void;
}

function TaskCards(props: TaskCardsProps) {
    return (
        <Card className={`${styles["taskCardGroup"]} ${styles[`taskCardGroup${props.variant}`]}`}>
            <Card.Body>
                <Card.Title className="fs-6">
                    <span className="align-middle">{props.statusText}</span>
                    <Badge pill bg="primary" className="ms-2">
                        {props.tasks.filter((task) => task.status === props.status).length}
                    </Badge>
                </Card.Title>
                {props.tasks.map((task) => {
                    if (task.status === props.status) {
                        return (
                            <Card key={task.id} className="mt-3 small">
                                <Card.Body>
                                    <Card.Title className="fs-6">
                                        <Link to={`${Constants.Routes.EDIT}/${task.id}`}>
                                            {task.task}
                                        </Link>
                                    </Card.Title>
                                    <Card.Text style={{ whiteSpace: "pre-wrap" }}>{task.content}</Card.Text>
                                    <div style={{ display: "flex", justifyContent: "space-between" }}>
                                        <div className="my-auto mx-0">期限:{formatDate(task.deadline) || "未定義"}</div>
                                        <Button size="sm" variant="danger" className="float-end" onClick={() => props.openDeleteModal(task.id)}>
                                            <MdDelete />
                                        </Button>
                                    </div>
                                </Card.Body>
                            </Card>
                        )
                    }
                })}
            </Card.Body>
        </Card>
    );
}

export default TaskCards

■ CSS

frontend/src/components/TaskCards/TaskCards.module.css
.taskCardGroup {
    border: hidden !important;
    border-top-style: solid !important;
    border-top-width: 0.5rem !important;
    background-color: #EFEFEF !important;
}

.taskCardGroupPrimary {
    border-top-color: #0d6efd !important;
}

.taskCardGroupSecondary {
    border-top-color: #6c757d !important;
}

.taskCardGroupSuccess {
    border-top-color: #198754 !important;
}

.taskCardGroupWarning {
    border-top-color: #ffc107 !important;
}

.taskCardGroupDanger {
    border-top-color: #dc3545 !important;
}

.taskCardGroupInfo {
    border-top-color: #0dcaf0 !important;
}

entities: エンティティ定義

commonディレクトリにもentitiesはありますが、
こちらは各処理で使用するObjectの型定義になります。
現状はタスク関連の定義として、APIのリクエストとレスポンスの型を定義しています。
タスク関連以外のAPIが増えた場合などは、ここにファイルが増えていくイメージになります。

ソースコード
frontend/src/entities/Task.tsx
/**
 * タスク関連のAPIのレスポンスエンティティ
 */
export type TaskResponseEntity = {
    id: number;
    task: string;
    content: string;
    deadline: string;
    status: number;
    created_at: string;
    updated_at: string;
    deleted_at: string;
}

/**
 * タスク関連のAPIのリクエストエンティティ
 */
export type TaskRequestEntity = {
    task: string;
    content: string;
    deadline: string;
    status: number;
}

functions: 処理

ラジオボタンに関する処理

ラジオボタンのエンティティ生成処理や表示名の取得処理を実装しています。

ソースコード
frontend/src/functions/RadioFunctions.tsx
import type { Radio } from "../common/entities/radio";

export class RadioFuncitons {
    /**
     * タスクステータスのラジオボタンエンティティの作成
     * @returns 
     */
    public static createTaskStatusEntities(): Radio[] {
        return [
            { name: "未着手", value: "0" },
            { name: "進行中", value: "1" },
            { name: "完了", value: "2" },
            { name: "保留", value: "3" },
        ]
    }

    /**
     * エンティティの中から指定の値に対応する表示名(name)を取得
     * @param radios ラジオボタンエンティティ
     * @param value 値(value)
     * @returns 表示名(name)
     */
    public static getName(radios: Radio[], value: string): string {
        const result = radios.find((radio) => { return radio.value === value })
        if (result === undefined) {
            return "";
        }

        return result.name;
    }
}

タスクAPIの実行処理

タスク関連のAPIの実行関数を実装しています。

ソースコード
frontend/src/functions/TaskFunctions.tsx
import axios from "axios";
import { Constants } from "../common/constants/constants";
import type { TaskRequestEntity, TaskResponseEntity } from "../entities/Task";

/** タスク操作クラス */
export class TaskFunctions {
    /**
     * 全件取得
     * @returns 
     */
    public static async findAll(): Promise<TaskResponseEntity[]> {
        const res: TaskResponseEntity[] = await axios.get(Constants.ApiEndpoint.TASK)
            .then(response => {
                return response.data;
            })
            .catch(error => {
                console.log("error: ", error);
                return null;
            });
        return res;
    }

    /**
     * 取得
     * @param id タスクID
     * @returns 
     */
    public static async findById(id: number): Promise<TaskResponseEntity> {
        const res: TaskResponseEntity = await axios.get(Constants.ApiEndpoint.TASK + id)
            .then(response => {
                return response.data;
            })
            .catch(error => {
                console.log("error: ", error);
                return null;
            });
        return res;
    }

    /**
     * 新規登録
     * @param task 
     */
    public static async create(task: TaskRequestEntity) {
        await axios.post(Constants.ApiEndpoint.TASK, task)
            .catch(error => {
                console.log("error: ", error);
            });
    }

    /**
     * 更新
     * @param task 
     */
    public static async update(id: number, task: TaskRequestEntity) {
        await axios.patch(Constants.ApiEndpoint.TASK + id, task)
            .catch(error => {
                console.log("error: ", error);
            })
    }

    /**
     * 削除
     * @param id タスクID
     */
    public static async delete(id: number) {
        await axios.delete(Constants.ApiEndpoint.TASK + id)
            .catch(error => {
                console.log("error: ", error);
            });
    }
}

pages: 画面

一覧画面のコンポーネント

初期処理(useEffect)でタスクの取得処理を実行し、
取得したデータをReact Bootstrapの「Table」を使用して表示しています。

参考:React Bootstrap - Tables -

ソースコード
frontend/src/pages/GridPage.tsx
import { useEffect, useState } from "react";
import { Badge, Button, Col, Row, Table } from "react-bootstrap";
import { BsFillPlusCircleFill } from "react-icons/bs";
import { MdDelete } from "react-icons/md";
import { Link } from "react-router-dom";
import { Constants } from "../common/constants/constants";
import { formatDate } from "../common/utils/dateUtil";
import DeleteModal from "../components/DeleteModal/DeleteModal";
import type { TaskResponseEntity } from "../entities/Task";
import { RadioFuncitons } from "../functions/RadioFunctions";
import { TaskFunctions } from "../functions/TaskFunctions";

function GridPage() {
    const [tasks, setTasks] = useState<TaskResponseEntity[]>([]);

    useEffect(() => {
        (async function () {
            const tasks = await TaskFunctions.findAll();
            setTasks(tasks === null ? [] : tasks)
        })();
    }, []);

    const [deleteTaskId, setDeleteTaskId] = useState(0);
    const [showModal, setShowModal] = useState(false);

    const openDeleteModal = (id: number) => {
        setDeleteTaskId(id);
        setShowModal(true);
    };

    const closeDeleteModal = () => {
        setShowModal(false);
    };

    const handleDelete = async (id: number) => {
        await TaskFunctions.delete(id)

        const tasks = await TaskFunctions.findAll();
        setTasks(tasks === null ? [] : tasks)
        closeDeleteModal();
    };

    const getTaskStatusColor = (status: number) => {
        switch (status) {
            case 0:
                return "danger";
            case 1:
                return "success";
            case 2:
                return "info";
            case 3:
                return "warning";
        }
    }

    return (
        <div className="page-container">
            <Row className="gx-0">
                <Col className="col-12 col-lg-11 col-xl-10 col-xxl-9 mx-auto">
                    <Row className="gx-0 mb-2">
                        <Col>
                            <Link to={Constants.Routes.ADD}>
                                <Button size="sm" variant="primary">
                                    <BsFillPlusCircleFill />
                                    <span className="ms-2 align-middle">新規登録</span>
                                </Button>
                            </Link>
                        </Col>
                    </Row>
                    <Table striped bordered size="sm">
                        <colgroup>
                            <col className="col-3 w-min-100px" />
                            <col className="col-auto" />
                            <col className="w-100px" />
                            <col className="col-2 w-min-100px" />
                            <col className="w-50px" />
                        </colgroup>
                        <thead>
                            <tr>
                                <th>タスク</th>
                                <th>内容</th>
                                <th>ステータス</th>
                                <th>期限</th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody>
                            {tasks.map((task) => {
                                return (
                                    <tr key={task.id}>
                                        <td className="align-middle" style={{ maxWidth: 0 }}>
                                            <div className="row">
                                                <div className="col text-truncate">
                                                    <Link to={`${Constants.Routes.EDIT}/${task.id}`}>
                                                        {task.task}
                                                    </Link>
                                                </div>
                                            </div>
                                        </td>
                                        <td className="align-middle">
                                            <span style={{ whiteSpace: "pre-wrap" }}>{task.content}</span>
                                        </td>
                                        <td className="text-center align-middle">
                                            <Badge bg={getTaskStatusColor(task.status)} className="fs-6 w-75">
                                                {RadioFuncitons.getName(RadioFuncitons.createTaskStatusEntities(), task.status.toString())}
                                            </Badge>
                                        </td>
                                        <td className="align-middle">{formatDate(task.deadline) || "未定義"}</td>
                                        <td className="text-center align-middle">
                                            <Button size="sm" variant="danger" onClick={() => { openDeleteModal(task.id) }}>
                                                <MdDelete />
                                            </Button>
                                        </td>
                                    </tr>
                                )
                            })}
                        </tbody>
                    </Table>

                </Col>
            </Row>

            {/* 削除確認ダイアログ */}
            <DeleteModal id={deleteTaskId} show={showModal} onHide={closeDeleteModal} onDelete={handleDelete} />
        </div>
    );
}

export default GridPage

新規登録画面のコンポーネント

新規登録画面は特に処理はないので、
新規登録のAPI実行関数を定義し、
入力フォーム(子コンポーネント)に渡してあげるだけになります。

ソースコード
frontend/src/pages/AddPage.tsx
import TaskInputForm from "../components/Form/TaskInputForm";
import type { TaskRequestEntity } from "../entities/Task";
import { TaskFunctions } from "../functions/TaskFunctions";

function AddPage() {
    /**
     * 新規登録APIの呼び出し
     * @param entity リクエストエンティティ
     * @param _id タスクID(省略しても実行可能)
     */
    async function callApi(entity: TaskRequestEntity, _id: string) {
        await TaskFunctions.create(entity);
    }

    return (
        <div className="page-container">
            <TaskInputForm title="新規登録" callApiFunction={callApi} displayButtonName="登録" />
        </div>
    );
}

export default AddPage

編集画面のコンポーネント

編集画面は表示した際に、下記の処理をする必要があります。

  1. パスパラメータのIDを使って、データを取得(FindById呼び出し)
  2. 取得したデータを入力フォームのデフォルト値として設定

画面表示のタイミングで処理を行いたい場合は、
「useEffect」を使用しますが、
親コンポーネントと子コンポーネントの両方にuseEffectを実装した場合に、
子コンポーネントの方が先に実行されます。

多分もっと上手なやり方はあると思いますが、
今回私は、親コンポーネントにはuseEffectは書かず、
代わりに初期処理関数を用意し、
子コンポーネントのuseEffect内で親コンポーネントの初期処理関数をコールするようにしました。

あと、パスパラメータは「useParams」を使用することで取得できます。
パスパラメータにIDがなかった場合に、
一覧画面に強制的に遷移させていますが、
画面遷移したい場合は「useNavigate」を使用します。

ソースコード
frontend/src/pages/EditPage.tsx
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Constants } from "../common/constants/constants";
import { formatDate } from "../common/utils/dateUtil";
import TaskInputForm, { TaskInputFormDefaultValues } from "../components/Form/TaskInputForm";
import type { TaskRequestEntity } from "../entities/Task";
import { TaskFunctions } from "../functions/TaskFunctions";

function EditPage() {
    const navigate = useNavigate();
    const params = useParams();

    const [defaultValues] = useState(new TaskInputFormDefaultValues("", "", "", "0", "", ""));

    /**
     * API呼び出し
     * @param entity 
     * @param id 
     */
    async function callApi(entity: TaskRequestEntity, id: string) {
        await TaskFunctions.update(parseInt(id), entity);
    }

    /**
     * 初期処理
     * @returns 
     */
    async function initialize() {
        if (params.id === undefined) {
            navigate(Constants.Routes.HOME);
            return
        }

        const taskId = parseInt(params.id);
        const task = await TaskFunctions.findById(taskId);

        defaultValues.id = task.id.toString();
        defaultValues.task = task.task;
        defaultValues.content = task.content;
        defaultValues.status = task.status.toString();

        if (task.deadline !== "") {
            const formattedDate = formatDate(task.deadline);
            defaultValues.deadlineDate = formattedDate.split(" ")[0];
            defaultValues.deadlineTime = formattedDate.split(" ")[1];
        }
    }

    return (
        <div className="page-container">
            <TaskInputForm title="編集" initializeFunction={initialize} callApiFunction={callApi} defaultValues={defaultValues} displayButtonName="更新" />
        </div>
    );
}

export default EditPage

ボード画面のコンポーネント

一覧画面と表示方法が異なるだけで、やっていることは同じです。

ソースコード
frontend/src/pages/BoardPage.tsx
import { useEffect, useState } from "react";
import { Button, Col, Row } from "react-bootstrap";
import { BsFillPlusCircleFill } from "react-icons/bs";
import { Link } from "react-router-dom";
import { Constants } from "../common/constants/constants";
import DeleteModal from "../components/DeleteModal/DeleteModal";
import TaskCards from "../components/TaskCards/TaskCards";
import type { TaskResponseEntity } from "../entities/Task";
import { TaskFunctions } from "../functions/TaskFunctions";

function BoardPage() {
    const [tasks, setTasks] = useState<TaskResponseEntity[]>([]);

    useEffect(() => {
        (async function () {
            const tasks = await TaskFunctions.findAll();
            setTasks(tasks === null ? [] : tasks)
        })();
    }, []);

    const [deleteTaskId, setDeleteTaskId] = useState(0);
    const [showModal, setShowModal] = useState(false);

    const openDeleteModal = (id: number) => {
        setDeleteTaskId(id);
        setShowModal(true);
    };

    const closeDeleteModal = () => {
        setShowModal(false);
    };

    const handleDelete = async (id: number) => {
        await TaskFunctions.delete(id)

        const tasks = await TaskFunctions.findAll();
        setTasks(tasks === null ? [] : tasks)
        closeDeleteModal();
    };

    return (
        <div className="page-container">
            <Row className="gx-0">
                <Col className="col-12 col-lg-11 col-xl-10 col-xxl-9 mx-auto">
                    <Row className="gx-0 mb-2">
                        <Col>
                            <Link to={Constants.Routes.ADD}>
                                <Button size="sm" variant="primary">
                                    <BsFillPlusCircleFill />
                                    <span className="ms-2 align-middle">新規登録</span>
                                </Button>
                            </Link>
                        </Col>
                    </Row>
                    <Row className="gx-0">
                        <Col className="me-1">
                            <TaskCards variant="Danger" status={0} statusText="未着手" openDeleteModal={openDeleteModal} tasks={tasks} />
                        </Col>
                        <Col className="mx-1">
                            <TaskCards variant="Success" status={1} statusText="進行中" openDeleteModal={openDeleteModal} tasks={tasks} />
                        </Col>
                        <Col className="mx-1">
                            <TaskCards variant="Info" status={2} statusText="完了" openDeleteModal={openDeleteModal} tasks={tasks} />
                        </Col>
                        <Col className="ms-1">
                            <TaskCards variant="Warning" status={3} statusText="保留" openDeleteModal={openDeleteModal} tasks={tasks} />
                        </Col>
                    </Row>
                </Col>
            </Row>

            {/* 削除確認ダイアログ */}
            <DeleteModal id={deleteTaskId} show={showModal} onHide={closeDeleteModal} onDelete={handleDelete} />
        </div>
    );
}

export default BoardPage

css関連

index.css

index.cssは、プロジェクト作成時に作られます。
今回は使いませんのでファイルの中の記述は全て削除します。

App.css

App.cssは、プロジェクト作成時に作られます。
元々の記述は全て削除し、
アプリ全体で使う共通的なスタイルの定義に使っています。

ソースコード
frontend/src/App.css
/* どこでも使用可能 */
:root {
    /* ヘッダの高さ定義 */
    --header-height: 56px;
}

.page-container {
    padding: 3rem 0.5rem;
    margin-top: var(--header-height);
    height: calc(100vh - var(--header-height));
    overflow: auto;
    overflow-x: hidden;
}

.w-50px {
    width: 50px;
}

.w-100px {
    width: 100px;
}

.w-min-100px {
    min-width: 100px;
}

トップレベルのコンポーネント修正

App.tsxは、プロジェクト作成時に作られます。
元々の記述は全て削除し、下記に書き換えます。
Headerコンポーネントの読み込みとルーティングの設定をしています。

ソースコード
frontend/src/App.tsx
import 'bootstrap/dist/css/bootstrap.min.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import { Constants } from './common/constants/constants';
import Header from './components/Header/Header';
import AddPage from './pages/AddPage';
import BoardPage from './pages/BoardPage';
import EditPage from './pages/EditPage';
import GridPage from './pages/GridPage';

function App() {
    return (
        <BrowserRouter>
            <Header />
            <Routes>
                <Route path={Constants.Routes.HOME} element={<GridPage />} />
                <Route path={Constants.Routes.ADD} element={<AddPage />} />
                <Route path={`${Constants.Routes.EDIT}/:id`} element={<EditPage />} />
                <Route path={Constants.Routes.BOARD} element={<BoardPage />} />
            </Routes>
        </BrowserRouter>
    );
}

export default App

実装完了

以上で一通りの実装が完了です。
bootstarp使って作っているので、
画面サイズを縮めてもある程度はレイアウト崩れないようになっていると思います。
検索機能や一覧のページング機能など付けれなかった機能もありますが、今回はここまで。

一覧画面

新規登録画面

編集画面

ボード画面

削除確認モーダル

91works Tech Blog

Discussion