プログラミング

メモアプリ開発② メモ機能

この記事は約53分で読めます。

メモアプリ「Simpl」の開発手順をまとめるシリーズ
第2弾はメモ機能です。

ユーザーと紐づけたメモを扱います。


これまでの学習内容については以下の記事にまとめています。

はじめに

今回追加する機能は以下のとおりです。

  1. メモの取得
  2. メモの作成
  3. メモの更新
  4. メモの削除

作業の流れを以下のように分類してまとめます。

  • 【設計】要件定義
  • 【設計】データベース設計
  • 【設計】API設計
  • 【設計】デザインカンプ
  • 【設計】コンポーネント設計
  • 【MySQL】テーブルの追加
  • 【Spring Boot】バックエンド処理の追加
  • 【React】フロントエンド処理の追加

【設計】要件定義

メモに関する4つの機能について、簡易的な要件を定めます。

メモの取得
ユーザーがサインインすると自動的にページが遷移され、ユーザーのメモが表示されます。

メモの作成
ユーザーは新規作成フォームからメモを作成します。
タイトルとメモの内容を入力します。
EnterもしくはEscもしくはメモ外を押下で作成します。

メモの更新
ユーザーは表示されているメモを押下して編集を開始します。
EnterもしくはEscもしくはメモ外を押下で更新します。

メモの削除
ユーザーはゴミ箱アイコンを押下してメモを削除します。

30日保管するゴミ箱フォルダや、削除直後に復元する機能は今回は実装しません。

【設計】データベース設計

memosテーブルが外部キーのuser_idを持ち、ユーザーとメモを紐付けます。

ER図

【設計】API設計

ユーザーとメモを一意に識別するためにパスパラメーターを使用します。

【設計】デザインカンプ

Figmaで作成しました。

  • 1枚目:メモ一覧画面
  • 2枚目:メモhover時
  • 3枚目:メモactive時
  • 4枚目:メモ作成フォームhover時
  • 5枚目:メモ作成フォームactive時

【設計】コンポーネント設計

デザインカンプをもとにコンポーネントを設計します。

【MySQL】テーブルの追加

データベース設計書に従ってテーブルを作成します。

SQL
CREATE TABLE memos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    title VARCHAR(512) NOT NULL,
    content VARCHAR(1024) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);
Zsh
mysql> CREATE TABLE memos (
    ->     id INT AUTO_INCREMENT PRIMARY KEY,
    ->     user_id INT NOT NULL,
    ->     title VARCHAR(512) NOT NULL,
    ->     content VARCHAR(1024) NOT NULL,
    ->     created_at DATETIME NOT NULL,
    ->     updated_at DATETIME NOT NULL,
    ->     FOREIGN KEY (user_id) REFERENCES users(id)
    -> );
Query OK, 0 rows affected (0.03 sec)

mysql> show tables;
+-----------------------+
| Tables_in_simple_memo |
+-----------------------+
| memos                 |
| users                 |
+-----------------------+
2 rows in set (0.00 sec)

mysql> DESCRIBE memos;
+------------+---------------+------+-----+---------+----------------+
| Field      | Type          | Null | Key | Default | Extra          |
+------------+---------------+------+-----+---------+----------------+
| id         | int           | NO   | PRI | NULL    | auto_increment |
| user_id    | int           | NO   | MUL | NULL    |                |
| title      | varchar(512)  | NO   |     | NULL    |                |
| content    | varchar(1024) | NO   |     | NULL    |                |
| created_at | datetime      | NO   |     | NULL    |                |
| updated_at | datetime      | NO   |     | NULL    |                |
+------------+---------------+------+-----+---------+----------------+
6 rows in set (0.01 sec)

【Spring Boot】バックエンド処理の追加

API設計に従ってバックエンド処理を作成します。

プロジェクト構成

既存のプロジェクトに以下のように追加します。

└── example
    └── simplememo
        ├── SimpleMemoApplication.java
        ├── config
           └── WebConfig.java
        ├── controller
           ├── UserController.java
           └── MemoController.java # 追加
        ├── mapper
           ├── UserMapper.java
           └── MemoMapper.java # 追加
        ├── model
           ├── User.java
           └── Memo.java # 追加
        └── service
            ├── UserService.java
            └── MemoService.java # 追加

モデルクラスの作成

データベースのレコードやメソッドの引数データを格納するデータモデルを作成します。

memosテーブルのレコードを格納するMemoクラス

src/main/java/com/example/simplememo/model/Memo.java
package com.example.simplememo.model;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class Memo {
    private int id;
    private int userId;
    private String title;
    private String content;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    public Memo(int id, int userId, String title, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
        this.id = id;
        this.userId = userId;
        this.title = title;
        this.content = content;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }
}

メモの登録と修正のリクエストを格納するMemoRequestクラス

src/main/java/com/example/simplememo/controller/MemoRequest.java
package com.example.simplememo.controller;

import lombok.Data;

@Data
public class MemoRequest {
    private String title;
    private String content;
}

MemoMapperの作成

データベースとの直接的なやり取りを担当するMemoMapperを作成します。

src/main/java/com/example/simplememo/mapper/MemoMapper.java
package com.example.simplememo.mapper;

import com.example.simplememo.model.Memo;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface MemoMapper {
    @Select("SELECT * FROM memos WHERE user_id = #{userId}")
    List<Memo> getMemosByUserId(int userId);

    @Select("SELECT * FROM memos WHERE id = #{id} AND user_id = #{userId}")
    Memo findMemo(@Param("userId") int userId, @Param("id") int id);

    @Insert("INSERT INTO memos(user_id, title, content, created_at, updated_at) VALUES(#{userId}, #{memo.title}, #{memo.content}, #{memo.createdAt}, #{memo.updatedAt})")
    void insertMemo(@Param("userId") int userId, @Param("memo") Memo memo);

    @Update("UPDATE memos SET title=#{title}, content=#{content}, updated_at=#{updatedAt} WHERE id=#{id} AND user_id = #{userId}")
    void updateMemo(@Param("userId") int userId, @Param("id") int id, @Param("memo") Memo memo);

    @Delete("DELETE FROM memos WHERE id=#{id} AND user_id = #{userId}")
    void deleteMemo(@Param("userId") int userId, @Param("id") int id);
}

insertMemo メソッドのように複数のパラメータ(int userId と Memo memo)を受け取る場合、MyBatis は引数を認識するために @Param アノテーションが必要です。これにより、パラメータ名を明示的に指定できます。

MemoServiceの作成

MapperとControllerの間でビジネスロジックの処理を行うMemoServiceを作成します。

src/main/java/com/example/simplememo/service/MemoService.java
package com.example.simplememo.service;

import java.time.LocalDateTime;

import com.example.simplememo.controller.MemoRequest;
import com.example.simplememo.mapper.MemoMapper;
import com.example.simplememo.model.Memo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class MemoService {
    @Autowired
    private MemoMapper memoMapper;

    public List<Memo> getMemosByUserId(int userId) {
        return memoMapper.getMemosByUserId(userId);
    }

    public Memo findMemo(int userID, int id) {
        return memoMapper.findMemo(userID, id);
    }

    public void insertMemo(int userId, MemoRequest request) {
        System.out.println("receive request... userId:" + userId + ", request:" + request);
        Memo memo = new Memo(0, userId, request.getTitle(), request.getContent(), LocalDateTime.now(),
                LocalDateTime.now());
        System.out.println(memo);
        try {
            memoMapper.insertMemo(userId, memo);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("メモの登録に失敗しました。");
        }
    }

    public void updateMemo(int userId, int id, MemoRequest request) {
        try {
            Memo memo = memoMapper.findMemo(userId, id);
            if (memo == null) {
                throw new RuntimeException("メモが見つかりません。");
            }
            Memo updateMemo = new Memo(id, userId, request.getTitle(), request.getContent(), memo.getCreatedAt(),
                    LocalDateTime.now());
            memoMapper.updateMemo(userId, id, updateMemo);
        } catch (Exception e) {
            throw new RuntimeException("メモの更新に失敗しました。");
        }
    }

    public void deleteMemo(int userId, int id) {
        try {
            memoMapper.deleteMemo(userId, id);
        } catch (Exception e) {
            throw new RuntimeException("メモの削除に失敗しました。");
        }
    }
}

MemoControllerの作成

クライアントからリクエストを受け取り、適切なサービスやデータモデルを呼び出して処理を行い、レスポンスを返すMemoControllerを作成します。

src/main/java/com/example/simplememo/controller/MemoController.java
package com.example.simplememo.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.simplememo.model.Memo;
import com.example.simplememo.service.MemoService;

@RestController
@RequestMapping("/api/users/{user_id}/memos")
public class MemoController {
    @Autowired
    private MemoService memoService;

    @GetMapping
    public ResponseEntity<List<Memo>> getMemosByUserId(@PathVariable("user_id") int userId) {
        System.out.println("receive request");
        return ResponseEntity.ok(memoService.getMemosByUserId(userId));
    }

    @PostMapping
    public ResponseEntity<Integer> createMemo(@PathVariable int userId, @RequestBody MemoRequest request) {
        try {
            memoService.insertMemo(userId, request);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    @PutMapping("/{id}")
    public ResponseEntity<Void> updateMemo(@PathVariable int userId, @PathVariable int id, @RequestBody MemoRequest request) {
        try {
            memoService.updateMemo(userId, id, request);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMemo(@PathVariable int userId, @PathVariable int id) {
        try {
            memoService.deleteMemo(userId, id);
            return ResponseEntity.noContent().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }
}

動作確認

curlコマンドでAPIの動作を確認します。

Zsh
# メモの作成
$ curl -i -X POST "http://localhost:8080/api/users/1/memos" \
-H "Content-Type: application/json" \
-d '{
  "title": "フロントエンドのテスト",
  "content": "単体テスト(個々のコンポーネントや関数)\n結合テスト(複数のコンポーネントや機能)"
}'
HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Length: 0
Date: Wed, 03 Jul 2024 23:19:51 GMT


# メモの取得(確認)
$ curl -i -X GET "http://localhost:8080/api/users/1/memos"
HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 03 Jul 2024 23:25:57 GMT

[{"id":2,"userId":6,"title":"フロントエンドのテスト","content":"単体テスト(個々のコンポーネントや関数)\n結合テスト(複数のコンポーネントや機能)","createdAt":"2024-07-04T08:19:51","updatedAt":"2024-07-04T08:19:51"}]%    

# メモの更新
$ curl -i -X PUT "http://localhost:8080/api/users/1/memos/1" \
-H "Content-Type: application/json" \
-d '{
  "title": "フロントエンドのテスト",
  "content": "単体テスト(個々のコンポーネントや関数)\n結合テスト(複数のコンポーネントや機能)\nE2Eテスト(アプリケーション全体)"
}'
TTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Length: 0
Date: Wed, 03 Jul 2024 23:52:45 GMT

# メモの取得(確認) 
$ curl -i -X GET "http://localhost:8080/api/users/1/memos"
HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 03 Jul 2024 23:53:15 GMT

[{"id":2,"userId":6,"title":"フロントエンドのテスト","content":"単体テスト(個々のコンポーネントや関数)\n結合テスト(複数のコンポーネントや機能)\nE2Eテスト(アプリケーション全体)","createdAt":"2024-07-04T08:19:51","updatedAt":"2024-07-04T08:52:46"}]% 

# メモの削除
$ curl -i -X DELETE "http://localhost:8080/api/users/1/memos/1"
HTTP/1.1 204 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Date: Wed, 03 Jul 2024 23:53:38 GMT

# メモの取得(確認)
$ curl -i -X GET "http://localhost:8080/api/users/1/memos"
HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 03 Jul 2024 23:53:51 GMT

[]%     

【React】フロントエンド処理の追加

コンポーネント構造

メモに関するオブジェクトを格納するMemoProviderを追加します。

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main
            ├── AuthContainer
               ├── SignIn
               └── SignUp
            └── MemoProvider # ココ以降が今回のメイン
                └── MemoContainer
                    ├── NewMemoRow
                    └── MemoTable
                        └── MemoRow

このコンポーネント構造に従って実装していきます。
AuthProviderとHeader配下は変更しません。

Main

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main # ココ
            ├── AuthContainer
               ├── SignIn
               └── SignUp
            └── MemoProvider
                └── MemoContainer
                    ├── NewMemoRow
                    └── MemoTable
                        └── MemoRow

認証フォームやメモ一覧を表示するコンポーネントです。

AuthContextから以下を取得しています。

  • isSignedIn:ユーザーのサインイン状態に応じてコンポーネントを切り替えます。
src/components/Main/Main.tsx
import AuthContainer from "./AuthContainer/AuthContainer";
import { useAuthContext } from "../../context/AuthContext";
import MemoContainer from "./MemoContainer/MemoContainer";
import { MemoProvider } from "../../context/MemoContext";

const Main: React.VFC = () => {
  const { isSignedIn } = useAuthContext();
  return (
    <div className="main">
      {isSignedIn ? (
        <MemoProvider>
          <MemoContainer />
        </MemoProvider>
      ) : (
        <AuthContainer />
      )}
    </div>
  );
};

export default Main;

MemoProvider

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main 
            ├── AuthContainer
               ├── SignIn
               └── SignUp
            └── MemoProvider # ココ
                └── MemoContainer
                    ├── NewMemoRow
                    └── MemoTable
                        └── MemoRow

Mainコンポーネントの直下にMemoProviderコンポーネントを作成します。

以下の状態や関数をMemoContextで提供します。
詳細は使用するコンポーネントとともに解説します。

  • originalMemos / setOriginalMemos:ユーザーに紐づいたメモを管理します。
  • handleMemoUpdate:メモの作成・更新の処理を実行します。
  • handleMemoDelete:メモの削除の処理を実行します。
  • handleTitleKeyDown:メモのタイトル入力時のキー操作処理を実行します。
  • handleContentKeyDown:メモのコンテンツ入力時のキー操作処理を実行します。
  • handleRowClick:メモをクリックした時の処理を実行します。
  • newMemo / setNewMemo:作成するメモを管理します。
src/context/MemoContext.tsx
import {
  ReactNode,
  RefObject,
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import { Memo } from "../Types";

// Contextオブジェクトの型
interface MemoContextProps {
  originalMemos: Memo[];
  handleMemoUpdate: (targetMemo: Memo) => void;
  handleMemoDelete: (id: number) => void;
  handleTitleKeyDown: (
    e: React.KeyboardEvent,
    titleRef: RefObject<HTMLInputElement>,
    contentRef: RefObject<HTMLTextAreaElement>,
    targetMemo: Memo
  ) => void;
  handleContentKeyDown: (
    e: React.KeyboardEvent,
    titleRef: RefObject<HTMLInputElement>,
    contentRef: RefObject<HTMLTextAreaElement>,
    targetMemo: Memo
  ) => void;
  handleRowClick: (
    e: React.MouseEvent<HTMLDivElement>,
    titleRef: RefObject<HTMLInputElement>
  ) => void;
  newMemo: Memo;
  setNewMemo: (memo: Memo) => void;
}

const MemoContext = createContext<MemoContextProps | undefined>(undefined);

const MemoProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [originalMemos, setOriginalMemos] = useState<Memo[]>([]); // データベースから取得したメモの一覧
  const [userId, setUserId] = useState("");
  const initialMemo: Memo = {
    id: -1,
    title: "",
    content: "",
    createdAt: "",
    updatedAt: "",
  };
  const [newMemo, setNewMemo] = useState<Memo>(initialMemo);

  useEffect(() => {
    const userInfo = localStorage.getItem("userInfo");
    if (!userInfo) return;
    setUserId(JSON.parse(userInfo).id);
  }, []);

  useEffect(() => {
    if (userId) fetchAndSetMemos();
  }, [userId]);

  // GET:メモ一覧を取得
  const fetchMemos = async () => {
    try {
      const response = await fetch(
        `${process.env.REACT_APP_API_BASE_URL}/api/users/${userId}/memos`
      );
      if (!response.ok) throw new Error("Failed to fetch memos");
      const memos = await response.json();
      // 更新日時順にソート
      memos.sort((a: Memo, b: Memo) => {
        if (a.createdAt < b.createdAt) return 1;
        if (a.createdAt > b.createdAt) return -1;
        return 0;
      });
      return memos;
    } catch (error) {
      console.error("Error fetching memos:", error);
    }
  };

  const fetchAndSetMemos = async () => {
    const memos = await fetchMemos();
    setOriginalMemos(memos);
  };

  // PUT・POST:メモを更新・作成
  const handleMemoUpdate = async (targetMemo: Memo) => {
    // タイトルと内容のどちらかが入力されていない場合は何もしない
    if (!targetMemo.title && !targetMemo.content) return;

    // タイトルと内容が変更されていない場合は何もしない
    const originalMemo = originalMemos.find(
      (memo) => memo.id === targetMemo.id
    );
    if (
      originalMemo?.title === targetMemo.title &&
      originalMemo?.content === targetMemo.content
    ) {
      return;
    }

    const method = targetMemo.createdAt ? "PUT" : "POST";
    const url =
      method === "PUT"
        ? `${process.env.REACT_APP_API_BASE_URL}/api/users/${userId}/memos/${targetMemo.id}` // PUT
        : `${process.env.REACT_APP_API_BASE_URL}/api/users/${userId}/memos`; // POST

    try {
      const response = await fetch(url, {
        method,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          title: targetMemo.title,
          content: targetMemo.content,
        }),
      });
      if (!response.ok)
        throw new Error(
          `Failed to ${method === "PUT" ? "update" : "add"} memo`
        );
      fetchAndSetMemos();
      setNewMemo(initialMemo);
    } catch (error) {
      console.error(
        `Error ${method === "PUT" ? "updating" : "adding"} memo:`,
        error
      );
    }
  };

  // DELETE:メモを削除
  const handleMemoDelete = async (id: number) => {
    console.log("handleMemoDelete");
    console.log(
      `${process.env.REACT_APP_API_BASE_URL}/api/users/${userId}/memos/${id}`
    );
    try {
      const response = await fetch(
        `${process.env.REACT_APP_API_BASE_URL}/api/users/${userId}/memos/${id}`,
        {
          method: "DELETE",
        }
      );
      if (!response.ok) throw new Error(`Failed to delete memos id: ${id}`);
      fetchAndSetMemos();
    } catch (error) {
      console.error("Error deleting memo:", error);
    }
  };

  const handleTitleKeyDown = (
    e: React.KeyboardEvent,
    titleRef: RefObject<HTMLInputElement>,
    contentRef: RefObject<HTMLTextAreaElement>,
    targetMemo: Memo
  ) => {
    // Command + Enter, Control + Enterで作成
    if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
      e.preventDefault(); // デフォルトの動作を無効化
      titleRef.current?.blur();
      handleMemoUpdate(targetMemo);
      return;
    }
    // Enter, Tabでコンテンツ入力欄にフォーカス
    if (
      (e.key === "Enter" && !e.nativeEvent.isComposing) ||
      (e.key === "Tab" && !e.shiftKey)
    ) {
      e.preventDefault();
      if (contentRef.current) {
        contentRef.current.focus();
        contentRef.current.select();
        return;
      }
    }

    // 変換時を除く下矢印押下でコンテンツ入力欄にフォーカス
    if (e.key === "ArrowDown" && !e.nativeEvent.isComposing) {
      e.preventDefault();
      if (contentRef.current) {
        contentRef.current.focus();
        return;
      }
    }
  };

  // コンテンツ入力中のキーボード押下ハンドラ
  const handleContentKeyDown = (
    e: React.KeyboardEvent,
    titleRef: RefObject<HTMLInputElement>,
    contentRef: RefObject<HTMLTextAreaElement>,
    targetMemo: Memo
  ) => {
    // Command + Enter, Control + Enterで作成
    if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
      e.preventDefault(); // デフォルトの動作を無効化
      contentRef.current?.blur();
      handleMemoUpdate(targetMemo);
      return;
    }
  };

  const handleRowClick = (
    e: React.MouseEvent<HTMLDivElement>,
    titleRef: RefObject<HTMLInputElement>
  ) => {
    // クリック対象がpathの場合
    const target = e.target as HTMLElement;
    console.log((e.target as HTMLElement).tagName);
    // クリック対象が動的要素ではない場合
    if (
      !target.classList.contains("memo__form") && // 入力欄
      !target.classList.contains("memo__delete") && // 削除アイコンのラッパー
      !(target.tagName === "path") // 削除アイコン(path)
    ) {
      // タイトル入力欄にフォーカス
      titleRef.current?.focus();
      titleRef.current?.select();
    }
  };

  return (
    <MemoContext.Provider
      value={{
        originalMemos,
        handleMemoUpdate,
        handleMemoDelete,
        handleTitleKeyDown,
        handleContentKeyDown,
        handleRowClick,
        newMemo,
        setNewMemo,
      }}
    >
      {children}
    </MemoContext.Provider>
  );
};

// useMemoContextフックを追加
const useMemoContext = () => {
  const context = useContext(MemoContext);
  if (!context) {
    throw new Error("useMemoContext must be used within a MemoProvider");
  }
  return context;
};

export { MemoProvider, useMemoContext };

MemoContainer

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main 
            ├── AuthContainer
               ├── SignIn
               └── SignUp
            └── MemoProvider
                └── MemoContainer # ココ
                    ├── NewMemoRow
                    └── MemoTable
                        └── MemoRow

メモ作成フォームとメモ一覧を表示するコンポーネントです。

src/components/Main/MemoContainer/MemoContainer.tsx
import React from "react";
import MemoTable from "./MemoTable";
import NewMemoRow from "./NewMemoRow";

const MemoContainer: React.VFC = () => {
  return (
    <div className="memo__table" data-testid="memo-container">
        <NewMemoRow />
        <MemoTable />
    </div>
  );
};

export default MemoContainer;

NewMemoRow

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main 
            ├── AuthContainer
               ├── SignIn
               └── SignUp
            └── MemoProvider
                └── MemoContainer
                    ├── NewMemoRow # ココ
                    └── MemoTable
                        └── MemoRow

メモの作成を行うコンポーネントです。
処理はMemoContextで定義した関数を使用します。

MemoContextから以下を取得しています。

  • handleMemoUpdate:メモの作成の処理を実行します。
  • handleTitleKeyDown:メモのタイトル入力時のキー操作処理を実行します。
  • handleContentKeyDown:メモのコンテンツ入力時のキー操作処理を実行します。
  • handleRowClick:メモをクリックした時の処理を実行します。
  • newMemo / setNewMemo:作成するメモを管理します。
src/components/Main/MemoContainer/NewMemoRow.tsx
import React, { useRef, useState } from "react";
import { useMemoContext } from "../../../context/MemoContext";
import { Memo } from "../../../Types";
import KeyboardCommandKeyIcon from "@mui/icons-material/KeyboardCommandKey";

const NewMemoRow: React.VFC = () => {
  const {
    handleTitleKeyDown,
    handleContentKeyDown,
    handleRowClick,
    newMemo,
    setNewMemo,
  } = useMemoContext();
  const titleRef = useRef<HTMLInputElement>(null);
  const contentRef = useRef<HTMLTextAreaElement>(null);

  return (
    <div
      className="memo__row"
      data-testid="new-memo-row"
      onClick={(e) => handleRowClick(e, titleRef)}
    >
      <input
        type="text"
        className="memo__form memo__form--title"
        value={newMemo.title}
        placeholder="Enter Title"
        onChange={(e) => setNewMemo({ ...newMemo, title: e.target.value })}
        onKeyDown={(e) => handleTitleKeyDown(e, titleRef, contentRef, newMemo!)}
        ref={titleRef}
      />
      <textarea
        className="memo__form memo__form--content"
        value={newMemo.content}
        placeholder="Enter Content"
        onChange={(e) => setNewMemo({ ...newMemo, content: e.target.value })}
        data-testid="new-memo-textarea"
        onKeyDown={(e) =>
          handleContentKeyDown(e, titleRef, contentRef, newMemo!)
        }
        ref={contentRef}
      />
      <div className="memo__hover-box">
        <div className="memo__left-box">
          <p className="memo__shortcut">
            <span className="memo__shortcut-key">Enter</span>
            <span className="memo__shortcut-desc">改行</span>
          </p>
          <p className="memo__shortcut">
            <span className="memo__shortcut-key">
              <KeyboardCommandKeyIcon sx={{ width: "1rem", height: "1rem" }} />+
              Enter
            </span>
            <span className="memo__shortcut-desc">保存</span>
          </p>
        </div>
      </div>
    </div>
  );
};

export default NewMemoRow;

MemoTable

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main 
            ├── AuthContainer
               ├── SignIn
               └── SignUp
            └── MemoProvider
                └── MemoContainer
                    ├── NewMemoRow
                    └── MemoTable # ココ
                        └── MemoRow

作成済みメモの一覧を表示するコンポーネントです。
メモ一覧をmapでMemoRowコンポーネントに渡すだけです。

MemoContextから以下を取得しています。

  • originalMemos / setOriginalMemos:ユーザーに紐づいたメモを管理します。
src/components/Main/MemoContainer/MemoTable.tsx
import React from "react";
import MemoRow from "./MemoRow";
import { useMemoContext } from "../../../context/MemoContext";
import { Memo } from "../../../Types";

const MemoTable: React.VFC = () => {
  const { originalMemos } = useMemoContext();
  return (
    <>
      {originalMemos.map((originalMemo: Memo) => (
        <MemoRow originalMemo={originalMemo} key={originalMemo.id} />
      ))}
    </>
  );
};

export default MemoTable;

MemoRow

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main 
            ├── AuthContainer
               ├── SignIn
               └── SignUp
            └── MemoProvider
                └── MemoContainer
                    ├── NewMemoRow
                    └── MemoTable
                        └── MemoRow # ココ

メモ1件の表示を行うコンポーネントです。

MemoContextから以下を取得しています。

  • handleMemoUpdate:メモの更新の処理を実行します。
  • handleMemoDelete:メモの削除の処理を実行します。
  • handleTitleKeyDown:メモのタイトル入力時のキー操作処理を実行します。
  • handleContentKeyDown:メモのコンテンツ入力時のキー操作処理を実行します。
  • handleRowClick:メモをクリックした時の処理を実行します。
src/components/Main/MemoContainer/NewMemoRow.tsx
import React, { useRef, useState } from "react";
import { useMemoContext } from "../../../context/MemoContext";
import { Memo } from "../../../Types";
import KeyboardCommandKeyIcon from "@mui/icons-material/KeyboardCommandKey";

const NewMemoRow: React.VFC = () => {
  const {
    handleTitleKeyDown,
    handleContentKeyDown,
    handleRowClick,
    newMemo,
    setNewMemo,
  } = useMemoContext();
  const titleRef = useRef<HTMLInputElement>(null);
  const contentRef = useRef<HTMLTextAreaElement>(null);

  return (
    <div
      className="memo__row"
      data-testid="new-memo-row"
      onClick={(e) => handleRowClick(e, titleRef)}
    >
      <input
        type="text"
        className="memo__form memo__form--title"
        value={newMemo.title}
        placeholder="Enter Title"
        onChange={(e) => setNewMemo({ ...newMemo, title: e.target.value })}
        onKeyDown={(e) => handleTitleKeyDown(e, titleRef, contentRef, newMemo!)}
        ref={titleRef}
      />
      <textarea
        className="memo__form memo__form--content"
        value={newMemo.content}
        placeholder="Enter Content"
        onChange={(e) => setNewMemo({ ...newMemo, content: e.target.value })}
        data-testid="new-memo-textarea"
        onKeyDown={(e) =>
          handleContentKeyDown(e, titleRef, contentRef, newMemo!)
        }
        ref={contentRef}
      />
      <div className="memo__hover-box">
        <div className="memo__left-box">
          <p className="memo__shortcut">
            <span className="memo__shortcut-key">Enter</span>
            <span className="memo__shortcut-desc">改行</span>
          </p>
          <p className="memo__shortcut">
            <span className="memo__shortcut-key">
              <KeyboardCommandKeyIcon sx={{ width: "1rem", height: "1rem" }} />+
              Enter
            </span>
            <span className="memo__shortcut-desc">保存</span>
          </p>
        </div>
      </div>
    </div>
  );
};

export default NewMemoRow;

動作確認

まとめ

今回はメモ機能について設計から実装までまとめました。
ユーザーに紐づいたメモを扱うためにAPIから修正しています。

以下の課題が残っていますがひとまず完成に向けて次はデプロイに取り組みます。

今後の課題

  • 認証のセキュリティが低い
    • フロントエンドにパスワード情報を持ってきている(localStorageに保存)
    • api/users/{user_id}/memos/{id}のAPIをサインインしていない状態からリクエストして不正にデータを取得できてしまう
  • UI/UXの向上
    • サインアウト/アカウント削除時のフィードバックがない

コメント