前回に続きReact+Firebaseでミニアプリを作っていきます。前回の投稿はこちらから。
React + Firebaseのミニアプリを作る【実装編2】
前回の投稿から期間が空いてしまいましたが、引き続きミニアプリを作っていきます。 前回の投稿はこちらから。 どんなアプリを作るかは【計画編】を見て下さい。 今回はCRUDの実装を開始します ...
続きを見る
どんなアプリを作るかは【計画編】を見て下さい。
前回はReduxのセットアップと、Firebaseからデータを取得し表示するところまで実装しました。
今回はデータの作成、すなわちCRUDのCreate部分から実装していきます。
REST APIでCRUD作成(前回の続き)
今回ベースとするソースコードは以下のコードから【実装編2】の内容を実装したものになります。
CRUDの完成コードはこちら↓
フォルダ構成やファイルの全体像がわからなくなった場合は完成コードを参照して下さい。
CREATE
Subjectに関してデータの作成が出来るよう実装していきます。
コンポーネント
Subject(お題)の新規作成のためにSubjectCreateコンポーネントを作成します。フォームやレイアウトは編集のときに再利用できるように別コンポーネントに分割します。
src > components > content > form > SubjectCreate.jsを作成します。
import React from "react"; import { useDispatch } from "react-redux"; import { useHistory } from "react-router-dom"; import { useForm } from "react-hook-form"; import { createSubject } from "../../../actions/subjects"; import SubjectForm from "./SubjectForm"; import SubjectFormLayout from "../../layout/SubjectFormLayout"; const SubjectCreate = () => { const dispatch = useDispatch(); const history = useHistory(); const { register, handleSubmit } = useForm(); const onSubmit = data => { dispatch(createSubject(data)); history.push("/"); }; return ( <SubjectFormLayout title="新規作成"> <SubjectForm handleSubmit={handleSubmit} register={register} onSubmit={onSubmit} /> </SubjectFormLayout> ); }; export default SubjectCreate;
重要な点はonSubmit部分でcreateSubjectをdispatchし、その後一覧ページに遷移させているところです。
フォームの処理にはReact Hook Formを使用しています。React Hook Form に関しては以下の記事で取り上げていますので興味のある方はどうぞ。
React Hook Formがいい感じ!
Contentsはじめに使ってみるMaterial UIと使うまとめ はじめに ReactでWebアプリケーションを作る際に自力でフォームを作成するのは、現実的に考えるとやらない方がいいです。自力でフ ...
続きを見る
ポイント
ここからは単にフォーム画面を作っていくだけですので流し読みorコピペでOKです。
次に同じフォルダにSubjectFormを作成します。
import React from "react"; import TextField from "@material-ui/core/TextField"; import Button from "@material-ui/core/Button"; import Box from "@material-ui/core/Box"; const SubjectForm = props => { const { handleSubmit, register, onSubmit, title } = props; return ( <form onSubmit={handleSubmit(onSubmit)}> <TextField label="タイトル" variant="outlined" fullWidth inputRef={register} name="title" autoComplete="off" defaultValue={title} /> <Box textAlign="center" mt={2}> <Button variant="contained" color="primary" type="submit"> Save </Button> </Box> </form> ); }; export default SubjectForm;
components > layout > SubjectFormLayout.jsを以下のように書きます。
import React from "react"; import { Link } from "react-router-dom"; import Button from "@material-ui/core/Button"; import Box from "@material-ui/core/Box"; import Grid from "@material-ui/core/Grid"; import ArrowBackIosIcon from "@material-ui/icons/ArrowBackIos"; const SubjectFormLayout = props => { const { title, children } = props; return ( <React.Fragment> <Grid item xs={12}> <Box textAlign="center" mt={2}> <h2>{title}</h2> </Box> <Link to="/"> <Button variant="contained" color="primary" startIcon={<ArrowBackIosIcon />} > Back </Button> </Link> <Box mt={2}>{children}</Box> </Grid> </React.Fragment> ); }; export default SubjectFormLayout;
作成したフォーム画面を表示するため、components > layout > Content.js内に以下のようにRouteを追加して下さい。
// import SubjectCreate from "../content/form/SubjectCreate"; インポートも忘れずに <Route path="/create"> <Grid container justify="center" className={classes.root}> <Grid item xs={12} sm={10} md={8}> <SubjectCreate /> </Grid> </Grid> </Route>
ポイント
React-RouterのSwitch内はパスが部分一致していればレンダーされます。上から順に最初にマッチしたものがレンダーされるため、path="/"が一番上にあれば必ずマッチしてしまいます。path="/"は一番下に書くか、exactプロパティを設定しましょう。
これで/createにアクセスすればSubjectCreateコンポーネントが表示されます。(この段階ではaction creatorを作っていないのでエラーになります。)
Action Creator
次にSubjectの新規作成のaction creatorを作成します。Subject作成時にQuestion&Answerの初期データも作成します。
流れとしては
- POSTメソッドのAPI通信でデータを新規作成
- 作成されたSubjectのIDを取得
- IDを元にstateのsubjectListを変更
- それと同時にIDを用いてQ&Aの初期データを作成(ここでもAPI通信)
ということをやっていきます。以下はactions > subjects.js。
import { createInitialQuestion } from "./questionAnswers"; // これをimportに追加 export const createSubject = data => async dispatch => { const subject = { title: data.title, image: "" }; // imageには空の文字列を入れておく try { const result = await axios.post("/subject.json", subject); // POSTメソッドでAPI通信 dispatch(createSubjectSuccess(data, result.data.name)); // state変更 dispatch(createInitialQuestion(result.data.name)); // 作成する際にはQuestion & Answerも同時に作成 } catch (error) { dispatch(apiFailed("create subject failed")); } }; const createSubjectSuccess = (data, id) => { return { type: ACTION_TYPES.CREATE_SUBJECT, payload: { id: id, title: data.title, image: "/image" // imageは適当な値を入れておく } }; };
ポイント
画像アップロードは後で対応するためimageには空の文字列・もしくは適当な値が入れてあります。
通信後に返ってくるデータのdata.nameに作成されたオブジェクトのIDが入っていますので、これを利用してQ&Aの初期データを作成します。
DBは「qa > subject のID > データ」という構造にしたいため、Subjectと同じようにPOSTメソッドで通信すると意図した構造になりません。結論から言うとPATCHを使います。
src > actions > questionAnswers.jsに以下のように書きます。
import * as ACTION_TYPES from "./actionTypes"; import axios from "../axios"; import { apiFailed } from "./index"; import { qas } from "../initialQuestions"; export const createInitialQuestion = id => async dispatch => { try { /* メソッドはpatchでなければならない。 postにした場合は id: { value }の形で保存されるため axios.post("/qa.json", { [id]: qas })とすると u-id: { id: { value } } という1階層深い形になる。 axios.post("/qa/${id}.json", { qas })とすると id: { u-id: { value } } となる putの場合は目的通りの形になるが、データを追加すると古いデータが上書き消去されてしまう。 そのためpatchである必要がある。 */ await axios.patch("/qa.json", { [id]: qas }); } catch (error) { dispatch(apiFailed("create initial question failed")); } };
ソースコード中に書いてありますが、メソッドはpatchでなくてはなりません。これはFirebaseの仕様上postやputを使うと階層が意図せず深くなってしまったり、データが上書きされたりしてしまうからです。
これに関しては公式ドキュメントが最も参考になります↓
気になる方はご自身で挙動を確かめてみて下さい。
src > initialQuestions.jsに以下のように質問の初期データを設定しておきます。
export const qas = { q1: "「それ」を一言で表すと?", q2: "「それ」はどんな問題をどう解決した?", q3: "「それ」の代わりとなるものは?", q4: "「それ」に関連するキーワードは?", q5: "「それ」の目次を作るとしたら?", a1: "", a2: "", a3: "", a4: "", a5: "" };
補足
もちろんDBに質問を保存しておくことも出来ますし、そちらの方が良いです。今回は簡略化のためローカルに初期データを設定しておきます。
あとはReducer側で通信結果をstateに反映させるだけです。
Reducer
reducers > subjects.jsに以下のcase文を追加します。追加したデータをconcatで連結。
case ACTION_TYPES.CREATE_SUBJECT: return { ...state, subjectList: state.subjectList.concat(action.payload) };
これでSubjectのCreateは完成です。
Createボタンを押すことでこのようにフォームが表示されるはずです。
新規作成をするとFirebase側にきちんと保存されているはずです。確認してみて下さい。
詳細画面・Q&Aの表示
続いて作成したQ&Aの初期データを詳細画面で表示させます。
コンポーネント
データの表示のためSubjectのIDを取得し、IDを元にAPIでデータを取得します。
またuseSelectorを使って表示するデータをstateから取得するよう変更します。
DetailView.jsを以下のように編集します。
import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useParams } from "react-router-dom"; import Grid from "@material-ui/core/Grid"; import Box from "@material-ui/core/Box"; import Button from "@material-ui/core/Button"; import QuestionAnswer from "./detail/QuestionAnswer"; import DetailToolbar from "./detail/DetailToolbar"; import ExtraQuestion from "./detail/ExtraQuestion"; import { getSubject } from "../../actions/subjects"; const DetailView = props => { const dispatch = useDispatch(); const { id } = useParams(); const item = useSelector(state => state.subjects.subject); const questions = useSelector(state => state.subjects.questions); const answers = useSelector(state => state.subjects.answers); useEffect(() => { dispatch(getSubject(id)); }, [dispatch, id]); return ( <Grid container spacing={2}> <Grid item xs={12}> <DetailToolbar title={item.title} id={item.id} /> </Grid> <Grid item xs={12}> <Grid container spacing={5}> {Object.keys(questions).map(ky => { const answerKey = ky.replace("q", "a"); return ( <Grid item key={ky} xs={12}> <QuestionAnswer question={questions[ky]} answer={answers[answerKey]} /> </Grid> ); })} <Grid item xs={12}> <ExtraQuestion /> </Grid> <Grid item xs={12}> <Box textAlign="center"> <Button variant="contained" color="primary" type="submit"> Save </Button> </Box> </Grid> </Grid> </Grid> </Grid> ); }; export default DetailView;
詳細画面はpath="/detail/:id"
でアクセスしているので、このURLからIDを取得します。
const { id } = useParams();
でURLのParamを取得できます(参考)ので、このIDを元にデータの取得を行います。
補足
ListViewでクリックされた要素をReduxのStateで保存しておく、という方法も可能です。ただしその場合はリロード後に情報が失われてしまいますし、上記の方法の方が簡単に実装できます。
またcomponents > content > detail > DetailToolbar.jsで
titleをpropsから取得するよう変更します。
const { title } = props
Action Creator
詳細画面ではSubjectデータとQ&Aデータのどちらも必要になります。
それぞれGETリクエストで取得します。
export const getSubject = id => async dispatch => { try { const result = await axios.get(`/subject/${id}.json`); // subjectの取得 const questionAnswers = await axios.get(`qa/${id}.json`); // Q&Aの取得 dispatch(getSubjectSuccess(id, result.data, questionAnswers.data)); } catch (error) { dispatch(apiFailed("get subject failed")); } }; const getSubjectSuccess = (id, subjectData, questionAnswers) => { let qas = { questions: {}, answers: {} }; Object.keys(questionAnswers).forEach(key => { key.startsWith("q") ? (qas["questions"][key] = questionAnswers[key]) : (qas["answers"][key] = questionAnswers[key]); }); return { type: ACTION_TYPES.GET_SUBJECT, payload: { id: id, data: subjectData, questionAnswers: qas } }; };
前回の記事のDB設計の通りDB側ではQuestionとAnswerが同じ階層で保存されているため、getSubjectSuccessでは取得したデータをそれぞれquestionsオブジェクト, answersオブジェクトに分けています。
つまり以下のようなデータを
{ "a1" : "", "a2" : "", "a3" : "", "a4" : "", "a5" : "", "q1" : "「それ」を一言で表すと?", "q2" : "「それ」はどんな問題をどう解決した?", "q3" : "「それ」の代わりとなるものは?", "q4" : "「それ」に関連するキーワードは?", "q5" : "「それ」の目次を作るとしたら?" }
questions = { q1: "「それ」を一言で表すと?", q2: "「それ」はどんな問題をどう解決した?", q3: "「それ」の代わりとなるものは?", q4: "「それ」に関連するキーワードは?", q5: "「それ」の目次を作るとしたら?" }; answers = { a1: "", a2: "", a3: "", a4: "", a5: "" };
このように変換しています。
またactionTypes.jsにGET_SUBJECTを追加しておいて下さい。
export const GET_SUBJECT = "GET_SUBJECT";
Reducer
action.payloadのデータはSubjectのIDとData、そしてQuestionsとAnswers(がまとめられたオブジェクト)です。
これらをそれぞれstateのsubject, questions, answersに入れます。
reducers > subjects.jsに以下のcase文を追加します。
case ACTION_TYPES.GET_SUBJECT: return { ...state, subject: { ...action.payload.data, id: action.payload.id }, questions: action.payload.questionAnswers.questions, answers: action.payload.questionAnswers.answers };
これでQ&Aも含めた詳細画面の表示は実装できました。
確認してみます。
このように表示されればOKです。
Answerの初期データが空の文字列なので表示されませんが、Questionはきちんと表示されています。
Firebaseコンソールから直接DBに値を入れればAnswerも表示されることが確認できます。
これでCRUDのうちCRまで出来ました。次はUpdateを実装します。
===
次の記事は明日中に投稿予定です。
続きはこちらから↓
React + Firebaseのミニアプリを作る【実装編4】
前回に続きReact+Firebaseでミニアプリを作っていきます。前回の投稿はこちらから。 どんなアプリを作るかは【計画編】を見て下さい。 前回まででCreate, Readまでは実装 ...
続きを見る