Closed26

Webアプリ開発初心者による「豆ログ」の開発2 ~Reactでフロントエンド開発~

gotoooogotoooo

3.フロントエンド開発

準備

  1. リポジトリ作成
    githubにて作成。
    のちのデプロイのことを見据えてバックエンドのリポジトリとは別のリポジトリとした。

  2. プロジェクト作成
    まずNode.jsをインストールする。
    筆者環境ではすでにv21.5.0がインストール済みだったので今回は省略。

プロジェクトの作成方法はいくつかバリエーションがある。
2024年3月時点だとViteを使って環境構築するのが良さそうである。
https://zenn.dev/takiko/articles/827c182638eb3b

VSCodeのターミナルで以下コマンドを実行する

yarn create vite .

ターミナルで対話的に選択を求められるので、
React
TypeScript
を選択した。

下記フォルダ、ファイルが生成される。

その後以下コマンドを実行

yarn

node_modules以下に依存するライブラリが収まる。

さらに以下を実行する

yarn dev

デフォルトのWebアプリのサービスが起動する。

ターミナルに表示されるURLをブラウザで開くとWebアプリが表示される

gotoooogotoooo

準備 続き

不要なファイルを消す

不要なコードを削除、修正する

App.tsx

function App() {

  return (
    <>
      <div>
      </div>
    </>
  )
}

export default App

index.css
html {
  font-family: sans-serif;
}

body {
  margin: 0;
}
main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

yarn devでサービスを起動してブラウザで表示させると真っ白なページが表示される

gotoooogotoooo

実装

  1. 機能実装1巡目
    ログイン、サインアップを実装してReactの作法に慣れる。

  2. 機能実装2巡目
    一覧ページ、詳細ページ、編集・削除 などを実装。

  3. 見た目の調整
    cssなりでいい感じに仕上げる。

gotoooogotoooo

実装 ログイン画面

見様見真似でログイン画面を実装してみた。
画面は要素をただ並べただけの状態。

以下メモ

  • APIのリクエストはaxiosを使う。APIのリクエスト先は環境変数読み出しの仕組みを使って開発、本番環境で切り替えられるようにする。
  • ログイン時のButtonはtype=submitで使う。ただしsubmitとすると画面がリロードされるのでそれを防ぐためにsubmitのイベントハンドラでpreventDefaultする。
  • ReactのuseStateを活用してTextFieldの変更イベント時に内部的に保持する変数を更新する。
  • API側の入力仕様に合わせた型でJson化したデータを送る。※なので先にAPI仕様を決めるのが重要。

LoginPage.tsx
LoginPage.tsx
import { Button, TextField } from "@mui/material";
import axios from "axios";
import React, { useState } from "react";

const LoginPage: React.FC = () => {
  const [loginId, setLoginId] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [errorMessage, setErrorMessage] = useState<string>("");

  const onSubmitHandler = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const url = import.meta.env.VITE_API_DOMAIN + "login";
      const config = {
        headers: {
          'Content-Type': 'application/json',
        }
      };
      const data = {
        login_id: loginId,
        password: password
      };
      await axios.post(url, data, config);
      console.log('login successful')
      setErrorMessage("");

    } catch (error) {
      console.log(error);
      setErrorMessage("login failed.");
    }
  };

  return (
    <div className="App" style={{ display: "flax", flexDirection: "column" }}>
      {errorMessage && <p>{errorMessage}</p>}
      <form onSubmit={onSubmitHandler}>
        <TextField
          property="required"
          label="login id"
          variant="outlined"
          onChange={(e) => setLoginId(e.target.value)}
        />
        <TextField
          property="required"
          label="password"
          variant="outlined"
          type="password"
          autoComplete="current-password"
          onChange={(e) => setPassword(e.target.value)}
        />
        <Button variant="contained" type="submit">
          Login
        </Button>
      </form>
      <Button>sign in</Button>
    </div>
  );
};

export default LoginPage;

gotoooogotoooo

実装 ログイン画面<-->サインアップ画面の遷移

予め 「npm install react-router-dom」 する。

画面遷移はuseNavigateを使う。
どのURLに何を表示させるかはRouterを実装して決める。

React Router v6になってから諸々作法が変わったようで、Web上の情報を参考にする際には使用バージョンに留意する必要がある。

main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

App.tsx
import LoginPage from "./features/Auth/pages/LoginPage";
import { Routes, Route } from "react-router-dom";
import SignupPage from "./features/Auth/pages/SignupPage";

const App: React.FC = () => {
  return (
    <Routes>
      <Route path="/" element={<LoginPage />} />
      <Route path="/signup" element={<SignupPage />} />
    </Routes>
  );
};

export default App;

LoginPage.tsx
import { Button, TextField } from "@mui/material";
import axios from "axios";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const LoginPage: React.FC = () => {
  const [loginId, setLoginId] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [errorMessage, setErrorMessage] = useState<string>("");
  const navigate = useNavigate();

  const onSubmitHandler = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const url = import.meta.env.VITE_API_DOMAIN + "login";
      const config = {
        headers: {
          "Content-Type": "application/json",
        },
      };
      const data = {
        login_id: loginId,
        password: password,
      };
      await axios.post(url, data, config);
      console.log("login successful");
      setErrorMessage("");

      navigate("/purchase_history_list");
    } catch (error) {
      console.log(error);
      setErrorMessage("login failed.");
    }
  };

  return (
    <div className="App" style={{ display: "flax", flexDirection: "column" }}>
      {errorMessage && <p>{errorMessage}</p>}
      <form onSubmit={onSubmitHandler}>
        <TextField
          property="required"
          label="login id"
          variant="outlined"
          onChange={(e) => setLoginId(e.target.value)}
        />
        <TextField
          property="required"
          label="password"
          variant="outlined"
          type="password"
          autoComplete="current-password"
          onChange={(e) => setPassword(e.target.value)}
        />
        <Button variant="contained" type="submit">
          Login
        </Button>
      </form>
      <Link to="/signup">sign up</Link>
    </div>
  );
};

export default LoginPage;

SignupPage.tsx
import { Button, TextField } from "@mui/material";
import axios from "axios";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const SignupPage: React.FC = () => {
  const [loginId, setLoginId] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [passwordConfirm, setPasswordConfirm] = useState<string>("");
  const [name, setName] = useState<string>("");
  const [errorMessage, setErrorMessage] = useState<string>("");
  const navigate = useNavigate();

  const onSubmitHandler = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      if (password != passwordConfirm) {
        throw new Error("confirm password.");
      }

      const url = import.meta.env.VITE_API_DOMAIN + "register";
      const config = {
        headers: {
          "Content-Type": "application/json",
        },
      };
      const data = {
        name: name,
        login_id: loginId,
        password: password,
        roll_id: "1",
      };
      await axios.post(url, data, config);
      console.log("signup successful");
      setErrorMessage("");

      navigate("/purchase_history_list");
    } catch (error) {
      console.log(error);
      setErrorMessage("signup failed.");
    }
  };

  return (
    <div className="App" style={{ display: "flax", flexDirection: "column" }}>
      {errorMessage && <p>{errorMessage}</p>}
      <form onSubmit={onSubmitHandler}>
        <TextField
          property="required"
          label="login id"
          variant="outlined"
          onChange={(e) => setLoginId(e.target.value)}
        />
        <TextField
          property="required"
          label="password"
          variant="outlined"
          type="password"
          autoComplete="current-password"
          onChange={(e) => setPassword(e.target.value)}
        />
        <TextField
          property="required"
          label="password for confirmation"
          variant="outlined"
          type="password"
          onChange={(e) => setPasswordConfirm(e.target.value)}
        />
        <TextField
          label="name"
          variant="outlined"
          onChange={(e) => setName(e.target.value)}
        />
        <Button variant="contained" type="submit">
          Signup
        </Button>
      </form>
      <Link to="/">login</Link>
    </div>
  );
};

export default SignupPage;

gotoooogotoooo

実装 サインアップ機能の動作確認

バックエンド側のノウハウではあるがFastAPIの場合、サーバのURLの末尾に/docsとつけるとAPIドキュメントを参照できる。
さらにその中でAPIの手動実行ができる。

フロントエンド側でサインアップ実行

バックエンド側で確認

gotoooogotoooo

React + TypeScript で Emotion を使用するためのTips

emotionでスタイルを当てようとしたらうまく行かず、小一時間ハマった。

  1. /** @jsxImportSource @emotion/react */を書く
  2. tsconfig.jsonに設定を追記
MyComponent.tsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";

const NavigationBar: React.FC = () => {

  const divStyle = css({
    backgroundColor: "hotpink",
    })

  return (
    <div css={divStyle} className="navbar">Hello World!
    </div>
  );
};

export default MyComponent;


tsconfig.json
//tsconfig.json
{
  "compilerOptions": {
...
    "types": ["@emotion/react/types/css-prop"]
  }

参考

https://hatolabo.com/programming/【react-typescript】emotion入れたら-property-css-does-not-exist-on-type-detailedhtmlprops-のエラーにな

https://ralacode.com/blog/post/react-emotion/

gotoooogotoooo

useEffectが2回呼ばれる

開発環境での実行時のみ、React.StrictModeで囲っているとuseEffectが2回呼ばれる。
ググるとたくさん解説記事が見つかる。
ここでは「const oneShot = useRef(false);」のフラグを用意して2回呼び出されても期待する振る舞いを維持するようにした。

回避策
import React, { useEffect, useRef, useState } from "react";
import NavigationBar from "../../../common/components/layouts/NavigationBar";
import PurchaseHistoryList from "../components/PurchaseHistoryList";
import { PurchaseHistory } from "../../../common/types/PurchaseHistory";

const PurchaceHistoryListPage: React.FC = () => {
  const [items, setItems] = useState<PurchaseHistory[]>([]);
  const oneShot = useRef(false);

  useEffect(() => {
    console.log("useEffect");

    if (oneShot.current === false) {
      const dummyItem: PurchaseHistory = {
        id: "1",
        datetime: "2022-10-20 10:22:33",
        breandName: "Dummy Brand",
        storeName: "Dummy Store",
        imageSource: "dummy-image.png",
      };

      setItems((prevItems) => [...prevItems, dummyItem]);
      oneShot.current = true;
    }

    return () => {
      console.log(oneShot);
    };
  }, []);

  const handleDeleteItem = (id: string) => {
    setItems((prevItems) => prevItems.filter((item) => item.id !== id));
  };

  const handleEditItem = (id: string) => {
    console.log(id);
    // TODO: navigate to edit page
  };

  return (
    <div style={{ display: "flax", flexDirection: "column" }}>
      <PurchaseHistoryList
        onDelete={handleDeleteItem}
        onEdit={handleEditItem}
        items={items}
      />
      <NavigationBar />
    </div>
  );
};

export default PurchaceHistoryListPage;

gotoooogotoooo

ListPage -> DetailPage への遷移時にデータを渡す方法

遷移元:ListPage

useNavigationを使う。
navigation("遷移先のパス", "渡すデータ");

遷移先:DetailPage

useLocationを使う。

const item = location.state.item as 変換したい型

※ 遷移の際にAPIにアクセスせずにデータを渡す方法の一例。遷移時にIDだけ渡して遷移先でAPIからデータを取得する方法も考えられる。

gotoooogotoooo

VSCode, React, TypeScript, Vite の構成でVSCodeでデバッグする方法

最も準備に手数が少ない方法でやる。

  1. launch.jsonを作成

  2. Webアプリ(Edge)を選択

  3. urlをyanr dev実行時に立ち上がるサービスのポートに修正する

  1. yarn dev実行
    サービスが起動する。

  2. VSCodeでF5
    Edgeが起動する。これでVSCode上にブレークポイントをおいたデバッグが可能になる。

gotoooogotoooo

styleの適用

ChatGPTに実現したい画面レイアウトとコードを渡してstlyeを教えてもらいながら実装を進めた。

かなり見栄えは良くなった。

コード例
import { Button, MenuItem, Popover, Typography } from "@mui/material";
import React, { useState } from "react";
import { BsThreeDots } from "react-icons/bs";
import { PurchaseHistory } from "../../../common/types/PurchaseHistory";
import { IconContext } from "react-icons";
/** @jsxImportSource @emotion/react */
import {css} from "@emotion/react";

interface PurchaseHistoryProps {
  item: PurchaseHistory;
  onSelect: (id: string) => void;
  onDelete: (id: string) => void;
  onEdit: (id: string) => void;
}

const PurchaseHistoryListItem: React.FC<PurchaseHistoryProps> = (props) => {
  const [anchorElm, setAnchorElm] = useState<HTMLButtonElement | null>(null);

  const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.stopPropagation();
    setAnchorElm(event.currentTarget);
  };

  const handleCloseMenu = (event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    setAnchorElm(null);
  };

  const handleEditMenu = (event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    props.onEdit(props.item.id);
  };

  const handleDeleteMenu = (event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    props.onDelete(props.item.id);
  };

  const hoverStyle = css({
    ":hover" : {
      backgroundColor : "#FAFAFA",
      cursor: "pointer"
    }
  });

  return (
    <div
      className="item"
      onClick={() => props.onSelect(props.item.id)}
      style={{ display: "flex", flexDirection: "row", flexWrap: "wrap", alignItems: "center", height:"64px" }}
    >
      <img
        className="itemImage"
        src={props.item.imageSource}
        style={{
          width: "64px",
          height: "64px",
          objectFit: "cover",
          overflow: "hidden",
          borderRadius: "20%",
          border: "1px solid #000",
        }}
      />

      <div className="itemImfo" style={{ marginLeft: "16px" }}>
        <Typography className="header" sx={{ fontSize: "12px" }}>
          {props.item.datetime}
        </Typography>
        <Typography
          className="title"
          sx={{ fontSize: "16px", fontWeight: "bold" }}
        >
          {props.item.brandName}
        </Typography>
        <Typography
          className="subTitle"
          sx={{ fontSize: "12px", color: "gray" }}
        >
          {props.item.storeName}
        </Typography>
      </div>

      <div style={{ width: "auto", height: "100%", display: "flex", marginLeft: "auto", alignItems: "center" }}>
        <Button
          className="itemControl"
          variant="text"
          onClick={handleMenuClick}
          sx={{ width: "auto", height: "100%" }}
        >
          <IconContext.Provider value={{ size: "16", color: "black" }}>
            <BsThreeDots />
          </IconContext.Provider>
        </Button>
      </div>

      <Popover
        open={Boolean(anchorElm)}
        anchorEl={anchorElm}
        onClose={handleCloseMenu}
        anchorOrigin={{
          vertical: "bottom",
          horizontal: "left",
        }}
        transformOrigin={{
          vertical: "top",
          horizontal: "left",
        }}
      >
        <MenuItem onClick={handleEditMenu}>Edit</MenuItem>
        <MenuItem onClick={handleDeleteMenu}>Delete</MenuItem>
      </Popover>
    </div>
  );
};

export default PurchaseHistoryListItem;

gotoooogotoooo

ComponentのStyle適用は機能実装が終わってからまとめてやろうと思っていたが、一つずつある程度のクオリティまで仕上げてからのほうがモチベーションを維持しやすい。(しょぼいUIのまま放置しているとやる気が下がる)

gotoooogotoooo

横幅によって表示内容を切りかえる

MaterialUIを使った画面レイアウトの作成に凝り始めて機能実装が進まない。
見た目は少しずつ良くなってきた。

  • モバイルモード

  • PCモード

gotoooogotoooo

デフォルトでこのあたりのUXが実装されているGlideはすごいなー。

gotoooogotoooo

MUIのDatePickerから日付を指定して"YYYY-MM-DD"文字列に変換する部分でハマった。

  • 普通にtoJson()すると日付がずれる。タイムゾーンの影響で-9h戻った日付になる。
  • MUIのDatePicker側にロケールを指定できるようだがうまく反映されているかよくわからない。
  • わざわざnew Date()でインスタンス化しないとエラーが出る。

苦肉の策でsv-SEロケールで文字列化することにした。

const data: PurchaseHistorySchema = {
      id: null,
      userId: "0", // TODO
      brandId: brandId,
      storeId: storeId,
      purchaseAt: purchaseDate as Date, 
      annotation: annotation,
    };
    console.log(data);
    console.log(new Date(data.purchaseAt).toLocaleDateString('sv-SE'));

参考:
https://www.ey-office.com/blog_archive/2023/04/18/short-code-to-get-todays-date-in-yyyy-mm-dd-format-in-javascript/

gotoooogotoooo

入力フォームのバリデーション

yupを使う

npm install yup

コード例
import * as yup from "yup";

const PurchaseHistoryAddDialog: React.FC<DialogParams> = (props) => {
  const [purchaseDate, setPurchaseDate] = useState<Date | null>(
    dayjs() as unknown as Date
  );
  const [storeId, setStoreId] = useState<string>("");
  const [brandId, setBrandId] = useState<string>("");
  const [annotation, setAnnotation] = useState<string>("");
  const [validationError, setValidationError] = useState<string>("");

  // yupスキーマを定義
  const schema = yup.object().shape({
    purchaseDate: yup.date().required("Purchase date is required"),
    storeId: yup.string().required("Store is required"),
    brandId: yup.string().required("Brand is required"),
    annotation: yup.string(),
  });

  const onSubmitHandler = async (e: React.FormEvent) => {
    e.preventDefault();

    try {
      // フォームの値をyupスキーマで検証
      await schema.validate(
        { purchaseDate, storeId, brandId, annotation },
        { abortEarly: false } // すべてのエラーを一度に表示するために設定
      );

      // エラーがない場合はダイアログを閉じる
      const data: PurchaseHistorySchema = {
        id: null,
        userId: "0", // TODO
        brandId: brandId,
        storeId: storeId,
        purchaseAt: purchaseDate as Date,
        annotation: annotation,
      };
      console.log(data);
      console.log(new Date(data.purchaseAt).toLocaleDateString("sv-SE"));

      const dialogResult: DialogResult = { result: "OK", item: { data: data } };
      props.onClose(dialogResult);
    } catch (error: unknown) {
      const vError = error as yup.ValidationError;
      // yupの検証エラーがある場合、エラーメッセージを表示
      setValidationError(vError.errors.join(", "));
      console.log(validationError);
    }
  };

  return (
    <Dialog onClose={handleClose} open={props.open} scroll="paper">
      <DialogTitle>Add Item</DialogTitle>
      <Box component="form" onSubmit={onSubmitHandler}>
        <DialogContent dividers={true}>
          <Stack spacing={"8px"}>
            <LocalizationProvider
              dateAdapter={AdapterDayjs}
              // adapterLocale={"ja"}
              // localeText={
              //   jaJP.components.MuiLocalizationProvider.defaultProps.localeText
              // }
            >
              <DatePicker
                format="YYYY/MM/DD"
                value={purchaseDate}
                defaultValue={purchaseDate}
                onChange={handleDatePickerChange}
                // localeText={jaJP.components.MuiLocalizationProvider.defaultProps.localeText}
              />
            </LocalizationProvider>

            <FormControl required>
              <InputLabel id="brandSelect">Brand</InputLabel>
              <Select
                id="brandSelect"
                label="Brand"
                // value={1}
                onChange={(e) => setBrandId(e.target.value as string)}
              >
                {brands.map((item, index) => (
                  <MenuItem key={index} value={item.id!}>
                    {item.name}
                  </MenuItem>
                ))}
              </Select>
            </FormControl>

...
          </Stack>
        </DialogContent>
        <DialogActions>
          <Button variant="outlined" onClick={handleClose}>
            Cancel
          </Button>
          <Button variant="contained" type="submit">
            Submit
          </Button>
        </DialogActions>
      </Box>
    </Dialog>
  );
};

export default PurchaseHistoryAddDialog;


...




gotoooogotoooo

アプリ全体で共有する変数へのアクセス

zustandを使う

※Reduxがメジャーなようだが、今回のような個人用小規模プロジェクトには不向きと判断した。

npm install zustand

方針

  1. 親ページとなるXXXListPageの読み込み時にAPIからfetchしてStoreにデータを格納する。
  2. 子ページor子ダイアログは改めてfetchせず、Storeに収まっているデータを参照する
コード例
ストア.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { BrandSchema } from "../schemas/BrandSchema";

// 状態とアクションを定義する
interface State {
  brands: BrandSchema[]; // ブランドリスト
  fetchBrands: () => void; // ブランドリストをAPIから取得する
}

// 初期状態
const initialState: State = {
  brands: [],
  fetchBrands: () => {},
};

// Storeを作成
export const useBrandListStore = create<State>()(
  // ReduxDevToolsとともに状態を確認するために使う。
  devtools((set) => ({
    ...initialState,
    // fetchBrands: async () => {

    // // ブランドリストを取得するAPI呼び出し
    // await fetch(import.meta.env.VITE_API_DOMAIN + 'brand')
    //   .then(response => response.json())
    //   .then(data => set({ brands: data.brands }))
    //   .catch(error => console.error('Error fetching brands:', error));
    // },

    // デバッグ用ダミー版
    fetchBrands: () => {
        const brand1 : BrandSchema = {
            id : "1",
            name : "dummyBrand1",
            productionAreaId: "1",
            productionAreaDetail: "North area",
            annotation: "new crop",
            imageSource: "https://www.mofa.go.jp/mofaj/kids/kokki/image/a23.gif",
        } 
        const brand2 : BrandSchema = {
            id : "2",
            name : "dummyBrand2",
            productionAreaId: "2",
            productionAreaDetail: null,
            annotation: null,
            imageSource: null,
        } 
      set({brands: [brand1, brand2]});
    },
  }))
);

親ListPage.tsx
import React, { useEffect, useRef, useState } from "react";
import { useBrandListStore } from "../../../common/stores/brandListStore";

const PurchaseHistoryListPage: React.FC = () => {
  const navigate = useNavigate();
  const {brands, fetchBrands} = useBrandListStore();

  useEffect(() => {
    if (oneShot.current === false) {
      oneShot.current = true;

      fetchBrands();
    }

    return () => {
    };
  }, []);

  return (
    <Stack>
      <PageHeader
        onSeachButtonClicked={handleSearchClicked}
        onAddButtonClicked={handleAddDialogOpen}
        headerText="Mamelog"
      />
      <Container>
...
      </Container>
    </Stack>
  );
};
子ダイアログ
import { useBrandListStore } from "../../../common/stores/brandListStore";

const PurchaseHistoryAddDialog: React.FC<DialogParams> = (props) => {
  const { brands, fetchBrands } = useBrandListStore();
...

  return (
    <Dialog onClose={handleClose} open={props.open} scroll="paper">
      <DialogTitle>Add Item</DialogTitle>
      <Box component="form" onSubmit={onSubmitHandler}>
        <DialogContent dividers={true}>
          <Stack spacing={"8px"}>
          ...
            <FormControl required>
              <InputLabel id="brandSelect">Brand</InputLabel>
              <Select
                id="brandSelect"
                label="Brand"
                onChange={(e) => setBrandId(e.target.value as string)}
              >
                {brands.map((item, index) => (
                  <MenuItem key={index} value={item.id!}>
                    {item.name}
                  </MenuItem>
                ))}
              </Select>
            </FormControl>
            ...
          </Stack>
        </DialogContent>
        <DialogActions>
          <Button variant="outlined" onClick={handleClose}>
            Cancel
          </Button>
          <Button variant="contained" type="submit">
            Submit
          </Button>
        </DialogActions>
      </Box>
    </Dialog>
  );
};
gotoooogotoooo

汎用的なDialogの実装

WPF開発用のライブラリであるPrismのIDialogServiceを真似して自作してみた。

Add, Editで入力内容は同じなので初期値とタイトルを切り替えるようにした。
また、Submit押下後の振る舞いも切り替えられるようにした。

コード例
DialogTypes.ts
export interface DialogParams {
  open: boolean;
  title: string;
  item: {[key: string]: object | null } | null;
  onClose: (result: DialogResult) => void;
}

export interface DialogResult {
  result: "OK" | "NG";
  item: {[key: string]: object | null} | null;
}


PurchaseHistoryFormDialog.tsx
import React, { useEffect, useState } from "react";
import {
  Box,
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  FormControl,
  InputLabel,
  MenuItem,
  Select,
  Stack,
  TextField,
} from "@mui/material";
import { DatePicker, LocalizationProvider, jaJP } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { PurchaseHistorySchema } from "../../../common/schemas/PurchaseHistorySchema";
import * as yup from "yup";
import dayjs from "dayjs";
import { useBrandListStore } from "../../../common/stores/BrandListStore";
import { useStoreListStore } from "../../../common/stores/StoreListStore";
import { DialogParams, DialogResult } from "../../../common/types/DialogTypes";

const PurchaseHistoryFormDialog: React.FC<DialogParams> = (props) => {
  const [purchaseDate, setPurchaseDate] = useState<Date | null>(
    dayjs() as unknown as Date
  );
  const [storeId, setStoreId] = useState<string>("");
  const [brandId, setBrandId] = useState<string>("");
  const [annotation, setAnnotation] = useState<string>("");
  const [validationError, setValidationError] = useState<string>("");
  const { brands, fetchBrands } = useBrandListStore();
  const { stores, fetchStores } = useStoreListStore();

  useEffect(() => {
    if (props.item?.data) {
      const target = props.item?.data as PurchaseHistorySchema;
      setPurchaseDate(dayjs(target.purchaseAt) as unknown as Date);

      setStoreId(target.storeId);
      setBrandId(target.brandId);
      setAnnotation(target.annotation ?? "");
    }
}, [props.item]);

  // yupスキーマを定義
  const schema = yup.object().shape({
    purchaseDate: yup.date().required("Purchase date is required"),
    storeId: yup.string().required("Store is required"),
    brandId: yup.string().required("Brand is required"),
    annotation: yup.string(),
  });

  const handleClose = () => {
    const dialogResult: DialogResult = { result: "NG", item: null };
    props.onClose(dialogResult);
  };

  const handleDatePickerChange = (newValue: Date | null) => {
    setPurchaseDate(newValue);
    // const dateString = new Date(newValue as Date).toLocaleDateString("ja-JP", { year: "numeric", month: "2-digit", day: "2-digit" }).replace('/', '-');
    // console.log(dateString);
  };

  const onSubmitHandler = async (e: React.FormEvent) => {
    e.preventDefault();

    try {
      // フォームの値をyupスキーマで検証
      await schema.validate(
        { purchaseDate, storeId, brandId, annotation },
        { abortEarly: false } // すべてのエラーを一度に表示するために設定
      );

      // エラーがない場合はダイアログを閉じる
      const data: PurchaseHistorySchema = {
        id: null,
        userId: "0", // TODO
        brandId: brandId,
        storeId: storeId,
        purchaseAt: purchaseDate as Date,
        annotation: annotation,
      };
      console.log(data);
      console.log(new Date(data.purchaseAt).toLocaleDateString("sv-SE"));

      const dialogResult: DialogResult = { result: "OK", item: { data: data } };
      props.onClose(dialogResult);
    } catch (error: unknown) {
      const vError = error as yup.ValidationError;
      // yupの検証エラーがある場合、エラーメッセージを表示
      setValidationError(vError.errors.join(", "));
      console.log(validationError);
    }
  };

  return (
    <Dialog onClose={handleClose} open={props.open} scroll="paper">
      <DialogTitle>{props.title}</DialogTitle>
      <Box component="form" onSubmit={onSubmitHandler}>
        <DialogContent dividers={true}>
          <Stack spacing={"8px"}>
            <LocalizationProvider
              dateAdapter={AdapterDayjs}
              // adapterLocale={"ja"}
              // localeText={
              //   jaJP.components.MuiLocalizationProvider.defaultProps.localeText
              // }
            >
              <DatePicker
                format="YYYY/MM/DD"
                value={purchaseDate}
                defaultValue={purchaseDate}
                onChange={handleDatePickerChange}
                // localeText={jaJP.components.MuiLocalizationProvider.defaultProps.localeText}
              />
            </LocalizationProvider>

            <FormControl required>
              <InputLabel id="brandSelect">Brand</InputLabel>
              <Select
                id="brandSelect"
                label="Brand"
                value={brandId}
                onChange={(e) => setBrandId(e.target.value as string)}
              >
                {brands.map((item, index) => (
                  <MenuItem key={index} value={item.id!}>
                    {item.name}
                  </MenuItem>
                ))}
              </Select>
            </FormControl>

            <FormControl required>
              <InputLabel id="storeSelect">Store</InputLabel>
              <Select
                id="storeSelect"
                label="Store"
                value={storeId}
                onChange={(e) => setStoreId(e.target.value as string)}
              >
                {stores.map((item, index) => (
                  <MenuItem key={index} value={item.id!}>
                    {item.name}
                  </MenuItem>
                ))}
              </Select>
            </FormControl>

            <TextField
              label="Annotation"
              variant="outlined"
              value={annotation}
              onChange={(e) => setAnnotation(e.target.value)}
            />
          </Stack>
        </DialogContent>
        <DialogActions>
          <Button variant="outlined" onClick={handleClose}>
            Cancel
          </Button>
          <Button variant="contained" type="submit">
            Submit
          </Button>
        </DialogActions>
      </Box>
    </Dialog>
  );
};

export default PurchaseHistoryFormDialog;

PurchaseHistoryListPage.tsx
import React, { useEffect, useRef, useState } from "react";
import PurchaseHistoryList from "../components/PurchaseHistoryList";
import { PurchaseHistoryView } from "../../../common/types/PurchaseHistoryView";
import { useNavigate } from "react-router-dom";
import PageHeader from "../../../common/components/layouts/PageHeader";
import { Container, Stack } from "@mui/material";
import axios from "axios";
import { DialogParams, DialogResult } from "../../../common/types/DialogTypes";
import { useBrandListStore } from "../../../common/stores/BrandListStore";
import { useStoreListStore } from "../../../common/stores/StoreListStore";
import { usePurchaseHistoryStore } from "../../../common/stores/PurchaseHistoryListStore";
import { PurchaseHistorySchema } from "../../../common/schemas/PurchaseHistorySchema";
import PurchaseHistoryFormDialog from "../components/PurchaseHistoryFormDialog";

const PurchaseHistoryListPage: React.FC = () => {
  const [items, setItems] = useState<PurchaseHistoryView[]>([]);
  const [filteredItems, setFilteredItems] = useState<PurchaseHistoryView[]>([]);
  const [editDialogOpen, setEditDialogOpen] = useState(false);
  const [addDialogOpen, setAddDialogOpen] = useState(false);
  const [editDialogParam, setEditDialogParam] =
    useState<DialogParams["item"]>(null);
  const [queryString, setQueryString] = useState("");
  const oneShot = useRef(false);
  const navigate = useNavigate();
  const { brands, fetchBrands } = useBrandListStore();
  const { stores, fetchStores } = useStoreListStore();
  const { purchaseHistories, fetchPurchaseHistories } =
    usePurchaseHistoryStore();

  useEffect(() => {
    // console.log("useEffect");

    if (oneShot.current === false) {
      // TODO: ここでDBから一覧を取得して各型を変換する
      // 毎回DBアクセスするのではなく初回のみDBから取得しキャッシュする。更新があればDB, キャッシュ先両方を更新する。二回目以降はキャッシュ先から取りに行く。

      const fetchData = async () => {
        await fetchBrands();
        await fetchStores();
        await fetchPurchaseHistories();
        console.log("fechData");
      };

      if (purchaseHistories.length <= 0) {
        // TODO: Add, Update, DeleteなどDBのデータが変化した場合の対応を検討
        fetchData();
      }

      const itemViews = purchaseHistories.map((item: PurchaseHistorySchema) => {
        const itemView: PurchaseHistoryView = {
          id: item.id ?? "",
          purchaseAt: item.purchaseAt.toLocaleDateString("ja-JP", {
            year: "numeric",
            month: "2-digit",
            day: "2-digit",
          }),
          brandName:
            brands.find((brand) => brand.id === item.brandId)?.name ?? "",
          storeName:
            stores.find((store) => store.id === item.storeId)?.name ?? "",
          annotation: item.annotation ?? "-",
          imageSource: "https://www.mofa.go.jp/mofaj/kids/kokki/image/a23.gif", // TODO: 更に別テーブルから参照する
        };
        return itemView;
      });
      setItems((prevItems) => [...prevItems, ...itemViews]);

      oneShot.current = true;
    }

    return () => {
      // console.log(oneShot);
    };
  }, []);

  useEffect(() => {
    if (queryString.length > 0) {
      const filteredItems = items.filter((item) =>
        item.brandName.includes(queryString)
      );
      setFilteredItems(filteredItems);
    }
    else{
      setFilteredItems(items);
    }
  }, [items, queryString]);

  const handleSelectItem = (id: string) => {
    console.log("select");
    console.log(id);

    const target = items.find((item) => item.id == id);
    navigate("/purchase-history-detail", { state: { item: target } });
  };

  const handleDeleteItem = (id: string) => {
    console.log("delete");
    setItems((prevItems) => prevItems.filter((item) => item.id !== id));
    // TODO: delete request to API
  };

  const handleAddDialogOpen = () => {
    console.log("add");
    setAddDialogOpen(true);
  };

  const handleAddDialogClosed = async (dialogResult: DialogResult) => {
    if (dialogResult.result === "NG") {
      setAddDialogOpen(false);
      return;
    }

    try {
      const url = import.meta.env.VITE_API_DOMAIN + "purchase_history";
      const config = {
        headers: {
          "Content-Type": "application/json",
        },
      };
      const data = dialogResult.item!["data"] as PurchaseHistoryView;
      await axios.post(url, data, config);
      console.log("add successful");
      setAddDialogOpen(false);
      return;
    } catch (error) {
      console.log(error);
      alert(error);
    }
  };

  const handleEditDialogOpen = (id: string) => {
    console.log("edit" + id);
    const target =
      purchaseHistories.find((history) => history.id === id) ?? null;

    const setTarget = async () => {
      await setEditDialogParam({ data: target });
    };

    setTarget().then(() => {
      setEditDialogOpen(true);
      // console.log(editDialogParam);
    });
  };

  const handleEditDialogClosed = async (dialogResult: DialogResult) => {
    if (dialogResult.result === "NG") {
      setEditDialogOpen(false);
      return;
    }

    try {
      const data = dialogResult.item!["data"] as PurchaseHistorySchema;
      const url =
        import.meta.env.VITE_API_DOMAIN + "purchase_history/" + data.id;
      const config = {
        headers: {
          "Content-Type": "application/json",
        },
      };
      await axios.put(url, data, config);
      console.log("edit successful");
      setEditDialogOpen(false);
      return;
    } catch (error) {
      console.log(error);
      alert(error);
    }
  };

  const handleSearchClicked = (text: string) => {
    console.log(text);
    setQueryString(text);
  };

  return (
    <Stack>
      <PageHeader
        onSeachButtonClicked={handleSearchClicked}
        onAddButtonClicked={handleAddDialogOpen}
        headerText="Mamelog"
      />
      <Container>
        <PurchaseHistoryList
          onSelect={handleSelectItem}
          onDelete={handleDeleteItem}
          onEdit={handleEditDialogOpen}
          items={filteredItems}
        />
        <PurchaseHistoryFormDialog
          open={addDialogOpen}
          item={null}
          title="Add"
          onClose={handleAddDialogClosed}
        />
        <PurchaseHistoryFormDialog
          open={editDialogOpen}
          item={editDialogParam}
          title="Edit"
          onClose={handleEditDialogClosed}
        />
      </Container>
    </Stack>
  );
};

export default PurchaseHistoryListPage;

gotoooogotoooo

エンティティ追加時の機序メモ

  1. Add操作実行
    この中でAdd用のAPIにリクエストする
  2. DBに追加されてIDが付与されたエンティティが返ってくる
  3. Zustandで管理している配列(xxxItems)に追加する
    xxxStoreにaddXXXメソッドを用意する
  4. useEffectでxxxItemsの更新を監視しておき、表示用配列(xxxViewItems)を更新する
  5. useEffectでxxxViewItems、検索文字列を監視しておき、検索後表示用配列(filteredxxxViewItems)を更新する

xxxViewItemsは不要かもしれない。直接xxxItems => filteredxxxViewItemsを生成できる。

gotoooogotoooo

ようやくオーソドックスなCRUD操作の実装が完了した。
Reactの扱い方はだいたい理解できた。
長くなったのでこの記事は一旦Closeする。

残:

  • 他のエンティティの実装
  • 認証周り
  • 本物DB準備
  • どこかにデプロイ
このスクラップは2024/03/14にクローズされました