Webアプリ開発初心者による「豆ログ」の開発2 ~Reactでフロントエンド開発~
前篇
3.フロントエンド開発
準備
-
リポジトリ作成
githubにて作成。
のちのデプロイのことを見据えてバックエンドのリポジトリとは別のリポジトリとした。 -
プロジェクト作成
まずNode.jsをインストールする。
筆者環境ではすでにv21.5.0がインストール済みだったので今回は省略。
プロジェクトの作成方法はいくつかバリエーションがある。
2024年3月時点だとViteを使って環境構築するのが良さそうである。
VSCodeのターミナルで以下コマンドを実行する
yarn create vite .
ターミナルで対話的に選択を求められるので、
React
TypeScript
を選択した。
下記フォルダ、ファイルが生成される。
その後以下コマンドを実行
yarn
node_modules以下に依存するライブラリが収まる。
さらに以下を実行する
yarn dev
デフォルトのWebアプリのサービスが起動する。
ターミナルに表示されるURLをブラウザで開くとWebアプリが表示される
準備 続き
不要なファイルを消す
不要なコードを削除、修正する
function App() {
return (
<>
<div>
</div>
</>
)
}
export default App
html {
font-family: sans-serif;
}
body {
margin: 0;
}
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でサービスを起動してブラウザで表示させると真っ白なページが表示される
準備 続き2
フォルダ構成
これというのはない。
.NETのMVVMでのアプリ開発で遭遇した悩みと同じものを感じる。
以下構成で進めてみる。多分実装が進むに連れてかわる。
参考
AtomicDesignでUI要素の自作を試みたが公開すべきプロパティをどうするか考えるだけで時間を浪費してしまうので潔くライブラリを使うことにした。
こちらを参考にMaterial UIを使う。
フロントエンドでの環境変数の持ち方メモ
実装
-
機能実装1巡目
ログイン、サインアップを実装してReactの作法に慣れる。 -
機能実装2巡目
一覧ページ、詳細ページ、編集・削除 などを実装。 -
見た目の調整
cssなりでいい感じに仕上げる。
実装 ログイン画面
見様見真似でログイン画面を実装してみた。
画面は要素をただ並べただけの状態。
以下メモ
- APIのリクエストはaxiosを使う。APIのリクエスト先は環境変数読み出しの仕組みを使って開発、本番環境で切り替えられるようにする。
- ログイン時のButtonはtype=submitで使う。ただしsubmitとすると画面がリロードされるのでそれを防ぐためにsubmitのイベントハンドラでpreventDefaultする。
- ReactのuseStateを活用してTextFieldの変更イベント時に内部的に保持する変数を更新する。
- API側の入力仕様に合わせた型でJson化したデータを送る。※なので先にAPI仕様を決めるのが重要。
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;
実装 ログイン画面<-->サインアップ画面の遷移
予め 「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;
実装 サインアップ機能の動作確認
バックエンド側のノウハウではあるがFastAPIの場合、サーバのURLの末尾に/docsとつけるとAPIドキュメントを参照できる。
さらにその中でAPIの手動実行ができる。
フロントエンド側でサインアップ実行
バックエンド側で確認
React + TypeScript で Emotion を使用するためのTips
emotionでスタイルを当てようとしたらうまく行かず、小一時間ハマった。
- /** @jsxImportSource @emotion/react */を書く
- tsconfig.jsonに設定を追記
/** @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
{
"compilerOptions": {
...
"types": ["@emotion/react/types/css-prop"]
}
参考
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;
ListPage -> DetailPage への遷移時にデータを渡す方法
遷移元:ListPage
useNavigationを使う。
navigation("遷移先のパス", "渡すデータ");
遷移先:DetailPage
useLocationを使う。
const item = location.state.item as 変換したい型
※ 遷移の際にAPIにアクセスせずにデータを渡す方法の一例。遷移時にIDだけ渡して遷移先でAPIからデータを取得する方法も考えられる。
VSCode, React, TypeScript, Vite の構成でVSCodeでデバッグする方法
最も準備に手数が少ない方法でやる。
-
launch.jsonを作成
-
Webアプリ(Edge)を選択
-
urlをyanr dev実行時に立ち上がるサービスのポートに修正する
-
yarn dev実行
サービスが起動する。 -
VSCodeでF5
Edgeが起動する。これでVSCode上にブレークポイントをおいたデバッグが可能になる。
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;
ComponentのStyle適用は機能実装が終わってからまとめてやろうと思っていたが、一つずつある程度のクオリティまで仕上げてからのほうがモチベーションを維持しやすい。(しょぼいUIのまま放置しているとやる気が下がる)
ページ遷移してもNvigationBarの選択状態を維持できるようにしたい
=> 何らかの状態管理ライブラリでアプリの状態を管理する
==> そんなことしなくてもApp全体で常に表示させるようにすればよい。
参考
横幅によって表示内容を切りかえる
MaterialUIを使った画面レイアウトの作成に凝り始めて機能実装が進まない。
見た目は少しずつ良くなってきた。
-
モバイルモード
-
PCモード
デフォルトでこのあたりのUXが実装されているGlideはすごいなー。
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'));
参考:
入力フォームのバリデーション
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;
...
アプリ全体で共有する変数へのアクセス
zustandを使う
※Reduxがメジャーなようだが、今回のような個人用小規模プロジェクトには不向きと判断した。
npm install zustand
方針
- 親ページとなるXXXListPageの読み込み時にAPIからfetchしてStoreにデータを格納する。
- 子ページor子ダイアログは改めてfetchせず、Storeに収まっているデータを参照する
コード例
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]});
},
}))
);
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>
);
};
汎用的なDialogの実装
WPF開発用のライブラリであるPrismのIDialogServiceを真似して自作してみた。
Add, Editで入力内容は同じなので初期値とタイトルを切り替えるようにした。
また、Submit押下後の振る舞いも切り替えられるようにした。
コード例
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;
}
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;
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;
useStateの配列への追加、更新、削除
参考
エンティティ追加時の機序メモ
- Add操作実行
この中でAdd用のAPIにリクエストする - DBに追加されてIDが付与されたエンティティが返ってくる
- Zustandで管理している配列(xxxItems)に追加する
xxxStoreにaddXXXメソッドを用意する - useEffectでxxxItemsの更新を監視しておき、表示用配列(xxxViewItems)を更新する
- useEffectでxxxViewItems、検索文字列を監視しておき、検索後表示用配列(filteredxxxViewItems)を更新する
xxxViewItemsは不要かもしれない。直接xxxItems => filteredxxxViewItemsを生成できる。
ようやくオーソドックスなCRUD操作の実装が完了した。
Reactの扱い方はだいたい理解できた。
長くなったのでこの記事は一旦Closeする。
残:
- 他のエンティティの実装
- 認証周り
- 本物DB準備
- どこかにデプロイ