Reactのチュートリアル、学習記録アプリ Firebase認証・DB実装の後編となります。
前編では、React環境準備と、Firebaseのプロジェクト・認証・DB設定、ログイン機能とDBデータ表示・更新の実装まで行いました。
後編の今回は、DBへの新規データ登録・削除機能の実装、ユーザー認証の新規サインアップ、パスワード変更・リセットの実装と、GitHubリポジトリの利用、Firebaseホスティングへの連携・自動デプロイの実現について記載しています。
なお、Firebaseの機能実装に当たり、参考にしたサイトは以下のサイトです。
こちらの記事は、少々古い箇所もあるので、適宜、最新の仕様に合わせて作成しています。また、やはりFirebaseの公式ドキュメントもとても重要です。
はじめに
本記事は、Reactの初学者向けのチュートリアルコンテンツです。Reactの基本的な使い方から実際にアプリケーションをデプロイしてユーザーが使えるリリースまでを行えます。また、認証機能やDB処理にFirebaseのサービスを活用しており、バックエンドの要素も包括しています。
React環境は、Vite+TypeScript
CSS+UIツールとしては、Chakra UI、アイコンとして、React Icons
BaaSとして、認証機能及び、DB、ホスティングサービスをGoogleのFirebaseで実現しています。
本記事で、以下の対応が可能です。
- React+Vite環境の構築
- TypeScriptのコード記述
- ChakraUIの導入・利用
- Reactアイコンの利用
- useStateによるState管理、Propsの扱い、useEffectの使用
- React Routerによるルート設定、制御
- Formイベント、イベントハンドラーの扱い
- カスタムフックの活用
- async/awaitによる非同期処理
- Firebaseのプロジェクト作成、アプリの定義
- Firebaseの認証設定と、認証制御、セッション管理
- Firebase、FirestoreDBの環境設定とテーブル処理(表示、登録、更新、削除)
- GitHubリポジトリの扱い
- Firebaseホスティングの利用、GitHubと連携した自動デプロイ 等
前回はFirebase認証によるログインとFirestoreDBのデータ取得・更新まで行いました。後編の今回は、DBデータへの登録・削除と、新規ユーザーのサインアップ機能、パスワードリセット・変更機能の実装、及びGitHub・Firebaseホスティングとの連携と自動デプロイの実装について解説していきます。
作成するアプリ構造のイメージは下図となります。
6. DBデータ新規登録
後編と言うことで、第6章から開始です。
6章ではFirestoreDBへのデータ新規登録機能を開発していきます。
前編5章の冒頭で記載していた通り、この学習記録アプリにおいては、新規学習データ登録時、既存タイトルと被るものは、既存タイトルの学習時間を合算し更新処理を行います。
まずは、カスタムフック、useFirebaseの変更です。
6.1 useFirebaseの変更
カスタムフック、useFirebaseに、新たにFirestoreDBにデータを登録する機能を追加します。
変更後のuseFirebas.tsxを以下記載します。下記はコード全文です。
// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { signInWithEmailAndPassword, User } from "firebase/auth";
import { addDoc, collection, doc, getDocs, query, updateDoc, where } from "firebase/firestore";//addDoc追加
import { auth, db } from "../utils/firebase";
import { StudyData } from "../types/studyData";
type UseFirebase = () => {
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
email: string;
setEmail: React.Dispatch<React.SetStateAction<string>>;
password: string;
setPassword: React.Dispatch<React.SetStateAction<string>>;
handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
learnings: StudyData[];
setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;
fetchDb: (data: string) => Promise<void>
calculateTotalTime: () => number
updateDb: (data: StudyData) => Promise<void>
entryDb: (data: StudyData) => Promise<void>;//追加
}
export const useFirebase: UseFirebase = () => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [user, setUser] = useState<User | null>(null);
const [learnings, setLearnings] = useState<StudyData[]>([]);
const navigate = useNavigate()
const toast = useToast()
////Authentication
//ログイン処理
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
const userLogin = await signInWithEmailAndPassword(auth, email, password);
console.log("User Logined:", userLogin);
toast({
title: 'ログインしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/')
}
catch (error) {
console.error("Error during sign up:", error);
toast({
title: 'ログインに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
finally {
setLoading(false);
}
};
//ユーザがセッション中か否かの判定処理
useEffect(() => {
const unsubscribed = auth.onAuthStateChanged((user) => {
setUser(user);
if (user) {
setEmail(user.email as string)
} else {
navigate("/login");//userがセッション中でなければ/loginに移動
}
});
return () => {
unsubscribed();
};
}, [user]);
////Firestore
//Firestoreデータ取得
const fetchDb = async (data: string) => {
setLoading(true);
try {
const usersCollectionRef = collection(db, 'users_learnings');
const q = query(usersCollectionRef, where('email', '==', data)); // ログインユーザーのemailでフィルタ
const querySnapshot = await getDocs(q);
const fetchedLearnings = querySnapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
} as StudyData));// Firebaseから取得したデータを`StudyData`型に明示的に変換
console.log("取得したデータ:", fetchedLearnings)
setLearnings(fetchedLearnings); // 正しい型でセット
}
catch (error) {
console.error("Error getting documents: ", error);
}
finally {
setLoading(false);
}
}
//Firestoreデータ更新
const updateDb = async (data: StudyData) => {
setLoading(true)
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await updateDoc(userDocumentRef, {
title: data.title,
time: data.time
});
toast({
title: 'データ更新が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.log(error)
toast({
title: 'データ更新に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
} finally {
setLoading(false)
}
}
//追加:Firestoreデータ新規登録
const entryDb = async (data: StudyData) => {
setLoading(true)
try {
const usersCollectionRef = collection(db, 'users_learnings');
const documentRef = await addDoc(usersCollectionRef, {
title: data.title,
time: data.time,
email: email
});
console.log(documentRef, data);
toast({
title: 'データ登録が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error adding document: ", error);
toast({
title: 'データ登録に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false)
}
}
////Others
//学習時間合計
const calculateTotalTime = () => {
return learnings.reduce((total, learning) => total + learning.time, 0);
};
return {
loading,
setLoading,
email,
setEmail,
password,
setPassword,
handleLogin,
user,
setUser,
learnings,
setLearnings,
fetchDb,
calculateTotalTime,
updateDb,
entryDb//追加
}
}
コメントで追加と記載している箇所が追加箇所になります。
// /src/hooks/useFirebse.ts
import { addDoc, collection, doc, getDocs, query, updateDoc, where } from "firebase/firestore";//addDoc追加
.
.
entryDb: (data: StudyData) => Promise<void>;//追加
.
.
//追加:Firestoreデータ新規登録
const entryDb = async (data: StudyData) => {
.
.
return {
.
.
entryDb//追加
}
冒頭インポートの箇所は、FirebaseSDKのfirebase/firestoreから、addDocメソッドを追加しています。
型定義の箇所は、新たに追加している、DB更新処理の関数、entryDbの型定義を追加しています。
間に追加した関数entryDbの内容があり、最後のreturn文の箇所にentryDbをオブジェクトとして追加しています。
追加した関数entryDbについて具体的に説明します。
// /src/hooks/useFirebse.ts
//追加:Firestoreデータ新規登録
const entryDb = async (data: StudyData) => {//async/awaitで処理実施、dataは学習記録のStudyDataの型
setLoading(true)//ローディング中にセット
try {
const usersCollectionRef = collection(db, 'users_learnings');//対象コレクションとして'users_learnings'を指定
const documentRef = await addDoc(usersCollectionRef, {
//FirebaseSDKのaddDocにより、'users_learnings'に、title, time, emailを新規追加(title,timeはStudyData型、emailはstringで追加)
title: data.title,
time: data.time,
email: email
});
console.log(documentRef, data);
toast({//処理が正常終了すれば、Chakra UIのToast機能で、正常終了メッセージ表示
title: 'データ登録が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error adding document: ", error);
toast({//エラー発生時は、Chakra UIのToast機能で、エラーメッセージ表示
title: 'データ登録に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false)//最後にローディング状態を解除
}
}
コード中にコメントを記載してますが、fetchDb、updateDbと同様、async/awaitで処理実施しています。dataの型は、StudyDataです。
ローディングをセットして、try文の箇所は、FirebaseSDKのメソッドaddDocで追加処理を行っています。なお、data(title, time)の他、emailも登録データとして渡しています。
正常終了すれば、Chakra UIのToast機能で成功メッセージ表示、エラー発生時は同様にエラーメッセージを表示させています。
最後にローディングを解除して終了です。
6.2 Home.tsxの変更
続いて、updateDb時と同様、Homeコンポーネントに新規登録用のモーダルを追加していきます。
以下は、変更後のHome.tsxコード全文です。
// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import {
Box, Button, Card, CardBody, Flex, FormControl, FormLabel, Heading, Input, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Stack,
Table, TableContainer, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast
} from "@chakra-ui/react";
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";
import { StudyData } from "../types/studyData";
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb } = useFirebase()//追加:entryDb
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()//追加
const initialRef = useRef(null)
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })//追加
const toast = useToast()
useEffect(() => {
if (user) {
fetchDb(email)
console.log('Firestore', email)
}
}, [user]);
const handleUpdate = async () => {
await updateDb(editLearning);
fetchDb(email)
if (!loading) {
setTimeout(() => {
modalEdit.onClose();
}, 500);
}
}
const handleEntry = async () => {//追加:クリック時、入力データの新規登録、もしくは既存データの更新を実施
if (learnings.some((l) => l.title === entryLearning.title)) {
const existingLearning = learnings.find((l) => l.title === entryLearning.title);
if (existingLearning) {
existingLearning.time += entryLearning.time;
await updateDb(existingLearning);
}
} else {
await entryDb(entryLearning);
}
fetchDb(email)
setEntryLearning({ id: "", title: "", time: 0 })
if (!loading) {
setTimeout(() => {
modalEntry.onClose()
}, 500);
}
};
return (
<>
<Flex alignItems='center' justify='center' p={5}>
<Card size={{ base: 'sm', md: 'lg' }}>
<Box textAlign='center' mb={2} mt={10}>
ようこそ!{email} さん
</Box>
<Heading size='md' textAlign='center'>Learning Records</Heading>
<CardBody>
<Box textAlign='center'>
学習記録
{loading && <Box p={10}><Spinner /></Box>}
<TableContainer>
<Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
<Thead>
<Tr>
<Th>学習内容</Th>
<Th>時間(分)</Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{learnings.map((learning, index) => (
<Tr key={index}>
<Td>{learning.title}</Td>
<Td>{learning.time}</Td>
<Td>
{/* 編集用モーダル */}
<Button variant='ghost' onClick={() => {
setEditLearning(learning)
modalEdit.onOpen()
}}><FiEdit color='black' /></Button>
<Modal
initialFocusRef={initialRef}
isOpen={modalEdit.isOpen}
onClose={modalEdit.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>記録編集</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
placeholder='学習内容'
name='title'
value={editLearning.title}
onChange={(e) => {
setEditLearning({ ...editLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
placeholder='学習時間'
name='time'
value={editLearning.time}
onChange={(e) => {
setEditLearning({ ...editLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{editLearning.title}</div>
<div>入力されている学習時間:{editLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (editLearning.title !== "" && editLearning.time > 0) {
handleUpdate()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
データを更新
</Button>
<Button onClick={() => {
modalEdit.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Td>
<Td>
<Button variant='ghost'
onClick={() => { }}><MdDelete color='black' /></Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
<Box p={5}>
<div>合計学習時間:{calculateTotalTime()}分</div>
</Box>
{/*コメントで囲んだ部分を下記の新規登録モーダル箇所と置換え
<Box p={25}>
<Stack spacing={3}>
<Button
colorScheme='green'
variant='outline'
onClick={() => { }}
>新規データ登録
</Button>
</Stack>
</Box>*/}
{/* 新規登録モーダルここから */}
<Box p={25}>
<Stack spacing={3}>
<Button
colorScheme='green'
variant='outline'
onClick={modalEntry.onOpen}>
新規データ登録
</Button>
</Stack>
<Modal
initialFocusRef={initialRef}
isOpen={modalEntry.isOpen}
onClose={modalEntry.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>新規データ登録</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
name='newEntryTitle'
placeholder='学習内容'
value={entryLearning.title}
onChange={(e) => {
setEntryLearning({ ...entryLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
name='newEntryTime'
placeholder='学習時間'
value={entryLearning.time}
onChange={(e) => {
setEntryLearning({ ...entryLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{entryLearning.title}</div>
<div>入力されている学習時間:{entryLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (entryLearning.title !== "" && entryLearning.time > 0) {
handleEntry()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
登録
</Button>
<Button onClick={() => {
modalEntry.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
{/* 新規登録モーダルここまで */}
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => { }}
>ログアウト</Button>
</Stack>
</Box>
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => { }}
>パスワード更新</Button>
</Stack>
</Box>
</CardBody>
</Card>
</Flex>
</>
)
}
export default Home;
インポートの箇所は変更ありません。
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb } = useFirebase()//追加:entryDb
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()//追加
const initialRef = useRef(null)
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })//追加
const toast = useToast()
フックの箇所は、useFirebaseのオブジェクトに、entryDbを追加しています。また、モーダル開閉制御用のuseDisclosure()について、新たに新規登録モーダル用のmodalEntryを追加しています。及び、新規登録時の学習記録用ローカルステート、entryLearningを追加しています。これはDB更新処理時に追加したeditLearningを転用することも考えたのですが、stateの初期化や値セットが複雑になりそうなので、個別に分けることにしました。
続いて、入力・編集された値の処理の関数関係ですが、新たにhandleEntryを追加しています。
// /src/components/Home.tsx
const handleEntry = async () => {//追加:クリック時、入力データの新規登録、もしくは既存データの更新を実施
if (learnings.some((l) => l.title === entryLearning.title)) {//入力データのtitleが既存アイテムに存在するか確認
const existingLearning = learnings.find((l) => l.title === entryLearning.title);//既存確認結果をexistingLearningに代入
if (existingLearning) {//既存アイテムがあれば
existingLearning.time += entryLearning.time;//該当既存アイテムの時間に入力された時間を加算し、
await updateDb(existingLearning);//DBデータを更新
}
} else {//既存アイテムがなければ、
await entryDb(entryLearning);//DBへのデータ新規登録を実施
}
fetchDb(email)//処理完了後、反映されたDBデータを改めて取得
setEntryLearning({ id: "", title: "", time: 0 })//entryLearningを初期化
if (!loading) {//ローディング解除されたら、0.5秒後、モーダルをクローズ
setTimeout(() => {
modalEntry.onClose()
}, 500);
}
};
handleEntryは、入力されたデータを既にあるアイテムがどうかを判定した上で、useFirebaseの関数を利用し、既存アイテムがある場合は学習時間を加算してデータ更新、新規アイテムの場合は、新規アイテムとしてDBへの登録処理を行います。新規登録モーダルの「登録」ボタンをクリックすると発動します。
それぞれの処理の説明は、コメント中に記載している通りです。処理の流れが、5章のデータ更新時と同様ですが、新規登録の場合は、処理完了後、entryLearningの初期化を行っています。これは、新規登録完了後に再び新規登録モーダルを開いた際に入力値を初期化しておくためです。この辺は、使い勝手の考え方にもよりますが、前回登録したデータをそのまま残しておくのであれば、この処理は不要です。
続いて、JSXの箇所です。
// /src/components/Home.tsx
{/*コメントで囲んだ部分を下記の新規登録モーダル箇所と置換え
<Box p={25}>
<Stack spacing={3}>
<Button
colorScheme='green'
variant='outline'
onClick={() => { }}
>新規データ登録
</Button>
</Stack>
</Box>*/}
{/* 新規登録モーダルここから */ }
<Box p={25}>
<Stack spacing={3}>
<Button colorScheme='green'//モーダルオープンのボタン部分
variant='outline'
onClick={modalEntry.onOpen}//モーダルをオープンする。新規登録用のモーダルのため、modalEntry.onOpen()の形で指定。
>
新規データ登録
</Button>
</Stack>
<Modal
initialFocusRef={initialRef}//ここからモーダルの内容
isOpen={modalEntry.isOpen}//モーダルのオープン状態を監視、新規登録用のモーダルのため、modalEntry.を付与
onClose={modalEntry.onClose}//モーダルクローズの定義、新規登録用のモーダルのため、modalEntry.を付与
>
<ModalOverlay />
<ModalContent>
<ModalHeader>新規データ登録</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
name='newEntryTitle'
placeholder='学習内容'
value={entryLearning.title}//valueは新規登録用のロカールステートを利用
onChange={(e) => {//Inputエリアのtitleの入力値をsntryLearning.titleに格納
setEntryLearning({ ...entryLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
name='newEntryTime'
placeholder='学習時間'
value={entryLearning.time}//valueは新規登録用のロカールステートを利用
onChange={(e) => {//Inputエリアのtimeの入力値をsntryLearning.titleに数値に変換して格納
setEntryLearning({ ...entryLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{entryLearning.title//新規登録用ロカールステートを利用
}</div>
<div>入力されている学習時間:{entryLearning.time//新規登録用ロカールステートを利用
}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (entryLearning.title !== "" && entryLearning.time > 0) {
//学習タイトルと時間が共に入力されていれば、
handleEntry()//DB新規登録処理実行(登録済アイテムであれば既存アイテムに時間加算して更新)
}
else {
toast({//学習タイトルと時間が共に入力されていなければ、エラーメッセージ表示
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
登録
</Button>
<Button onClick={() => {//cancelクリックの場合、そのままモーダルクローズ
modalEntry.onClose()//新規登録用のモーダルのため、modalEntry.を付与
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
{/* 新規登録モーダルここまで */ }
追加した新規登録用モーダルの箇所のみ掲載しています。
冒頭にある、今まで「新規データ登録」ボタン表示のみだった<Button>…</Button>の箇所を、その下に記載している、「追加:新規登録モーダルここから」から一番下側の「新規登録モーダルここまで」に置換え・追加します。内容の説明については、コメント記載通りです。また、5.2章での説明と同じような内容となります。
ここまでの実装で以下画面のような動きが実現できます。
FirestoreDBにも新たにデータが登録されていることが確認できます。
7. DBデータ削除
続いて、FirestoreDBのデータ削除機能を実装していきます。これまでの編集、新規登録と同じような流れです。
7.1 useFirebaseの変更
これまでと同様、まずは、カスタムフック、useFirebaseへのDB削除機能の追加を行っていきます。
変更後のuseFirebas.tsxを以下記載します。下記はコード全文です。
// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { signInWithEmailAndPassword, User } from "firebase/auth";
import { addDoc, collection, deleteDoc, doc, getDocs, query, updateDoc, where } from "firebase/firestore";//deleteDoc追加
import { auth, db } from "../utils/firebase";
import { StudyData } from "../types/studyData";
type UseFirebase = () => {
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
email: string;
setEmail: React.Dispatch<React.SetStateAction<string>>;
password: string;
setPassword: React.Dispatch<React.SetStateAction<string>>;
handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
learnings: StudyData[];
setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;
fetchDb: (data: string) => Promise<void>
calculateTotalTime: () => number
updateDb: (data: StudyData) => Promise<void>
entryDb: (data: StudyData) => Promise<void>;
deleteDb: (data: StudyData) => Promise<void>//追加
}
export const useFirebase: UseFirebase = () => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [user, setUser] = useState<User | null>(null);
const [learnings, setLearnings] = useState<StudyData[]>([]);
const navigate = useNavigate()
const toast = useToast()
////Authentication
//ログイン処理
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
const userLogin = await signInWithEmailAndPassword(auth, email, password);
console.log("User Logined:", userLogin);
toast({
title: 'ログインしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/')
}
catch (error) {
console.error("Error during sign up:", error);
toast({
title: 'ログインに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
finally {
setLoading(false);
}
};
//ユーザがセッション中か否かの判定処理
useEffect(() => {
const unsubscribed = auth.onAuthStateChanged((user) => {
setUser(user);
if (user) {
setEmail(user.email as string)
} else {
navigate("/login");//userがセッション中でなければ/loginに移動
}
});
return () => {
unsubscribed();
};
}, [user]);
////Firestore
//Firestoreデータ取得
const fetchDb = async (data: string) => {
setLoading(true);
try {
const usersCollectionRef = collection(db, 'users_learnings');
const q = query(usersCollectionRef, where('email', '==', data)); // ログインユーザーのemailでフィルタ
const querySnapshot = await getDocs(q);
const fetchedLearnings = querySnapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
} as StudyData));// Firebaseから取得したデータを`StudyData`型に明示的に変換
console.log("取得したデータ:", fetchedLearnings)
setLearnings(fetchedLearnings); // 正しい型でセット
}
catch (error) {
console.error("Error getting documents: ", error);
}
finally {
setLoading(false);
}
}
//Firestoreデータ更新
const updateDb = async (data: StudyData) => {
setLoading(true)
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await updateDoc(userDocumentRef, {
title: data.title,
time: data.time
});
toast({
title: 'データ更新が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.log(error)
toast({
title: 'データ更新に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
} finally {
setLoading(false)
}
}
//Firestoreデータ新規登録
const entryDb = async (data: StudyData) => {
setLoading(true)
try {
const usersCollectionRef = collection(db, 'users_learnings');
const documentRef = await addDoc(usersCollectionRef, {
title: data.title,
time: data.time,
email: email
});
console.log(documentRef, data);
toast({
title: 'データ登録が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error adding document: ", error);
toast({
title: 'データ登録に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false)
}
}
//追加:Firestoreデータ削除
const deleteDb = async (data: StudyData) => {
setLoading(true);
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await deleteDoc(userDocumentRef);
toast({
title: 'データを削除しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error during delete:", error);
toast({
title: 'デー削除に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
////Others
//学習時間合計
const calculateTotalTime = () => {
return learnings.reduce((total, learning) => total + learning.time, 0);
};
return {
loading,
setLoading,
email,
setEmail,
password,
setPassword,
handleLogin,
user,
setUser,
learnings,
setLearnings,
fetchDb,
calculateTotalTime,
updateDb,
entryDb,
deleteDb//追加
}
}
コメントで追加と記載している箇所が追加箇所になります。
// /src/hooks/useFirebse.ts
import { addDoc, collection, deleteDoc, doc, getDocs, query, updateDoc, where } from "firebase/firestore";//deleteDoc追加
.
.
deleteDb: (data: StudyData) => Promise<void>//追加
.
.
//追加:Firestoreデータ削除
const deleteDb = async (data: StudyData) => {
.
.
return {
.
.
deleteDb//追加
}
冒頭インポートの箇所は、FirebaseSDKのfirebase/firestoreから、deleteDocメソッドを追加しています。型定義の箇所は、新たに追加している、DB更新処理の関数、deleteDbの型定義を追加しています。
間に追加した関数deleteDbの内容があり、最後のreturn文の箇所にdeleteDbをオブジェクトとして追加しています。
追加した関数deleteDbについて具体的に説明します。
// /src/hooks/useFirebse.ts
//追加:Firestoreデータ削除
const deleteDb = async (data: StudyData) => {//async/awaitで処理実施、dataは学習記録のStudyDataの型
setLoading(true);//ローディング中にセット
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);//対象ドキュメントとして'users_learnings'のdata.idを指定
await deleteDoc(userDocumentRef);//FirebaseSDKのdeleteDocにより、data.idに該当するデータを削除
toast({//処理が正常終了すれば、Chakra UIのToast機能で、正常終了メッセージ表示
title: 'データを削除しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error during delete:", error);
toast({//エラー発生時は、Chakra UIのToast機能で、エラーメッセージ表示
title: 'デー削除に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {//最後にローディング状態を解除
setLoading(false);
}
}
これまでのfetchDb, updateDb, entryDb と同じ流れです。
async/awaitで処理実施しています。dataの型は、StudyDataです。
ローディングをセットして、try文の箇所は、FirebaseSDKのメソッドdeleteDocでdata.idに該当するデータの削除処理を行っています。
正常終了すれば、Chakra UIのToast機能で成功メッセージ表示、エラー発生時は同様にエラーメッセージを表示させています。
最後にローディングを解除して終了です。
7.2 Home.tsxの変更
続いて、これまでと同様、Homeコンポーネントに削除用のモーダルを追加していきます。
以下は、変更後のHome.tsxコード全文です。
// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import {
Box, Button, Card, CardBody, Flex, FormControl, FormLabel, Heading, Input, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Stack,
Table, TableContainer, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast
} from "@chakra-ui/react";
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";
import { StudyData } from "../types/studyData";
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb, deleteDb } = useFirebase()//追加:deleteDb
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()
const modalDelete = useDisclosure()//追加
const initialRef = useRef(null)
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [deleteLearning, setDeleteLearning] = useState<StudyData>({ id: '', title: '', time: 0 })//追加
const toast = useToast()
useEffect(() => {
if (user) {
fetchDb(email)
console.log('Firestore', email)
}
}, [user]);
const handleUpdate = async () => {
await updateDb(editLearning);
fetchDb(email)
if (!loading) {
setTimeout(() => {
modalEdit.onClose();
}, 500);
}
}
const handleEntry = async () => {
if (learnings.some((l) => l.title === entryLearning.title)) {
const existingLearning = learnings.find((l) => l.title === entryLearning.title);
if (existingLearning) {
existingLearning.time += entryLearning.time;
await updateDb(existingLearning);
}
} else {
await entryDb(entryLearning);
}
fetchDb(email)
setEntryLearning({ id: "", title: "", time: 0 })
if (!loading) {
setTimeout(() => {
modalEntry.onClose()
}, 500);
}
};
const handleDelete = async () => {//追加:クリック時、入力データの新規登録、もしくは既存データの更新を実施
await deleteDb(deleteLearning);
fetchDb(email)
if (!loading) {
setTimeout(() => {
modalDelete.onClose();
}, 500);
}
}
return (
<>
<Flex alignItems='center' justify='center' p={5}>
<Card size={{ base: 'sm', md: 'lg' }}>
<Box textAlign='center' mb={2} mt={10}>
ようこそ!{email} さん
</Box>
<Heading size='md' textAlign='center'>Learning Records</Heading>
<CardBody>
<Box textAlign='center'>
学習記録
{loading && <Box p={10}><Spinner /></Box>}
<TableContainer>
<Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
<Thead>
<Tr>
<Th>学習内容</Th>
<Th>時間(分)</Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{learnings.map((learning, index) => (
<Tr key={index}>
<Td>{learning.title}</Td>
<Td>{learning.time}</Td>
<Td>
{/* 編集用モーダル */}
<Button variant='ghost' onClick={() => {
setEditLearning(learning)
modalEdit.onOpen()
}}><FiEdit color='black' /></Button>
<Modal
initialFocusRef={initialRef}
isOpen={modalEdit.isOpen}
onClose={modalEdit.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>記録編集</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
placeholder='学習内容'
name='title'
value={editLearning.title}
onChange={(e) => {
setEditLearning({ ...editLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
placeholder='学習時間'
name='time'
value={editLearning.time}
onChange={(e) => {
setEditLearning({ ...editLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{editLearning.title}</div>
<div>入力されている学習時間:{editLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (editLearning.title !== "" && editLearning.time > 0) {
handleUpdate()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
データを更新
</Button>
<Button onClick={() => {
modalEdit.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Td>
<Td>
{/*下記削除用モーダル箇所に置換え
<Button variant='ghost'
onClick={() => { }}><MdDelete color='black' /></Button>
*/}
{/* 追加:削除用モーダルここから */}
<Button variant='ghost'
onClick={() => {
setDeleteLearning(learning)
modalDelete.onOpen()
}}><MdDelete color='black' /></Button>
<Modal
isOpen={modalDelete.isOpen}
onClose={modalDelete.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>データ削除</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<Box>
以下のデータを削除します。<br />
学習内容:{deleteLearning.title}、学習時間:{deleteLearning.time}
</Box>
</ModalBody>
<ModalFooter>
<Button onClick={modalDelete.onClose} mr={3}>Cancel</Button>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
ref={initialRef}
colorScheme='red'
onClick={handleDelete}
>
削除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 削除用モーダルここまで */}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
<Box p={5}>
<div>合計学習時間:{calculateTotalTime()}分</div>
</Box>
{/* 新規登録モーダル */}
<Box p={25}>
<Stack spacing={3}>
<Button
colorScheme='green'
variant='outline'
onClick={modalEntry.onOpen}>
新規データ登録
</Button>
</Stack>
<Modal
initialFocusRef={initialRef}
isOpen={modalEntry.isOpen}
onClose={modalEntry.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>新規データ登録</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
name='newEntryTitle'
placeholder='学習内容'
value={entryLearning.title}
onChange={(e) => {
setEntryLearning({ ...entryLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
name='newEntryTime'
placeholder='学習時間'
value={entryLearning.time}
onChange={(e) => {
setEntryLearning({ ...entryLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{entryLearning.title}</div>
<div>入力されている学習時間:{entryLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (entryLearning.title !== "" && entryLearning.time > 0) {
handleEntry()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
登録
</Button>
<Button onClick={() => {
modalEntry.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => { }}
>ログアウト</Button>
</Stack>
</Box>
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => { }}
>パスワード更新</Button>
</Stack>
</Box>
</CardBody>
</Card>
</Flex>
</>
)
}
export default Home;
これまでと同じ感じです。インポートの箇所は変更ありません。
// /src/components/Home.tsx
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb, deleteDb } = useFirebase()//追加:deleteDb
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()
const modalDelete = useDisclosure()//追加
const initialRef = useRef(null)
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [deleteLearning, setDeleteLearning] = useState<StudyData>({ id: '', title: '', time: 0 })//追加
const toast = useToast()
フックの箇所は、useFirebaseのオブジェクトに、deleteDbを追加しています。また、モーダル開閉制御用のuseDisclosure()について、新たに新規登録モーダル用のmodalDeleteを追加しています。及び、削除時の学習記録用ローカルステート、deleteLearningを追加しています。
続いて、値の処理の関数関係です。新たにhandleDeleteを追加しています。
// /src/components/Home.tsx
const handleDelete = async () => {//追加:クリック時、入力データの新規登録、もしくは既存データの更新を実施
await deleteDb(deleteLearning);//DBデータ削除を実施
fetchDb(email)//処理完了後、反映されたDBデータを改めて取得
if (!loading) {//ローディング解除されたら、0.5秒後、モーダルをクローズ
setTimeout(() => {
modalDelete.onClose();
}, 500);
}
}
コード中にコメント記載しています。これまでの更新・登録処理と同じ流れです。モーダルの「削除」ボタンクリックで実行します。
DBデータ削除処理を行い、反映されたDBデータを再取得し、モーダルクローズの流れです。
次にJSXの箇所です。
// /src/components/Home.tsx
{/*下記削除用モーダル箇所に置換え
<Button variant='ghost'
onClick={() => { }}><MdDelete color='black' /></Button>
*/}
{/* 追加:削除用モーダルここから */ }
<Button variant='ghost'
onClick={() => {//モーダルオープンのボタン部分
setDeleteLearning(learning)//deleteLearningにmapで展開したlearningの内容を格納
modalDelete.onOpen()//モーダルをオープンする。削除用のモーダルのため、modalDelete.onOpen()の形で指定。
}}><MdDelete color='black' /></Button>
<Modal
//ここからモーダルの内容
isOpen={modalDelete.isOpen}//モーダルのオープン状態を監視、削除用のモーダルのため、modalDelete.を付与
onClose={modalDelete.onClose}//モーダルクローズの定義、削除用のモーダルのため、modalDelete.を付与
>
<ModalOverlay />
<ModalContent>
<ModalHeader>データ削除</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<Box>
以下のデータを削除します。<br />
学習内容:{deleteLearning.title}、学習時間:{deleteLearning.time//ロカールステートを利用
}
</Box>
</ModalBody>
<ModalFooter>
<Button
//cancelクリックの場合、そのままモーダルクローズ
onClick={modalDelete.onClose} mr={3}//削除用のモーダルのため、modalDelete.を付与
>Cancel</Button>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
ref={initialRef}
colorScheme='red'
onClick={handleDelete}//DBデータ削除処理実行
>
削除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 削除用モーダルここまで */ }
追加した削除用モーダルの箇所のみ掲載しています。
冒頭にある、今まで削除アイコンのボタン表示のみだった<Button>…</Button>の箇所を、その下に記載している、「追加:削除用モーダルここから」から一番下側の「削除用モーダルここまで」に置換え・追加します。内容の説明については、コメント記載通りです。
ここまでで以下画面のような動きとなります。
8. ログアウト機能
FirestoreDBの取得・登録・更新・削除の機能が実装出来ましたので、今度は、認証系の機能を追加していきます。まず、ログアウト機能の実装です。
8.1 useFirebaseの変更
これまでと同様、まずはカスタムフック、useFirebaseへのログアウト機能の追加です。
以下、変更後のuseFirebase.tsの全文です。
// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { signInWithEmailAndPassword, User } from "firebase/auth";
import { addDoc, collection, deleteDoc, doc, getDocs, query, updateDoc, where } from "firebase/firestore";
import { auth, db } from "../utils/firebase";
import { StudyData } from "../types/studyData";
type UseFirebase = () => {
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
email: string;
setEmail: React.Dispatch<React.SetStateAction<string>>;
password: string;
setPassword: React.Dispatch<React.SetStateAction<string>>;
handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
learnings: StudyData[];
setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;
fetchDb: (data: string) => Promise<void>
calculateTotalTime: () => number
updateDb: (data: StudyData) => Promise<void>
entryDb: (data: StudyData) => Promise<void>;
deleteDb: (data: StudyData) => Promise<void>
handleLogout: () => Promise<void>//追加
}
export const useFirebase: UseFirebase = () => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [user, setUser] = useState<User | null>(null);
const [learnings, setLearnings] = useState<StudyData[]>([]);
const navigate = useNavigate()
const toast = useToast()
////Authentication
//ログイン処理
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
const userLogin = await signInWithEmailAndPassword(auth, email, password);
console.log("User Logined:", userLogin);
toast({
title: 'ログインしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/')
}
catch (error) {
console.error("Error during sign up:", error);
toast({
title: 'ログインに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
finally {
setLoading(false);
}
};
//ユーザがセッション中か否かの判定処理
useEffect(() => {
const unsubscribed = auth.onAuthStateChanged((user) => {
setUser(user);
if (user) {
setEmail(user.email as string)
} else {
navigate("/login");//userがセッション中でなければ/loginに移動
}
});
return () => {
unsubscribed();
};
}, [user]);
//追加:ログアウト処理
const handleLogout = async () => {
setLoading(true);
try {
const usertLogout = await auth.signOut();
console.log("User Logout:", usertLogout);
toast({
title: 'ログアウトしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/login');
}
catch (error) {
console.error("Error during logout:", error);
toast({
title: 'ログアウトに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
////Firestore
//Firestoreデータ取得
const fetchDb = async (data: string) => {
setLoading(true);
try {
const usersCollectionRef = collection(db, 'users_learnings');
const q = query(usersCollectionRef, where('email', '==', data)); // ログインユーザーのemailでフィルタ
const querySnapshot = await getDocs(q);
const fetchedLearnings = querySnapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
} as StudyData));// Firebaseから取得したデータを`StudyData`型に明示的に変換
console.log("取得したデータ:", fetchedLearnings)
setLearnings(fetchedLearnings); // 正しい型でセット
}
catch (error) {
console.error("Error getting documents: ", error);
}
finally {
setLoading(false);
}
}
//Firestoreデータ更新
const updateDb = async (data: StudyData) => {
setLoading(true)
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await updateDoc(userDocumentRef, {
title: data.title,
time: data.time
});
toast({
title: 'データ更新が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.log(error)
toast({
title: 'データ更新に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
} finally {
setLoading(false)
}
}
//Firestoreデータ新規登録
const entryDb = async (data: StudyData) => {
setLoading(true)
try {
const usersCollectionRef = collection(db, 'users_learnings');
const documentRef = await addDoc(usersCollectionRef, {
title: data.title,
time: data.time,
email: email
});
console.log(documentRef, data);
toast({
title: 'データ登録が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error adding document: ", error);
toast({
title: 'データ登録に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false)
}
}
//Firestoreデータ削除
const deleteDb = async (data: StudyData) => {
setLoading(true);
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await deleteDoc(userDocumentRef);
toast({
title: 'データを削除しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error during delete:", error);
toast({
title: 'デー削除に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
////Others
//学習時間合計
const calculateTotalTime = () => {
return learnings.reduce((total, learning) => total + learning.time, 0);
};
return {
loading,
setLoading,
email,
setEmail,
password,
setPassword,
handleLogin,
user,
setUser,
learnings,
setLearnings,
fetchDb,
calculateTotalTime,
updateDb,
entryDb,
deleteDb,
handleLogout//追加
}
}
コメントで追加と記載している箇所が追加箇所になります。
// /src/hooks/useFirebse.ts
handleLogout: () => Promise<void>//追加
.
.
//追加:ログアウト処理
const handleLogout = async () => {
.
.
return {
.
.
handleLogout//追加
}
インポートの箇所は、追加はありません。型定義の箇所で、新たに追加している、ログアウト処理、handleLogoutの型定義を追加しています。handleLogout: () => Promiseです。
間に追加した関数handleLogoutの内容があり、最後のreturn文の箇所にhandleLogoutをオブジェクトとして追加しています。
追加した関数handleLogoutについて具体的に説明します。
// /src/hooks/useFirebse.ts
//追加:ログアウト処理
const handleLogout = async () => {//async/awaitによる非同期通信
setLoading(true);//ローディングをローディング状態に
try {
const usertLogout = await auth.signOut();//Firebase SDKによるログアウト処理(signOut())、authは、firebaseクライアントで定義した引数
console.log("User Logout:", usertLogout);
toast({//処理が正常終了すれば、Chakra UIのToastを利用し、成功メッセージを表示
title: 'ログアウトしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/login');//ログアウト後、ログイン画面に遷移
}
catch (error) {
console.error("Error during logout:", error);
toast({//エラー時は、Chakra UIのToastを利用し、エラーメッセージ表示
title: 'ログアウトに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);//最後に、ローディング状態を解除
}
}
これまでの関数と同じ流れ、コード中に記載したコメント通りです。
ログアウトは、FirebaseSDKのメソッド、signOut()を利用します。
ログアウトが正常処理されれば、Chakra UIのToast機能で成功メッセージ表示、useNavigateにてログイン画面に遷移します。エラー発生時は同様にエラーメッセージを表示させています。
最後にローディングを解除して終了です。
8.2 Home.tsxの変更
続いて、Home.tsxの変更です。ログアウトはHome画面に配置してますので、Home.tsxにuseFirebaseで定義したログアウト処理を組み込みます。
まずは、今まで通り、変更したHome.tsxのコード全文を掲載します。
// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import {
AlertDialog, AlertDialogBody, AlertDialogCloseButton, AlertDialogContent,
AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay,
Box, Button, Card, CardBody, Flex, FormControl, FormLabel, Heading, Input, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Stack,
Table, TableContainer, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast
} from "@chakra-ui/react";//追加:Alert Dialog関連
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";
import { StudyData } from "../types/studyData";
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb, deleteDb, handleLogout } = useFirebase()//追加:handleLogout
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()
const modalDelete = useDisclosure()
const alertLogout = useDisclosure()//追加
const initialRef = useRef(null)
const cancelRef = useRef(null)//追加
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [deleteLearning, setDeleteLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const toast = useToast()
useEffect(() => {
if (user) {
fetchDb(email)
console.log('Firestore', email)
}
}, [user]);
const handleUpdate = async () => {
await updateDb(editLearning);
fetchDb(email)
if (!loading) {
setTimeout(() => {
modalEdit.onClose();
}, 500);
}
}
const handleEntry = async () => {
if (learnings.some((l) => l.title === entryLearning.title)) {
const existingLearning = learnings.find((l) => l.title === entryLearning.title);
if (existingLearning) {
existingLearning.time += entryLearning.time;
await updateDb(existingLearning);
}
} else {
await entryDb(entryLearning);
}
fetchDb(email)
setEntryLearning({ id: "", title: "", time: 0 })
if (!loading) {
setTimeout(() => {
modalEntry.onClose()
}, 500);
}
};
const handleDelete = async () => {
await deleteDb(deleteLearning);
fetchDb(email)
if (!loading) {
setTimeout(() => {
modalDelete.onClose();
}, 500);
}
}
return (
<>
<Flex alignItems='center' justify='center' p={5}>
<Card size={{ base: 'sm', md: 'lg' }}>
<Box textAlign='center' mb={2} mt={10}>
ようこそ!{email} さん
</Box>
<Heading size='md' textAlign='center'>Learning Records</Heading>
<CardBody>
<Box textAlign='center'>
学習記録
{loading && <Box p={10}><Spinner /></Box>}
<TableContainer>
<Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
<Thead>
<Tr>
<Th>学習内容</Th>
<Th>時間(分)</Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{learnings.map((learning, index) => (
<Tr key={index}>
<Td>{learning.title}</Td>
<Td>{learning.time}</Td>
<Td>
{/* 編集用モーダル */}
<Button variant='ghost' onClick={() => {
setEditLearning(learning)
modalEdit.onOpen()
}}><FiEdit color='black' /></Button>
<Modal
initialFocusRef={initialRef}
isOpen={modalEdit.isOpen}
onClose={modalEdit.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>記録編集</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
placeholder='学習内容'
name='title'
value={editLearning.title}
onChange={(e) => {
setEditLearning({ ...editLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
placeholder='学習時間'
name='time'
value={editLearning.time}
onChange={(e) => {
setEditLearning({ ...editLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{editLearning.title}</div>
<div>入力されている学習時間:{editLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (editLearning.title !== "" && editLearning.time > 0) {
handleUpdate()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
データを更新
</Button>
<Button onClick={() => {
modalEdit.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Td>
<Td>
{/* 削除用モーダル */}
<Button variant='ghost'
onClick={() => {
setDeleteLearning(learning)
modalDelete.onOpen()
}}><MdDelete color='black' /></Button>
<Modal
isOpen={modalDelete.isOpen}
onClose={modalDelete.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>データ削除</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<Box>
以下のデータを削除します。<br />
学習内容:{deleteLearning.title}、学習時間:{deleteLearning.time}
</Box>
</ModalBody>
<ModalFooter>
<Button onClick={modalDelete.onClose} mr={3}>Cancel</Button>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
ref={initialRef}
colorScheme='red'
onClick={handleDelete}
>
削除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
<Box p={5}>
<div>合計学習時間:{calculateTotalTime()}分</div>
</Box>
{/* 新規登録モーダル */}
<Box p={25}>
<Stack spacing={3}>
<Button
colorScheme='green'
variant='outline'
onClick={modalEntry.onOpen}>
新規データ登録
</Button>
</Stack>
<Modal
initialFocusRef={initialRef}
isOpen={modalEntry.isOpen}
onClose={modalEntry.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>新規データ登録</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
name='newEntryTitle'
placeholder='学習内容'
value={entryLearning.title}
onChange={(e) => {
setEntryLearning({ ...entryLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
name='newEntryTime'
placeholder='学習時間'
value={entryLearning.time}
onChange={(e) => {
setEntryLearning({ ...entryLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{entryLearning.title}</div>
<div>入力されている学習時間:{entryLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (entryLearning.title !== "" && entryLearning.time > 0) {
handleEntry()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
登録
</Button>
<Button onClick={() => {
modalEntry.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
{/*下記、ログアウトアラート追加の箇所と置換え
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => { }}
>ログアウト</Button>
</Stack>
</Box>
*/}
{/*追加:ログアウトアラートここから*/ }
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={alertLogout.onOpen}>ログアウト</Button>
<AlertDialog
motionPreset='slideInBottom'
leastDestructiveRef={cancelRef}
onClose={alertLogout.onClose}
isOpen={alertLogout.isOpen}
isCentered
>
<AlertDialogOverlay />
<AlertDialogContent>
<AlertDialogHeader>ログアウト</AlertDialogHeader>
<AlertDialogCloseButton />
<AlertDialogBody>
ログアウトしますか?
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={alertLogout.onClose}>
Cancel
</Button>
<Button isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='red' ml={3}
onClick={handleLogout}>
ログアウト</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Stack>
</Box>
{/*ログアウトアラートここまで*/}
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => { }}
>パスワード更新</Button>
</Stack>
</Box>
</CardBody>
</Card>
</Flex>
</>
)
}
export default Home;
冒頭の箇所を見てみます。
// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import {
AlertDialog, AlertDialogBody, AlertDialogCloseButton, AlertDialogContent,
AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay,
Box, Button, Card, CardBody, Flex, FormControl, FormLabel, Heading, Input, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Stack,
Table, TableContainer, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast
} from "@chakra-ui/react";//追加:Alert Dialog関連
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";
import { StudyData } from "../types/studyData";
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb, deleteDb, handleLogout } = useFirebase()//追加:handleLogout
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()
const modalDelete = useDisclosure()
const alertLogout = useDisclosure()//追加
const initialRef = useRef(null)
const cancelRef = useRef(null)//追加
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [deleteLearning, setDeleteLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const toast = useToast()
インポート部分は、Chakra UIのAlert Dialog関連コンポーネントを追加しています。Alert DialogはModalと同じような動きをするものです。Modalとあまり変わりはないですが、ちょっと試してみました。
フックの定義については、useFirebaseの定義にログアウト処理用のhandleLogoutを追加しています。
それと、Chakra UIのAlert Dialog利用のため、
const alertLogout = useDisclosure()、const cancelRef = useRef(null) を追加しています。useDisclosureはModalと同様に定義、つまり他のModalと区別がつくように、const alertLogoutと言う形で定義しています(つまりオープン・クローズの制御はModalと同じ仕組みと思われます)。
続いてJSXの箇所です。
// /src/components/Home.tsx
{/*下記、ログアウトアラート追加の箇所と置換え
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => { }}
>ログアウト</Button>
</Stack>
</Box>
*/}
{/*追加:ログアウトアラートここから*/ }
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
//アラートダイヤログのボタン部分
width='100%'
variant='outline'
onClick={alertLogout.onOpen}////アラートをオープンする。ログアウト用アラートのため、alertLogout.onOpen()の形で指定。
> ログアウト</Button>
<AlertDialog
//ここからアラートダイヤログの内容
motionPreset='slideInBottom'//表示の際のフェード指定
leastDestructiveRef={cancelRef}
onClose={alertLogout.onClose}//アラートのクローズ定義、ログアウト用のため、alertLogout.を付与
isOpen={alertLogout.isOpen}//アラートのオープン状態を監視、ログアウト用のため、alertLogout.を付与
isCentered
>
<AlertDialogOverlay />
<AlertDialogContent>
<AlertDialogHeader>ログアウト</AlertDialogHeader>
<AlertDialogCloseButton />
<AlertDialogBody>
ログアウトしますか?
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={alertLogout.onClose}//ログアウト用のため、alertLogout.を付与
>Cancel
</Button>
<Button isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='red' ml={3}
onClick={handleLogout}//クリック時、useFirebaseのhandleLogout実行
>ログアウト</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Stack>
</Box>
{/*ログアウトアラートここまで*/ }
追加したログアウトアラートの箇所のみ掲載しています。
冒頭にある、今まで「ログアウト」ボタン表示のみだった<Button>…</Button>の箇所を、その下に記載している、「追加:ログアウトアラートここから」から一番下側の「ログアウトアラートここまで」に置換え・追加します。内容の説明については、コメント記載通りです。
これでログアウトを行うと以下画面のような動きとなります。
9. サインアップ機能
次に新たにユーザー登録を行うサインアップ機能を実装していきます。
例によってuseFirebaseの変更からです。
9.1 useFirebaseの変更
それでは、カスタムフック、useFirebaseにFirebase Authenticationのサインアップ機能を追加します。
以下、変更後のuseFirebase.ts、コード全文です。
// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { createUserWithEmailAndPassword, signInWithEmailAndPassword, User } from "firebase/auth";//追加:createUserWithEmailAndPassword
import { addDoc, collection, deleteDoc, doc, getDocs, query, updateDoc, where } from "firebase/firestore";
import { auth, db } from "../utils/firebase";
import { StudyData } from "../types/studyData";
type UseFirebase = () => {
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
email: string;
setEmail: React.Dispatch<React.SetStateAction<string>>;
password: string;
setPassword: React.Dispatch<React.SetStateAction<string>>;
handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
learnings: StudyData[];
setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;
fetchDb: (data: string) => Promise<void>
calculateTotalTime: () => number
updateDb: (data: StudyData) => Promise<void>
entryDb: (data: StudyData) => Promise<void>;
deleteDb: (data: StudyData) => Promise<void>
handleLogout: () => Promise<void>
passwordConf: string//追加
setPasswordConf: React.Dispatch<React.SetStateAction<string>>//追加
handleSignup: (e: React.FormEvent<HTMLFormElement>) => Promise<void>//追加
}
export const useFirebase: UseFirebase = () => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConf, setPasswordConf] = useState('');//追加
const [user, setUser] = useState<User | null>(null);
const [learnings, setLearnings] = useState<StudyData[]>([]);
const navigate = useNavigate()
const toast = useToast()
////Authentication
//ログイン処理
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
const userLogin = await signInWithEmailAndPassword(auth, email, password);
console.log("User Logined:", userLogin);
toast({
title: 'ログインしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/')
}
catch (error) {
console.error("Error during sign up:", error);
toast({
title: 'ログインに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
finally {
setLoading(false);
}
};
//ユーザがセッション中か否かの判定処理
useEffect(() => {
const unsubscribed = auth.onAuthStateChanged((user) => {
setUser(user);
if (user) {
setEmail(user.email as string)
} else {
navigate("/login");//userがセッション中でなければ/loginに移動
}
});
return () => {
unsubscribed();
};
}, [user]);
//ログアウト処理
const handleLogout = async () => {
setLoading(true);
try {
const usertLogout = await auth.signOut();
console.log("User Logout:", usertLogout);
toast({
title: 'ログアウトしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/login');
}
catch (error) {
console.error("Error during logout:", error);
toast({
title: 'ログアウトに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
//追加:サインアップ処理
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== passwordConf) {
toast({
title: 'パスワードが一致しません',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
} else if (password.length < 6) {
toast({
title: 'パスワードは6文字以上にしてください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
}
try {
setLoading(true);
// Firebaseにユーザーを作成する処理
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
console.log("User created:", userCredential);
toast({
title: 'ユーザー登録が完了しました。',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/');
}
catch (error) {
console.error("Error during sign up:", error);
toast({
title: 'サインアップに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
////Firestore
//Firestoreデータ取得
const fetchDb = async (data: string) => {
setLoading(true);
try {
const usersCollectionRef = collection(db, 'users_learnings');
const q = query(usersCollectionRef, where('email', '==', data)); // ログインユーザーのemailでフィルタ
const querySnapshot = await getDocs(q);
const fetchedLearnings = querySnapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
} as StudyData));// Firebaseから取得したデータを`StudyData`型に明示的に変換
console.log("取得したデータ:", fetchedLearnings)
setLearnings(fetchedLearnings); // 正しい型でセット
}
catch (error) {
console.error("Error getting documents: ", error);
}
finally {
setLoading(false);
}
}
//Firestoreデータ更新
const updateDb = async (data: StudyData) => {
setLoading(true)
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await updateDoc(userDocumentRef, {
title: data.title,
time: data.time
});
toast({
title: 'データ更新が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.log(error)
toast({
title: 'データ更新に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
} finally {
setLoading(false)
}
}
//Firestoreデータ新規登録
const entryDb = async (data: StudyData) => {
setLoading(true)
try {
const usersCollectionRef = collection(db, 'users_learnings');
const documentRef = await addDoc(usersCollectionRef, {
title: data.title,
time: data.time,
email: email
});
console.log(documentRef, data);
toast({
title: 'データ登録が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error adding document: ", error);
toast({
title: 'データ登録に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false)
}
}
//Firestoreデータ削除
const deleteDb = async (data: StudyData) => {
setLoading(true);
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await deleteDoc(userDocumentRef);
toast({
title: 'データを削除しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error during delete:", error);
toast({
title: 'デー削除に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
////Others
//学習時間合計
const calculateTotalTime = () => {
return learnings.reduce((total, learning) => total + learning.time, 0);
};
return {
loading,
setLoading,
email,
setEmail,
password,
setPassword,
handleLogin,
user,
setUser,
learnings,
setLearnings,
fetchDb,
calculateTotalTime,
updateDb,
entryDb,
deleteDb,
handleLogout,
passwordConf,//追加
setPasswordConf,//追加
handleSignup//追加
}
}
冒頭、インポートは、”firebase/auth”から「createUserWithEmailAndPassword」を追加しています。
// /src/hooks/useFirebse.ts
import { createUserWithEmailAndPassword, signInWithEmailAndPassword, User } from "firebase/auth";//追加:createUserWithEmailAndPassword
名前の通り、emailとパスワードでユーザーを作成するメソッドです。
型定義については、新たなステートpasswordConfとそのsetStateと、サインアップ用の関数、handleSignupの型定義を追加しています。
// /src/hooks/useFirebse.ts
passwordConf: string//追加
setPasswordConf: React.Dispatch<React.SetStateAction<string>>//追加
handleSignup: (e: React.FormEvent<HTMLFormElement>) => Promise<void>//追加
フックの定義の箇所は新しいステートpasswordConfの定義を追加しています。
passwordConfは、入力されたpassword確認の為に再入力を行ったものを格納するステートで、サインアップ処理の際に、入力されたpasswordとpasswordConfが一致するかチェックします。
// /src/hooks/useFirebse.ts
const [passwordConf, setPasswordConf] = useState('');//追加
続いて、今回追加した、handleSignupについて解説します。
// /src/hooks/useFirebse.ts
//追加:サインアップ処理
const handleSignup = async (e: React.FormEvent) => {//async/awaitによる非同期通信、React.FormEventによるイベントの型
e.preventDefault();// submitイベントの本来の動作を抑止
if (password !== passwordConf) {//入力したパスワードと再入力したパスワードの一致確認
toast({//一致しなければ、エラーメッセージ表示し、処理終了
title: 'パスワードが一致しません',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
} else if (password.length < 6) {//パスワード要件、6文字以上に合致するかチェック
toast({//合致しなければ、エラーメッセージ表示し、処理終了
title: 'パスワードは6文字以上にしてください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
}
try {//上記パスワードチェック通過すれば実施
setLoading(true);//ローディング状態をセット
// Firebaseにユーザーを作成する処理
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
//FirebaseSDK、createUserWithEmailAndPasswordによる、サインアップ処理実施
console.log("User created:", userCredential);//結果をコンソール出力
toast({//正常終了すれば成功メッセージ表示
title: 'ユーザー登録が完了しました。',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/');//ルート('/')に移動
}
catch (error) {//エラーの場合は
console.error("Error during sign up:", error);//エラー出力
toast({//エラーメッセージ表示
title: 'サインアップに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);//最後にローディング解除
}
}
内容については、コード中のコメント記載通りです。
パスワード要件充足のチェックを入れてますが、Firebase Authenticationのパスワード要件は、FirebaseコンソールのAuthentication、設定タブ、パスワードポリシーで設定できます。デフォルトは6文字以上です。
パスワードチェック通過した後、FirebaseSDK、createUserWithEmailAndPasswordによる、サインアップ処理を実施しています。
サインアップ処理が成功すれば、成功メッセージを表示し、ルート(’/’)であるHome画面に遷移させています。エラー発生の場合は、エラーメッセージ表示です。最後にローディング状態解除し終了です。
そして、最後のreturn文で、追加したstate、関数をオブジェクトに追加しています。
// /src/hooks/useFirebse.ts
return {
.
.
passwordConf,//追加
setPasswordConf,//追加
handleSignup//追加
}
9.2 Register.tsxの作成
続いて、サインアップ用のコンポーネントを作成します。ファイルはRegister.tsxとします。
componentsフォルダに新たにRegister.tsxを作成します。
Register.tsxのコードは以下となります(全文です)。
// /src/components/Register.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Text } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiLockPasswordFill } from "react-icons/ri";
import { useFirebase } from "../hooks/useFirebase";
const Register = () => {
const { loading, email, setEmail, password, setPassword, passwordConf, setPasswordConf, handleSignup } = useFirebase()
return (
<>
<Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>
<Card size={{ base: 'sm', md: 'lg' }} p={4}>
<Heading size='md' textAlign='center'>ユーザ登録</Heading>
<CardBody>
<form onSubmit={handleSignup}>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<FaUserCheck color='gray' />
</InputLeftElement>
<Input
autoFocus
type='email'
placeholder='メールアドレスを入力'
name='email'
value={email}
required
mb={2}
onChange={e => setEmail(e.target.value)}
/>
</InputGroup>
<Text fontSize='12px' color='gray'>パスワードは6文字以上</Text>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='パスワードを入力'
name='password'
value={password}
required
mb={2}
onChange={e => setPassword(e.target.value)}
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='パスワードを入力(確認)'
name='passwordConf'
value={passwordConf}
required
mb={2}
onChange={e => setPasswordConf(e.target.value)}
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
type='submit'
colorScheme='green'
width='100%'
mb={2}>登録する</Button>
<Button
colorScheme='gray'
onClick={() => window.history.back()}
width='100%'>戻る</Button>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
};
export default Register;
解説していきます。構造としてはLoginコンポーネントと同じような作りです。
まず、冒頭のインポート、フック定義の箇所です。
// /src/components/Register.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Text } from "@chakra-ui/react";
//Chakura UIインポート
import { FaUserCheck } from "react-icons/fa";//ユーザーアイコンインポート
import { RiLockPasswordFill } from "react-icons/ri";//パスワードアイコンインポート
import { useFirebase } from "../hooks/useFirebase";//カスタムフック、useFirebaseインポート
const Register = () => {
//useFirebaseの定義
const { loading, email, setEmail, password, setPassword, passwordConf, setPasswordConf, handleSignup } = useFirebase()
Chakra UIで必要となるコンポーネントのインポートを行っています。その他、ユーザーアイコン、パスワードのアイコンをreact-iconsからインポートしています。そして、カスタムフックのuseFirebaseのインポートです。
フックの定義の箇所は、カスタムフック、useFirebaseの定義をしています。
定義した、loading, email, setEmail, password, setPassword, passwordConf, setPasswordConf, handleSignupを利用していきます。
次にJSXの箇所です。
// /src/components/Register.tsx
return (
<>
<Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>{/*Flex適用*/}
<Card size={{ base: 'sm', md: 'lg' }} p={4}>{/*Chakra UIのCard適用*/}
<Heading size='md' textAlign='center'>ユーザ登録</Heading>
<CardBody>
<form onSubmit={handleSignup}//formサブミットで、サインアップ処理するuseFirebaseのhandleSignupを実行
>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<FaUserCheck color='gray' />
</InputLeftElement>
<Input
autoFocus//自動でフォーカスをあてる
type='email'
placeholder='メールアドレスを入力'
name='email'
value={email}
required
mb={2}
onChange={e => setEmail(e.target.value)}//onChangeイベントで入力値をemailにセット
/>
</InputGroup>
<Text fontSize='12px' color='gray'>パスワードは6文字以上</Text>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='パスワードを入力'
name='password'
value={password}
required
mb={2}
onChange={e => setPassword(e.target.value)}//onChangeイベントで入力値をpasswordにセット
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='パスワードを入力(確認)'
name='passwordConf'
value={passwordConf}
required
mb={2}
onChange={e => setPasswordConf(e.target.value)}//onChangeイベントで入力値をpasswordConfにセット
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
type='submit'
colorScheme='green'
width='100%'
mb={2}>登録する</Button>
<Button
colorScheme='gray'
onClick={() => window.history.back()}//JavaScriptのwindow.history.backで前のURLに先鋭
width='100%'>戻る</Button>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
コード中に色々コメントで解説しています。
<Flex><Card>等、Chakra UIのコンポーネントを配置の上、<Form>設置、<Input>にてメールアドレス、パスワード入力エリアを設けています。
それぞれの入力エリアは、値が入力されれば、onChangeイベントで、set関数によりそれぞれのステートへの格納をしています。
formがsubmitされるとuseFirebaseのhandleSignupを実行し、ユーザーのサインアップを行います。
「戻る」ボタンについては、JavaScriptのwindow.history.backで前のページに戻る処理をしています。
9.3 関連コンポーネントの変更
続いて、Registerコンポーネントを機能させるために、関連コンポーネントを修正していきます。
9.3.1 App.tsxのルーティング設定
まずは、App.tsxに、作成したRegisterコンポーネントのルーティングを設定します。
パスは/register で設定します。以下、App.tsxのコード全文です。
// /src/App.tsx
import { Route, Routes } from "react-router-dom"
import Home from "./components/Home"
import Login from "./components/Login"
import Register from "./components/Register"//Register追加
function App() {
return (
<>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />}//Registerのルート追加
/>
</Routes>
</>
)
}
export default App
新たに、Registerのインポートを実施しています。
JSXの箇所に、Routeを追加し、Registerを/registerとしてセットしています。
9.3.2 Login.tsxの変更
次に、現在、処理を空の状態にしているLoginコンポーネントの「新規登録」ボタンの変更を行います。クリックすると、Registerコンポーネントに移動するようにします。
以下、変更後のLogin.tsxコード全文です。
// /src/components/Login.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Stack } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiLockPasswordFill } from "react-icons/ri";
import { useFirebase } from "../hooks/useFirebase";
import { useNavigate } from "react-router-dom";//追加
const Login = () => {
const { loading, email, setEmail, password, setPassword, handleLogin } = useFirebase()
const navigate = useNavigate()//追加
return (
<>
<Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>
<Card size={{ base: 'sm', md: 'lg' }} p={4}>
<Heading size='md' textAlign='center'>ログイン</Heading>
<CardBody>
<form onSubmit={handleLogin}
>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<FaUserCheck color='gray' />
</InputLeftElement>
<Input
autoFocus
type='email'
placeholder='メールアドレスを入力'
name='email'
value={email}
mb={2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='パスワードを入力'
name='password'
value={password}
required
mb={2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
type='submit' colorScheme='green' width='100%' mb={2}>ログイン</Button>
<Button
colorScheme='green'
width='100%'
variant='outline'
onClick={() => navigate('/register')}//変更:クリックしたら、/registerに遷移
>新規登録</Button>
</Box>
<Box mt={4} mb={2} textAlign='center'>
<Stack spacing={3}>
<Button
colorScheme='green'
width='100%'
variant='ghost'
onClick={() => { }}>パスワードをお忘れですか?</Button>
</Stack>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
}
export default Login;
まず、冒頭インポートとフック定義の箇所です。
// /src/components/Login.tsx
.
.
import { useNavigate } from "react-router-dom";//追加
const Login = () => {
const { loading, email, setEmail, password, setPassword, handleLogin } = useFirebase()
const navigate = useNavigate()//追加
ページ遷移を実装する為、React RouterのuseNavigateを新たにインポートしています。伴いフック定義にuseNavigateの定義を追加しています。
続いてJSXの箇所です。
// /src/components/Login.tsx
<Button
colorScheme='green'
width='100%'
variant='outline'
onClick={() => navigate('/register')}//変更:クリックしたら、/registerに遷移
>新規登録</Button>
「新規登録」ボタンの箇所、onClick時の処理について、useNavigateによるページ遷移を設定します。これで、ボタンクリックすると、/registerに移動します。
ここまでの実装で、サインアップ処理が実施でき、サインアップ完了すると、Home画面に遷移する動きが実現できます。試行するには、ログイン中の場合は、まずログアウトを行い、ログイン画面にある、新規登録をクリックし、サインアップ処理を行います。
以下画面のような動きが実現できると思います。
Firebaseのコンソール画面でもユーザーが追加されたことが確認できると思います。
10. パスワード変更・リセット機能
続いて、パスワード変更とリセット機能を実装していきます。
パスワード変更は、ログイン中にパスワードを変更する場合に使用します。現在のパスワードと新しいパスワードを入力し、変更します。
パスワードリセットは、パスワード自体を忘れてしまい、ログイン出来ない場合のリセット機能です。こちらは、リセット申請を行い、リセット用のURLをメールで案内する仕組みです。このリセット用のUIは、Firebaseが提供しているUIを利用します。
10.1 useFirebaseの変更
まずは、これまで通り、カスタムフックのuseFirebaseに、パスワード変更と、リセットの機能を追加します。下記は、変更後のuseFirebase.tsのコード全文です。
// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { createUserWithEmailAndPassword, EmailAuthProvider, reauthenticateWithCredential, sendPasswordResetEmail, signInWithEmailAndPassword, updatePassword, User } from "firebase/auth";
//EmailAuthProvider, reauthenticateWithCredential, updatePassword, sendPasswordResetEmail 追加
import { addDoc, collection, deleteDoc, doc, getDocs, query, updateDoc, where } from "firebase/firestore";
import { auth, db } from "../utils/firebase";
import { StudyData } from "../types/studyData";
type UseFirebase = () => {
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
email: string;
setEmail: React.Dispatch<React.SetStateAction<string>>;
password: string;
setPassword: React.Dispatch<React.SetStateAction<string>>;
handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
learnings: StudyData[];
setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;
fetchDb: (data: string) => Promise<void>
calculateTotalTime: () => number
updateDb: (data: StudyData) => Promise<void>
entryDb: (data: StudyData) => Promise<void>;
deleteDb: (data: StudyData) => Promise<void>
handleLogout: () => Promise<void>
passwordConf: string
setPasswordConf: React.Dispatch<React.SetStateAction<string>>
handleSignup: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
currentPassword: string//追加
setCurrentPassword: React.Dispatch<React.SetStateAction<string>>//追加
handleUpdatePassword: (e: React.FormEvent) => Promise<void>//追加
handleResetPassword: (e: React.FormEvent) => Promise<void>//追加
}
export const useFirebase: UseFirebase = () => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConf, setPasswordConf] = useState('');
const [currentPassword, setCurrentPassword] = useState('');//追加
const [user, setUser] = useState<User | null>(null);
const [learnings, setLearnings] = useState<StudyData[]>([]);
const navigate = useNavigate()
const toast = useToast()
////Authentication
//ログイン処理
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
const userLogin = await signInWithEmailAndPassword(auth, email, password);
console.log("User Logined:", userLogin);
toast({
title: 'ログインしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/')
}
catch (error) {
console.error("Error during sign up:", error);
toast({
title: 'ログインに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
finally {
setLoading(false);
}
};
//ユーザがセッション中か否かの判定処理
useEffect(() => {
const unsubscribed = auth.onAuthStateChanged((user) => {
setUser(user);
if (user) {
setEmail(user.email as string)
} else {
navigate("/login");//userがセッション中でなければ/loginに移動
}
});
return () => {
unsubscribed();
};
}, [user]);
//ログアウト処理
const handleLogout = async () => {
setLoading(true);
try {
const usertLogout = await auth.signOut();
console.log("User Logout:", usertLogout);
toast({
title: 'ログアウトしました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/login');
}
catch (error) {
console.error("Error during logout:", error);
toast({
title: 'ログアウトに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
//サインアップ処理
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== passwordConf) {
toast({
title: 'パスワードが一致しません',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
} else if (password.length < 6) {
toast({
title: 'パスワードは6文字以上にしてください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
}
try {
setLoading(true);
// Firebaseにユーザーを作成する処理
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
console.log("User created:", userCredential);
toast({
title: 'ユーザー登録が完了しました。',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/');
}
catch (error) {
console.error("Error during sign up:", error);
toast({
title: 'サインアップに失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
//追加:パスワード変更
const handleUpdatePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== passwordConf) {
toast({
title: 'パスワードが一致しません',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
} else if (password.length < 6) {
toast({
title: 'パスワードは6文字以上にしてください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
}
try {//パスワードの更新はユーザの再認証が必要
setLoading(true);
if (user) {
// 再認証のために、ユーザーの認証情報を取得
const credential = EmailAuthProvider.credential(
user.email!,
currentPassword // 現在のパスワードを入力
);
// 再認証処理
await reauthenticateWithCredential(user, credential);
// パスワードの更新処理
await updatePassword(user, password);
toast({
title: 'パスワード更新が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/'); // updatePasswordが成功した場合にのみページ遷移
}
} catch (error: any) {
console.error("Error during password reset:", error);
toast({
title: 'パスワード更新に失敗しました',
description: `${error.message}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
} finally {
setLoading(false);
}
};
//追加:パスワードリセット申請
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
try {
// パスワードリセットメール送信
await sendPasswordResetEmail(auth, email)
toast({
title: 'パスワード設定メールを確認してください',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/login'); // sendPasswordResetEmailが成功した場合にのみページ遷移
} catch (error: any) {
console.error("Error during password reset:", error);
toast({
title: 'パスワード更新に失敗しました',
description: `${error.message}`,
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
} finally {
setLoading(false);
}
};
////Firestore
//Firestoreデータ取得
const fetchDb = async (data: string) => {
setLoading(true);
try {
const usersCollectionRef = collection(db, 'users_learnings');
const q = query(usersCollectionRef, where('email', '==', data)); // ログインユーザーのemailでフィルタ
const querySnapshot = await getDocs(q);
const fetchedLearnings = querySnapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
} as StudyData));// Firebaseから取得したデータを`StudyData`型に明示的に変換
console.log("取得したデータ:", fetchedLearnings)
setLearnings(fetchedLearnings); // 正しい型でセット
}
catch (error) {
console.error("Error getting documents: ", error);
}
finally {
setLoading(false);
}
}
//Firestoreデータ更新
const updateDb = async (data: StudyData) => {
setLoading(true)
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await updateDoc(userDocumentRef, {
title: data.title,
time: data.time
});
toast({
title: 'データ更新が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.log(error)
toast({
title: 'データ更新に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
} finally {
setLoading(false)
}
}
//Firestoreデータ新規登録
const entryDb = async (data: StudyData) => {
setLoading(true)
try {
const usersCollectionRef = collection(db, 'users_learnings');
const documentRef = await addDoc(usersCollectionRef, {
title: data.title,
time: data.time,
email: email
});
console.log(documentRef, data);
toast({
title: 'データ登録が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error adding document: ", error);
toast({
title: 'データ登録に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false)
}
}
//Firestoreデータ削除
const deleteDb = async (data: StudyData) => {
setLoading(true);
try {
const userDocumentRef = doc(db, 'users_learnings', data.id);
await deleteDoc(userDocumentRef);
toast({
title: 'データを削除しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
}
catch (error) {
console.error("Error during delete:", error);
toast({
title: 'デー削除に失敗しました',
description: `${error}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
}
finally {
setLoading(false);
}
}
////Others
//学習時間合計
const calculateTotalTime = () => {
return learnings.reduce((total, learning) => total + learning.time, 0);
};
return {
loading,
setLoading,
email,
setEmail,
password,
setPassword,
handleLogin,
user,
setUser,
learnings,
setLearnings,
fetchDb,
calculateTotalTime,
updateDb,
entryDb,
deleteDb,
handleLogout,
passwordConf,
setPasswordConf,
handleSignup,
currentPassword,//追加
setCurrentPassword,//追加
handleUpdatePassword,//追加
handleResetPassword,//追加
}
}
冒頭のインポートの箇所は、”firebase/auth”から色々追加しています。
// /src/hooks/useFirebse.ts
import { createUserWithEmailAndPassword, EmailAuthProvider, reauthenticateWithCredential, sendPasswordResetEmail, signInWithEmailAndPassword, updatePassword, User } from "firebase/auth";
//EmailAuthProvider, reauthenticateWithCredential, updatePassword, sendPasswordResetEmail 追加
EmailAuthProvider, reauthenticateWithCredential, updatePassword, sendPasswordResetEmail を追加しています。名前からその役割は何となく想像がつくと思います。
続いて型定義です。
// /src/hooks/useFirebse.ts
currentPassword: string//追加
setCurrentPassword: React.Dispatch<React.SetStateAction<string>>//追加
handleUpdatePassword: (e: React.FormEvent) => Promise<void>//追加
handleResetPassword: (e: React.FormEvent) => Promise<void>//追加
新しいステート、currentPassword, setCurrentPassword を追加しています。これはパスワード更新の際の、現在のパスワードを格納するステートです。
その下は、パスワード更新を行うhandleUpdatePassword、パスワードリセットの申請を行うhandleResetPasswordの型定義を追加しています。
フックの定義です。
// /src/hooks/useFirebse.ts
const [currentPassword, setCurrentPassword] = useState('');//追加
先の型定義の説明で記載した通り、現在のパスワード格納用のステートを追加しています。
続いて、新たに追加した、2つの関数について説明します。まずは、パスワード更新機能です。
// /src/hooks/useFirebse.ts
//追加:パスワード変更
const handleUpdatePassword = async (e: React.FormEvent) => {//async/awaitによる非同期通信、React.FormEventによるイベントの型
e.preventDefault();//submitイベントの本来の動作を抑止
if (password !== passwordConf) {//入力したパスワードと再入力したパスワードの一致確認
toast({//一致しなければ、エラーメッセージ表示し、処理終了
title: 'パスワードが一致しません',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
} else if (password.length < 6) {//パスワード要件、6文字以上に合致するかチェック
toast({//合致しなければ、エラーメッセージ表示し、処理終了
title: 'パスワードは6文字以上にしてください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
return;
}
try {//パスワードの更新はユーザの再認証が必要
setLoading(true);//ローディング状態をセット
if (user) {
// 再認証のために、ユーザーの認証情報を取得
const credential = EmailAuthProvider.credential(//EmailAuthProviderメソッドで認証情報取得
user.email!,//emai情報を取得、型としてはnullの可能性がある為、末尾に!を付与
currentPassword // 現在のパスワードを入力
);
// 再認証処理
await reauthenticateWithCredential(user, credential);
// パスワードの更新処理
await updatePassword(user, password);
toast({//正常終了すれば成功メッセージ表示
title: 'パスワード更新が完了しました',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/'); // updatePasswordが成功した場合にのみページ遷移
}
} catch (error: any) {//エラーの場合は
console.error("Error during password reset:", error);//エラー出力
toast({//エラーメッセージ表示
title: 'パスワード更新に失敗しました',
description: `${error.message}`,
position: 'top',
status: 'error',
duration: 4000,
isClosable: true,
})
} finally {
setLoading(false);//最後にローディング解除
}
};
handleUpdatePasswordとして定義しています。説明はコード中にコメントしてますが、ポイントはパスワード更新等、重要な処理の場合は、ユーザーを再認証する必要がある点です。この為、EmailAuthProvider.credential でユーザー認証情報を取得し、再認証処理、reauthenticateWithCredentialを行っています。
なお、途中、user.email! と「!」を記載してますが、これは、型的にuserはnullの可能性があり、TypeScriptエラーが出るためです。「!」はnullでは無いということを強制的に宣言するものです。
再認証後は、updatePasswordメソッドでパスワード更新を実施してます。その後は、正常終了すれば、正常メッセージ表示の上、ルート(’/’)であるHome画面に遷移させています。エラー発生の場合は、エラーメッセージ表示です。最後にローディング状態解除し終了です。
次は、パスワードリセット申請を行う、handleResetPasswordです。この処理を行うと、リセット用のURLが記載されたメールが送信されます。
// /src/hooks/useFirebse.ts
//追加:パスワードリセット申請
const handleResetPassword = async (e: React.FormEvent) => {//async/awaitによる非同期通信、React.FormEventによるイベントの型
e.preventDefault();//submitイベントの本来の動作を抑止
setLoading(true);//ローディング状態をセット
try {
// パスワードリセットメール送信
await sendPasswordResetEmail(auth, email)
toast({//正常終了すれば成功メッセージ表示
title: 'パスワード設定メールを確認してください',
position: 'top',
status: 'success',
duration: 2000,
isClosable: true,
})
navigate('/login'); // sendPasswordResetEmailが成功した場合にのみページ遷移
} catch (error: any) {//エラーの場合は
console.error("Error during password reset:", error);//エラー出力
toast({//エラーメッセージ表示
title: 'パスワード更新に失敗しました',
description: `${error.message}`,
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
} finally {
setLoading(false);//ローディング状態を解除
}
};
処理はコメント通りです。FirebaseSDKのsendPasswordResetEmailメソッドで、リセットURLが記載されたメールを入力されたメールアドレス宛に送信します。
その後は、正常終了すれば、正常メッセージ表示の上、ルート(’/’)であるHome画面に遷移させています。エラー発生の場合は、エラーメッセージ表示。最後にローディング状態解除し終了です。
続いて、最後のreturn文です。追加したstate、関数をオブジェクトに追加しています。
// /src/hooks/useFirebse.ts
return {
loading,
setLoading,
email,
setEmail,
password,
setPassword,
handleLogin,
user,
setUser,
learnings,
setLearnings,
fetchDb,
calculateTotalTime,
updateDb,
entryDb,
deleteDb,
handleLogout,
passwordConf,
setPasswordConf,
handleSignup,
currentPassword,//追加
setCurrentPassword,//追加
handleUpdatePassword,//追加
handleResetPassword,//追加
}
10.2 UpdatePassword.tsxの作成
カスタムフックを更新しましたので、続いて、パスワード変更とパスワードリセットを行うコンポーネントを作成していきます。まずは、パスワード変更を行う、コンポーネント、UpdatePassword.tsxを作成します。
componentsフォルダに新たにUpdatePassword.tsxを作成します。
UpdatePassword.tsxに以下内容を記載します(コード全文です)。
// /src/components/UpdatePassword.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement } from "@chakra-ui/react";
import { RiLockPasswordFill } from "react-icons/ri";
import { useFirebase } from "../hooks/useFirebase";
const UpdatePassword = () => {
const { loading, password, setPassword, passwordConf, setPasswordConf, currentPassword, setCurrentPassword, handleUpdatePassword } = useFirebase()
return (
<>
<Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>
<Card size={{ base: 'sm', md: 'lg' }} p={4}>
<Heading size='md' textAlign='center'>パスワード更新</Heading>
<CardBody>
<form onSubmit={handleUpdatePassword}>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='現在のパスワードを入力'
name='currentPassword'
value={currentPassword}
required
mb={2}
onChange={e => setCurrentPassword(e.target.value)}
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='新パスワードを入力'
name='password'
value={password}
required
mb={2}
onChange={e => setPassword(e.target.value)}
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='新パスワードを入力(確認)'
name='passwordConf'
value={passwordConf}
required
mb={2}
onChange={e => setPasswordConf(e.target.value)}
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
type='submit'
colorScheme='green'
>パスワードを更新する</Button>
<Button
colorScheme='gray'
onClick={() => window.history.back()}
mx={2}
>戻る</Button>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
};
export default UpdatePassword
解説していきます。構造としてはLogin、Registerコンポーネントと同じような作りです。
まず、冒頭のインポート、フック定義の箇所です。
// /src/components/UpdatePassword.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement } from "@chakra-ui/react";//Chakura UIインポート
import { RiLockPasswordFill } from "react-icons/ri";//パスワードアイコンのインポート
import { useFirebase } from "../hooks/useFirebase";//カスタムフック、useFirevaseのインポート
const UpdatePassword = () => {
const { loading, password, setPassword, passwordConf, setPasswordConf, currentPassword, setCurrentPassword, handleUpdatePassword } = useFirebase()
//useFirebaseの定義
インポートはこれまでと同じ要領、Chakra UIのパーツインポートと、アイコンのインポート、カスタムフック、useFirevaseのインポートを定義しています。
その下、フック定義は、useFirebaseの定義を行っています。利用するオブジェクトは、loading, password, setPassword, passwordConf, setPasswordConf, currentPassword, setCurrentPassword, handleUpdatePassword です。
続いてJSXの箇所です。
// /src/components/UpdatePassword.tsx
return (
<>
<Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>{/*Flex適用*/}
<Card size={{ base: 'sm', md: 'lg' }} p={4}>{/*Chakra UIのCard適用*/}
<Heading size='md' textAlign='center'>パスワード更新</Heading>
<CardBody>
<form onSubmit={handleUpdatePassword}//formサブミットで、サインアップ処理するuseFirebaseのhandleUpdatePasswordを実行
>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='現在のパスワードを入力'
name='currentPassword'
value={currentPassword}
required
mb={2}
onChange={e => setCurrentPassword(e.target.value)}//onChangeイベントで入力値をcurrentPasswordにセット
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='新パスワードを入力'
name='password'
value={password}
required
mb={2}
onChange={e => setPassword(e.target.value)}//onChangeイベントで入力値をpasswordにセット
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='新パスワードを入力(確認)'
name='passwordConf'
value={passwordConf}
required
mb={2}
onChange={e => setPasswordConf(e.target.value)}//onChangeイベントで入力値をpasswordConfにセット
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
type='submit'
colorScheme='green'
>パスワードを更新する</Button>
<Button
colorScheme='gray'
onClick={() => window.history.back()}//JavaScriptのwindow.history.backで前のURLに先鋭
mx={2}
>戻る</Button>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
コード中にコメントで解説を記載していますが、<Flex><Card>等、Chakra UIのコンポーネントを配置の上、<Form>設置、<Input>にて現在のパスワードと新しいパスワード及び新パスワード再確認の入力エリアを設けています。
それぞれの入力エリアは、値が入力されれば、onChangeイベントで、set関数によりそれぞれのステートへの格納をしています。
formがsubmitされるとuseFirebaseのhandleUpdatePasswordを実行し、パスワードの更新を行います。「戻る」ボタンについては、JavaScriptのwindow.history.backで前のページに戻る処理をしています。
10.3 SendReset.tsxの作成
次に、パスワードリセットの申請を行うコンポーネントSendReset.tsxを作成します。
componentsフォルダに新たにSendReset.tsxを作成します。
SendReset.tsxに以下内容を記載します(コード全文です)。
// /src/components/SendReset.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Stack, Text } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiMailSendLine } from "react-icons/ri";
import { useFirebase } from "../hooks/useFirebase";
const SendReset = () => {
const { loading, email, setEmail, handleResetPassword } = useFirebase()
return (
<>
<Flex alignItems='center' justify='center' p={5}>
<Card px={5}>
<Heading size='md' textAlign='center' mt={4}>パスワードリセット申請</Heading>
<Text textAlign='center' fontSize='12px' color='gray'>入力したメールアドレス宛にパスワードリセットURLの案内をお送りします。</Text>
<CardBody w={{ base: 'xs', md: 'lg' }}>
<form onSubmit={handleResetPassword}>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<FaUserCheck color='gray' />
</InputLeftElement>
<Input
autoFocus
type='email'
placeholder='登録メールアドレスを入力'
name='email'
value={email}
required
mb={2}
onChange={e => setEmail(e.target.value)}
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
type="submit"
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mx={2}>
<Stack mr={2}><RiMailSendLine /></Stack>リセット申請する</Button>
<Button
colorScheme='gray'
onClick={() => window.history.back()}
mx={2}
>戻る</Button>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
};
export default SendReset
解説します。構造としては、ここまでのコンポーネントと同じような作りです。
まず、冒頭のインポート、フック定義の箇所です。
// /src/components/SendReset.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Stack, Text } from "@chakra-ui/react";//Chakura UIインポート
import { FaUserCheck } from "react-icons/fa";//ユーザーアイコンのインポート
import { RiMailSendLine } from "react-icons/ri";//メールアイコンのインポート
import { useFirebase } from "../hooks/useFirebase";//カスタムフック、useFirevaseのインポート
const SendReset = () => {
const { loading, email, setEmail, handleResetPassword } = useFirebase()//useFirebaseの定義
インポートはこれまでと同様です。Chakra UIのパーツインポートと、アイコンのインポート、カスタムフック、useFirevaseのインポートを定義しています。
その下、フック定義は、useFirebaseの定義を行っています。利用するオブジェクトは、loading, email, setEmail, handleResetPassword です。
続いてJSXの箇所です。
// /src/components/SendReset.tsx
return (
<>
<Flex alignItems='center' justify='center' p={5}>{/*Flex適用*/}
<Card px={5}>{/*Chakra UIのCard適用*/}
<Heading size='md' textAlign='center' mt={4}>パスワードリセット申請</Heading>
<Text textAlign='center' fontSize='12px' color='gray'>入力したメールアドレス宛にパスワードリセットURLの案内をお送りします。</Text>
<CardBody w={{ base: 'xs', md: 'lg' }}>
<form onSubmit={handleResetPassword}//formサブミットで、サインアップ処理するuseFirebaseのhandleResetPasswordを実行
>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<FaUserCheck color='gray' />
</InputLeftElement>
<Input
autoFocus
type='email'
placeholder='登録メールアドレスを入力'
name='email'
value={email}
required
mb={2}
onChange={e => setEmail(e.target.value)}//onChangeイベントで入力値をemailにセット
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
type="submit"
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mx={2}>
<Stack mr={2}><RiMailSendLine /></Stack>リセット申請する</Button>
<Button
colorScheme='gray'
onClick={() => window.history.back()}//JavaScriptのwindow.history.backで前のURLに先鋭
mx={2}
>戻る</Button>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
これまでのコンポーネントと同じ感じです。<Flex><Card>等、Chakra UIのコンポーネントを配置の上、<Form>設置、<Input>にてメールアドレス入力エリアを設けています。
値が入力されれば、onChangeイベントで、set関数によりemailステートへの格納をしています。
formがsubmitされるとuseFirebaseのhandleResetPasswordを実行し、入力されたメールアドレス宛にリセットURL案内のメールが送信されます(なお、メールアドレスはサインアップ済のメールアドレスである必要があります)。「戻る」ボタンについては、JavaScriptのwindow.history.backで前のページに戻る処理をしています。
10.4 関連コンポーネントの変更
パスワード変更と、パスワードリセットのコンポーネントが作成出来ましたので、これを機能するように、関連するコンポーネントを変更していきます。
10.4.1 App.tsxのルーティング設定
まずは、App.tsxにルーティングの設定を追加します。
以下、変更後のApp.tsx、コード全文です。以降、App.tsxの修正はありませんので、最終版です。
// /src/App.tsx
import { Route, Routes } from "react-router-dom"
import Home from "./components/Home"
import Login from "./components/Login"
import Register from "./components/Register"
import UpdatePassword from "./components/UpdatePassword"//UpdatePassword追加
import SendReset from "./components/SendReset"//SendReset追加
function App() {
return (
<>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/updatePassword" element={<UpdatePassword />}//UpdatePasswordのルート追加
/>
<Route path="/sendReset" element={<SendReset />}//SendResetのルート追加
/>
</Routes>
</>
)
}
export default App
新たに、UpdatePasswordとSendResetのインポートを追加しています。
JSXの箇所に、Routeを追加し、UpdatePasswordを/updatePassword、SendResetを/sendResetとしてセットしています。
10.4.2 Home.tsxの変更
続いて、パスワード更新ボタンを配置している、Homeコンポーネントを変更します。現時点は、ボタンを配置しているだけですが、クリックすると、/updatePasswordに遷移するように設定します。
変更する箇所は、冒頭のインポート、フックの定義、そして、JSXの「パスワード更新」ボタンのonClickの箇所です。
具体的に見ていきます。
まず冒頭のインポート部分です。
// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";//useNavigate追加
クリックして別ページに遷移させますので、React RouterのuseNavigateのインポートを追加します。
続いて、フックの定義箇所です。
// /src/components/Home.tsx
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb, deleteDb, handleLogout } = useFirebase()
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()
const modalDelete = useDisclosure()
const alertLogout = useDisclosure()
const initialRef = useRef(null)
const cancelRef = useRef(null)
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [deleteLearning, setDeleteLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const toast = useToast()
const navigate = useNavigate()//追加
インポートで追加した、useNavigateの定義を追加しています。
そしてJSXの箇所です。
// /src/components/Home.tsx
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => navigate('/updatePassword')}//変更
>パスワード更新</Button>
</Stack>
</Box>
「パスワード更新」ボタンの箇所で、onClick時に、useNavigateによるnavigateで’/updatePassword’に遷移させるよう変更しています。
長いですが、Home.tsxのこの時点でのコード全文は以下となります。また予定している変更は完了ですので、これで最終版です。
// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";//useNavigate追加
import {
AlertDialog, AlertDialogBody, AlertDialogCloseButton, AlertDialogContent,
AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay,
Box, Button, Card, CardBody, Flex, FormControl, FormLabel, Heading, Input, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Stack,
Table, TableContainer, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast
} from "@chakra-ui/react";
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";
import { StudyData } from "../types/studyData";
const Home = () => {
const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb, entryDb, deleteDb, handleLogout } = useFirebase()
const modalEdit = useDisclosure()
const modalEntry = useDisclosure()
const modalDelete = useDisclosure()
const alertLogout = useDisclosure()
const initialRef = useRef(null)
const cancelRef = useRef(null)
const [editLearning, setEditLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [entryLearning, setEntryLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const [deleteLearning, setDeleteLearning] = useState<StudyData>({ id: '', title: '', time: 0 })
const toast = useToast()
const navigate = useNavigate()//追加
useEffect(() => {
if (user) {
fetchDb(email)
console.log('Firestore', email)
}
}, [user]);
const handleUpdate = async () => {
await updateDb(editLearning);
fetchDb(email)
if (!loading) {
setTimeout(() => {
modalEdit.onClose();
}, 500);
}
}
const handleEntry = async () => {
if (learnings.some((l) => l.title === entryLearning.title)) {
const existingLearning = learnings.find((l) => l.title === entryLearning.title);
if (existingLearning) {
existingLearning.time += entryLearning.time;
await updateDb(existingLearning);
}
} else {
await entryDb(entryLearning);
}
fetchDb(email)
setEntryLearning({ id: "", title: "", time: 0 })
if (!loading) {
setTimeout(() => {
modalEntry.onClose()
}, 500);
}
};
const handleDelete = async () => {
await deleteDb(deleteLearning);
fetchDb(email)
if (!loading) {
setTimeout(() => {
modalDelete.onClose();
}, 500);
}
}
return (
<>
<Flex alignItems='center' justify='center' p={5}>
<Card size={{ base: 'sm', md: 'lg' }}>
<Box textAlign='center' mb={2} mt={10}>
ようこそ!{email} さん
</Box>
<Heading size='md' textAlign='center'>Learning Records</Heading>
<CardBody>
<Box textAlign='center'>
学習記録
{loading && <Box p={10}><Spinner /></Box>}
<TableContainer>
<Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
<Thead>
<Tr>
<Th>学習内容</Th>
<Th>時間(分)</Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{learnings.map((learning, index) => (
<Tr key={index}>
<Td>{learning.title}</Td>
<Td>{learning.time}</Td>
<Td>
{/* 編集用モーダル */}
<Button variant='ghost' onClick={() => {
setEditLearning(learning)
modalEdit.onOpen()
}}><FiEdit color='black' /></Button>
<Modal
initialFocusRef={initialRef}
isOpen={modalEdit.isOpen}
onClose={modalEdit.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>記録編集</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
placeholder='学習内容'
name='title'
value={editLearning.title}
onChange={(e) => {
setEditLearning({ ...editLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
placeholder='学習時間'
name='time'
value={editLearning.time}
onChange={(e) => {
setEditLearning({ ...editLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{editLearning.title}</div>
<div>入力されている学習時間:{editLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (editLearning.title !== "" && editLearning.time > 0) {
handleUpdate()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
データを更新
</Button>
<Button onClick={() => {
modalEdit.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Td>
<Td>
{/* 削除用モーダル */}
<Button variant='ghost'
onClick={() => {
setDeleteLearning(learning)
modalDelete.onOpen()
}}><MdDelete color='black' /></Button>
<Modal
isOpen={modalDelete.isOpen}
onClose={modalDelete.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>データ削除</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<Box>
以下のデータを削除します。<br />
学習内容:{deleteLearning.title}、学習時間:{deleteLearning.time}
</Box>
</ModalBody>
<ModalFooter>
<Button onClick={modalDelete.onClose} mr={3}>Cancel</Button>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
ref={initialRef}
colorScheme='red'
onClick={handleDelete}
>
削除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
<Box p={5}>
<div>合計学習時間:{calculateTotalTime()}分</div>
</Box>
{/* 新規登録モーダル */}
<Box p={25}>
<Stack spacing={3}>
<Button
colorScheme='green'
variant='outline'
onClick={modalEntry.onOpen}>
新規データ登録
</Button>
</Stack>
<Modal
initialFocusRef={initialRef}
isOpen={modalEntry.isOpen}
onClose={modalEntry.onClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>新規データ登録</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl>
<FormLabel>学習内容</FormLabel>
<Input
ref={initialRef}
name='newEntryTitle'
placeholder='学習内容'
value={entryLearning.title}
onChange={(e) => {
setEntryLearning({ ...entryLearning, title: e.target.value })
}}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>学習時間</FormLabel>
<Input
type='number'
name='newEntryTime'
placeholder='学習時間'
value={entryLearning.time}
onChange={(e) => {
setEntryLearning({ ...entryLearning, time: Number(e.target.value) })
}}
/>
</FormControl>
<div>入力されている学習内容:{entryLearning.title}</div>
<div>入力されている学習時間:{entryLearning.time}</div>
</ModalBody>
<ModalFooter>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='green'
mr={3}
onClick={() => {
if (entryLearning.title !== "" && entryLearning.time > 0) {
handleEntry()
}
else {
toast({
title: '学習内容と時間を入力してください',
position: 'top',
status: 'error',
duration: 2000,
isClosable: true,
})
}
}}
>
登録
</Button>
<Button onClick={() => {
modalEntry.onClose()
}}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
{/*ログアウトアラート*/}
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={alertLogout.onOpen}>ログアウト</Button>
<AlertDialog
motionPreset='slideInBottom'
leastDestructiveRef={cancelRef}
onClose={alertLogout.onClose}
isOpen={alertLogout.isOpen}
isCentered
>
<AlertDialogOverlay />
<AlertDialogContent>
<AlertDialogHeader>ログアウト</AlertDialogHeader>
<AlertDialogCloseButton />
<AlertDialogBody>
ログアウトしますか?
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={alertLogout.onClose}>
Cancel
</Button>
<Button isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
colorScheme='red' ml={3}
onClick={handleLogout}>
ログアウト</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Stack>
</Box>
<Box px={25} mb={4}>
<Stack spacing={3}>
<Button
width='100%'
variant='outline'
onClick={() => navigate('/updatePassword')}//変更
>パスワード更新</Button>
</Stack>
</Box>
</CardBody>
</Card>
</Flex>
</>
)
}
export default Home;
この時点で、Home画面からパスワード更新画面に遷移し、パスワード更新処理を行う事ができます。下図のような動きです。
10.4.3 Login.tsxの変更
変更する最後のコンポーネントです。現在、Loginコンポーネントの「パスワードをお忘れですか?」の箇所は空の処理を入れてますが、この箇所をクリックすると、パスワードリセット申請の画面に遷移するようにしていきます。
既に、新規登録のRegisterコンポーネントの時にuseNavigateの定義はしてありますので、追加するのは、JSXのonClickイベントの箇所のみです。
以下、変更箇所です。
// /src/components/Login.tsx
<Box mt={4} mb={2} textAlign='center'>
<Stack spacing={3}>
<Button
colorScheme='green'
width='100%'
variant='ghost'
onClick={() => navigate('/sendReset')}//変更:クリックしたら、/sendRestに遷移
>パスワードをお忘れですか?</Button>
</Stack>
</Box>
「パスワードをお忘れですか?」ボタンのonClickの箇所に、useNavigateによる、navigate(‘/sendReset’)を実行する形にしています。クリックすると、/sendRestに遷移する動きです。以下、Login.tsxのコード全体を乗せておきます。Login.tsxも変更はこれで最後ですので、最終版となります。
// /src/components/Login.tsx
import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Stack } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiLockPasswordFill } from "react-icons/ri";
import { useFirebase } from "../hooks/useFirebase";
import { useNavigate } from "react-router-dom";
const Login = () => {
const { loading, email, setEmail, password, setPassword, handleLogin } = useFirebase()
const navigate = useNavigate()
return (
<>
<Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>
<Card size={{ base: 'sm', md: 'lg' }} p={4}>
<Heading size='md' textAlign='center'>ログイン</Heading>
<CardBody>
<form onSubmit={handleLogin}
>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<FaUserCheck color='gray' />
</InputLeftElement>
<Input
autoFocus
type='email'
placeholder='メールアドレスを入力'
name='email'
value={email}
mb={2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
/>
</InputGroup>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<RiLockPasswordFill color='gray' />
</InputLeftElement>
<Input
type='password'
placeholder='パスワードを入力'
name='password'
value={password}
required
mb={2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
</InputGroup>
<Box mt={4} mb={2} textAlign='center'>
<Button
isLoading={loading}
loadingText='Loading'
spinnerPlacement='start'
type='submit' colorScheme='green' width='100%' mb={2}>ログイン</Button>
<Button
colorScheme='green'
width='100%'
variant='outline'
onClick={() => navigate('/register')}
>新規登録</Button>
</Box>
<Box mt={4} mb={2} textAlign='center'>
<Stack spacing={3}>
<Button
colorScheme='green'
width='100%'
variant='ghost'
onClick={() => navigate('/sendReset')}//変更:クリックしたら、/sendRestに遷移
>パスワードをお忘れですか?</Button>
</Stack>
</Box>
</form>
</CardBody>
</Card>
</Flex>
</>
)
}
export default Login;
ここまでの実装で、下図のようにパスワードリセット申請の処理を実現できます。
なお、上図の例では存在しないメールアドレスを入力してますが、受信可能なメールアドレス及び、認証済のメールアドレスじゃないとメールは送信されませんので、ご注意ください。この箇所をテストする場合は、受信可能及び認証済のメールアドレスでテストしましょう。
以下のようなメールがFirebaseから届きます。
リンクをクリックすると、下記のようなFirebaseのパスワードリセット画面に遷移します。そこでパスワードリセット処理を行います。
ここまでで、パスワード変更・リセット機能の実装は完了です。また、本記事で開発する内容はこれで完了となります。
11. GitHub、Firebase連携
一通り、コード作成が完了しましたので、リポジトリ作成とGitHubへのコミット(push)、そしてFirebaseとGitHub連携を行い、GitHubプッシュ時にFirebaseに自動デプロイされるようにしてみます。
この一連の作業は、過去投稿した下記記事に詳細を記載してますので、ご参照ください。プロジェクト名等を読み替えていただくだけです。
なお、注意点ですが、Firebaseにデプロイした場合、React Routerは、リロードした場合にパス設定がうまく動かず、404エラーとなります(/homeでリロードした場合等)。
これを回避するためには、下記記事が参考になります。
具体的にはプロジェクト直下にある、firebase.json に以下内容を追加します。
// /firebase.json
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
]
/*追加ここから*/
,
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
/*追加ここまで*/
}
}
上記は、TypeScriptのコメントアウト形式で追加内容を明示してますが、jsonファイルはコメントは記載出来ないのでご注意ください(コメントの箇所は省いてください)。
その他の注意点ですが、
- リポジトリは、Privateをおすすめします。.env.localの中にFirebase接続の機密情報が含まれるためです。
- .gitgnoreファイルにGitHub同期除外対象として、.env.localを定義する事も可能ですが、この場合は、GitHubでのビルド、Firebaseでのデプロイはうまく行きませんので(.env.local情報が無いため)、ご注意ください。
一見、ビルド及びデプロイは成功しますが、アプリが動きません(Firebase接続情報が見つからないとエラーになります)
Firebaseでデプロイして、ドメインの箇所からリリースされたアプリケーションを確認してみましょう。アプリが表示されれば公開完了です!
「学習記録アプリ Firebaseで認証・DB実装してみた」これにて終了です。どなたかの参考になれば嬉しいです。
No responses yet