【要件】
1. ユーザ登録機能
- メールアドレスとパスワード、ユーザ名を入力して登録可能
2. ログイン機能
- メールアドレスとパスワードにてログイン可能
3. ダッシュボード
- ログイン後はダッシュボードへ遷移
- ダッシュボードでは、ログインしているユーザ名が表示される
- ダッシュボードでは、自身のウォレット(残高)が表示
4. ログアウト機能
- ダッシュボードにログアウトボタン配置
- ログアウトボタン押下で、ログアウトされログイン画面へ遷移
- ログアウトされた状態でダッシュボードへアクセスすると、ログイン画面へリダイレクト
5. ユーザ一覧表示
- ダッシュボードに、自分以外の登録ユーザが一覧表示
- ユーザを選択し、そのユーザーのウォレットを確認可能
6. 投げ銭機能
- ユーザを選択し、渡す通貨の量を入力後、投げ銭可能
- 投げ銭後は即時にウォレットへ反映される
学習した内容
- firebaseの環境構築
- メールアドレスとパスワードを使用したログイン機能の実装
(firebase Authentication) - firebase cloudfirestoreの使い方
(データの保存、ドキュメントの呼び出し、データの更新、トランザクション) - データを保存/取り出し
- ユーザー登録
- ログイン機能
環境・使用技術
- react-router-dom : ルーティング(ページ移動)モジュール
- firebase : Authentication機能を利用
- react-redux : 状態管理
- redux-thunk : 非同期処理
- material-ui : cssフレームワー
環境の準備
①ターミナルでreactアプリケーションを作成する。
$ npx create-react-app <プロジェクト名>
% cd <プロジェクト名>
% npm start
Material-UI を利用するため、事前にライブラリをインストールする。
$ npm install @mui/material @emotion/react @emotion/styled
$ git diff -p
コンポーネント・ファイル構成
src
├── auth
├── AuthProvider.js
└── Login.js
└── PrivateRoute.js
└── SignUp.js
├── components
├── Header.js
└── Content.js
├── firebase
└── Firebase.js
├── App.js
1. ユーザ登録機能
6. 投げ銭機能
① ベースとなる画面構成を作成する
『ファイルタイプ別にグループ化する』方法を用いてソースを管理するため components ディレクトリを src ディレクトリ配下に配置し、Header コンポーネントと Content コンポーネントを定義するため、ファイルを用意する。
$ mkdir src/components
$ touch src/components/Header.js
$ touch src/components/Content.js
② App.jsを編集する
import { Grid } from "@material-ui/core";
import Header from "./components/Header";
import Content from "./components/Content";
function App() {
return (
<Grid container direction="column">
<Header />
<div style={{ padding: 30 }}>
<Content />
</div>
</Grid>
);
}
export default App;
③ Header.js/ Content.jsを編集する
import React from "react";
import {
AppBar,
Box,
Toolbar,
Typography,
IconButton,
FormGroup,
FormControlLabel,
Switch,
Menu,
MenuItem,
} from "@material-ui/core";
const Header = () => {
const [auth, setAuth] = React.useState(true);
const [anchorEl, setAnchorEl] = React.useState(null);
const handleChange = (event) => {
setAuth(event.target.checked);
};
const handleMenu = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<Box sx={{ flexGrow: 1 }}>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={auth}
onChange={handleChange}
aria-label="login switch"
/>
}
label={auth ? "Logout" : "Login"}
/>
</FormGroup>
<AppBar position="static">
<Toolbar>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
></IconButton>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
課題③
</Typography>
{auth && (
<div>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
></IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>Profile</MenuItem>
<MenuItem onClick={handleClose}>My account</MenuItem>
</Menu>
</div>
)}
</Toolbar>
</AppBar>
</Box>
);
};
export default Header;
import { Grid } from "@material-ui/core";
function Content() {
return (
<Grid container>
<Grid sm={2} />
<Grid lg={8} sm={8} spacing={10}>
コンテンツ
</Grid>
</Grid>
);
}
export default Content;
④ ブラウザで確認する
Content.js を編集
今回のアプリでは主に3つの領域があり、下記をdivタグで作成する。
- 残高のエリア
- ユーザの入力エリア
- ユーザ一覧エリア
追加機能の実装
インプットエリアに入力し、追加ボタンを押したものがユーザ一覧エリアに追加されていくような機能を実装する。まずは、今アプリで使用するstateを一通り宣言する。
今回は、下記の3つのstateを使う。
- 入力用にinputTodo
- ユーザ一覧用にincompleteTodos
stateを用意したら、incompleteTodosという配列を、mapで回して表示するようにする。このリストの要素にkeyを渡すのを忘れないようにする。
次に、キーボードから入力したものがインプットエリアに表示されるようにする。
これは、インプットエリアにonChangeイベントを持たせ、入力された値をsetInputTodoで変更させるようにすればOK。
次に、追加ボタンが押された時の機能を実装する。
これはボタンが押された時に、inputTodoの値をincompleteTodosに追加するような機能を実装すれば完了。
一応、空の状態でボタンが押された場合のことも考えて、空の場合はreturnするような実装にしておく。また、入力後はインプットエリアが空になるようにもしておく。
削除機能の実装
削除ボタンが押された時に、そのTodoが削除されるような機能を実装する。これは、ボタンが押された時に押されたTodoを削除するようなクリックイベントを付与すれば完成する。
しかし、現時点では押されたTodoがどれなのかを判別する手段がないので、mapにindexを追加する。そして、クリックイベントにindexを引数として渡すようにしてやれば、配列の何番目の要素が押されたかを判別できる。後は、そのindexから1つを削除するためにspliceを使い、新しく作成した配列をsetIncompleteTodosで設定すれば完了。
import React, { useState } from "react";
function App() {
const [inputTodo, setInputTodo] = useState("");
const [incompleteTodos, setIncompleteTodos] = useState(["test1", "test2"]);
const onChangeInputTodo = (e) => {
setInputTodo(e.target.value);
};
const onClickAdd = () => {
if (inputTodo === "") return;
const newTodos = [...incompleteTodos, inputTodo];
setIncompleteTodos(newTodos);
setInputTodo("");
};
const onClickDelete = (i) => {
const newTodos = [...incompleteTodos];
newTodos.splice(i, 1);
setIncompleteTodos(newTodos);
};
return (
<div className="App-header">
<div className="User-list">
<h2>受取人一覧</h2>
<input
placeHolder="TODOを入力"
value={inputTodo}
onChange={onChangeInputTodo}
/>
<button onClick={onClickAdd}>追加</button>
</div>
<div>
<h3>受取人名</h3>
<ol>
{incompleteTodos.map((todo, index) => {
return (
<div key={todo}>
<li>{todo}</li>
<button>walletを見る</button>
<button>送金</button>
<button onClick={() => onClickDelete(index)}>削除</button>
</div>
);
})}
</ol>
// </div>
);
}
export default App;
『Walletを見る』『送金』ボタンを実装
material-uiの同じフラグopen={open} を2種類のダイアログの表示条件に使っているため、2種類のダイアログが同時に表示される。片方をisOpen={IsOpen}にすれば、
片方ずづ実装できる。
残高機能の実装
stateを使って実装する。ボタンを作成し、ボタンを押すことにより残高が増えたり、
減ったりすることができるように実装する。
① 送金者の残高機能の実装する。
function App() {
const [count, setCount] = useState(0);
const [num, setNum] = useState(100);
const onCountUp = () => {
setCount(count + num);
};
const onCountDown = () => {
setCount(count - num);
};
return (
<div className="App-header">
<div className="Balance-list">
<h2>送金者残高 : {count} 円 </h2>
<input
value={count}
onChange={(e) => setCount(Number(e.target.value))}
/>{" "}
+
<input value={num} onChange={(e) => setNum(Number(e.target.value))} />
<button onClick={onCountUp}>Increment</button>
<button onClick={onCountDown}>Increment</button>
</div>
</div>
);
}
② 送金者が受金者へ『振込』すれば、送金者、受金者の残高機能の残高が変動の実装をする。
const handleClickTransferButton = () => {
onCountDown();
onBalanceUp();
};
この handleClickTransferButton を Button の onClick に渡す。
受取人追加後の実装
①『walletを見る』もしくは『送金』を押したら、モーダル内に表示される名前が追加した名前と同じA,B,C表示されるように実装する。
原因
原因は、元々の書き方は受取人のデータ( todos )をループを回してテーブルを作成していた。この処理自体は問題ないが、ダイアログもループの中で作っていたので、A,B,Cの3人がいたらA,B,Cの3人分のダイアログが表示されてしまい、3つのダイアログが同時に重なって表示されるため最後に追加したCのダイアログが表示されるように見えていました(見えないだけで、実はCのダイアログの後ろにA,Bのダイアログも隠れて表示されていた)。
修正の意図としては、ダイアログは常に1つで良いのでループの中で作らないように修正。
また、ダイアログの中に表示する受取人が誰なのかはボタンを押した段階で決まるので、 『targetTodo』という値を用意して、『setIsOpen』や『setOpen』が実行されるタイミングで 『targetTodo』がセットされるようにした。
あとはソースを見やすくするために 『TodoWalletDialog』と 『TodoTransferDialog』 という名前で別コンポーネントに切り出した。
//count 送金者の残高
//num 送金者の入金及び出勤額
//balance 受取人の残高
import React, { useState } from "react";
import Modal from "react-modal";
import {
Box,
TextField,
Button,
ButtonGroup,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Typography,
} from "@material-ui/core";
const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "background.paper",
border: "2px solid #000",
boxShadow: 24,
p: 4,
};
// 対象の受取人のWalletダイアログ
const TodoWalletDialog = ({ isOpen, todo, handleClose }) => {
if (!todo) return null;
return (
<Modal
className="wallet"
isOpen={isOpen}
onClose={handleClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<Typography id="modal-modal-title" variant="h6" component="h2">
{todo.comment}
</Typography>
<Typography id="modal-modal-description" sx={{ mt: 2 }}>
残高 : {todo.balance} 円
</Typography>
<Button variant="outlined" color="secondary" onClick={handleClose}>
Close
</Button>
</Box>
</Modal>
);
};
// 対象の受取人への送金ダイアログ
const TodoTransferDialog = ({
open,
todo,
num,
setNum,
handleClickClose,
handleClickTransferButton,
}) => {
if (!todo) return null;
return (
<Dialog open={open} onClose={handleClickClose}>
<DialogTitle>{todo.comment}</DialogTitle>
<DialogContent>
<DialogContentText>残高: {todo.balance} 円</DialogContentText>
<Box
component="form"
sx={{
"& > :not(style)": { m: 1, width: "25ch" },
}}
noValidate
autoComplete="off"
>
<TextField
className="balance"
id="outlined-basic"
label="Enter money(振込金額)"
variant="outlined"
value={num}
onChange={(e) => setNum(Number(e.target.value))}
/>
</Box>
</DialogContent>
<ButtonGroup
variant="outlined"
color="primary"
aria-label="outlined primary button group"
></ButtonGroup>
<DialogActions>
<Button variant="outlined" color="secondary" onClick={handleClickClose}>
Cancel
</Button>
<Button
onClick={handleClickTransferButton}
variant="outlined"
color="primary"
>
振込
</Button>
</DialogActions>
</Dialog>
);
};
//受取人 todos
//未完了の受取人 comment
function App() {
const [count, setCount] = useState(0);
const [balance, setBalance] = useState(0);
const [num, setNum] = useState(100);
const [todos, setTodos] = useState([]);
const [comment, setComment] = useState("");
const [open, setOpen] = React.useState(false);
const [IsOpen, setIsOpen] = React.useState(false);
const [targetTodo, setTargetTodo] = React.useState(null);
const onCountUp = () => {
setCount(count + num);
};
const onCountDown = () => {
setCount(count - num);
// setBalance(balance + num);
};
const onBalanceUp = () => {
setBalance(balance + num);
};
const onChangeTodo = (e) => {
setComment(e.target.value);
};
const addTodo = () => {
if (comment === "") return;
const todo = {
id: todos.length + 1,
comment,
status: "incomplete",
};
setTodos([...todos, todo]);
setComment("");
};
const handleDelete = (index) => {
const newTodos = [...todos];
newTodos.splice(index, 1);
setTodos(newTodos);
setTodos(newTodos.map((e, i) => ({ ...e, id: i + 1 })));
};
const handleClickOpen = (todo) => {
setTargetTodo(todo);
setOpen(true);
};
const handleClickOpen2 = (todo) => {
setTargetTodo(todo);
setIsOpen(true);
};
const handleClickClose = () => {
setOpen(false);
};
const handleClose = () => {
setIsOpen(false);
};
const handleClickTransferButton = () => {
onCountDown();
onBalanceUp();
};
return (
<div className="App-header">
<div className="balance-list">
<h2>残高 : {count} 円 </h2>
<Box
component="form"
sx={{
"& > :not(style)": { m: 1, width: "25ch" },
}}
noValidate
autoComplete="off"
>
<TextField
label="入出金額"
variant="outlined"
value={num}
onChange={(e) => setNum(Number(e.target.value))}
/>
</Box>
<Button onClick={onCountUp} variant="outlined" color="primary">
Increment
</Button>
<Button onClick={onCountDown} variant="outlined" color="secondary">
Decrement
</Button>
</div>
<div>
<h2>受取人一覧</h2>
<Box
component="form"
sx={{
"& > :not(style)": { m: 1, width: "25ch" },
}}
noValidate
autoComplete="off"
>
<TextField
id="outlined-basic"
label="Enter Receiver (受取人追加)"
variant="outlined"
value={comment}
onChange={onChangeTodo}
/>
</Box>
<Button variant="outlined" color="primary" onClick={addTodo}>
Addition
</Button>
</div>
<table>
<tbody>
<h2>受取人名</h2>
{todos.map((todo, index) => {
return (
<tr key={todo.id}>
<td>{todo.id}</td>
<td>{todo.comment}</td>
<td>
<Button
className="wallet"
variant="outlined"
color="primary"
onClick={() => {
handleClickOpen2(todo);
}}
>
walletを見る
</Button>
<Button
className="sendmoney"
variant="outlined"
color="primary"
onClick={() => {
handleClickOpen(todo);
}}
>
送金
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => handleDelete(index)}
>
削除
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
<TodoWalletDialog
isOpen={IsOpen}
todo={targetTodo}
handleClose={handleClose}
/>
<TodoTransferDialog
open={open}
todo={targetTodo}
num={num}
setNum={setNum}
handleClickClose={handleClickClose}
handleClickTransferButton={handleClickTransferButton}
/>
</div>
);
}
export default App;
参考サイト
【React Redux初心者向け】Todoリスト作成を通してしっかり学ぶRedux
Building a Todo app using React, redux and material-ui
【Reactチュートリアル】Todoリストを作ってみよう