続：React + FirestoreでCRUD


以前も同じ記事を書いたのですが、人の記事をなぞっただけなので自分で書いてみました。
さらに、実案件を想定して、ファイルアップロードやカレンダーも含めたCRUDを試してみました。

## 追記

ここの内容はCRUDとしては少々Formが複雑すぎるので、[簡単版](https://qiita.com/zaburo/items/353524e4f54671c8eace)を作成しました。そちらもご利用ください。

## 仕様・完成イメージ

CRUDといいつつ、Editページに詳細表示機能を兼ねさせたり削除機能を追加することにより、以下の３ページを作成することでことは足ります。

* Create（新規登録）
* Index（一覧）
* Edit（編集＋詳細画面を兼ねる＋削除機能追加）
* Show（いらないけど参考実装）


![スクリーンショット 2019-12-03 11.04.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55188/3a268582-d0c9-6994-fca2-30aec7250ea0.png "スクリーンショット 2019-12-03 11.04.50.png")


完成版のgithubのリポジトリは[こちら](https://github.com/eizaburo/react-firebase-crud)。



## 実装の手順

1. データがないと始まらないのでまずCreateを実装します。
2. 次に一覧を実装します。
3. 最後にEditを実装します。

## 準備

### 作業場所作成とモジュールのインストール

作業場所を作成し、必要なモジュールをインストールします。

```bash
create-react-app crud
cd crud

yarn add bootstrap firebase formik moment react-datepicker react-firebase-file-uploader react-router-dom reactstrap yup
```

### 必要なファイルの作成

必要なファイルを生成しておきます。ファイルはsrc以下に作成します。

```bash
mkdir src/screens
touch src/screens/Create.js
touch src/screens/Index.js
touch src/screens/Edit.js
touch src/screens/Show.js

touch src/Firebase.js
```

### bootstrapのcssを提供する

```diff:index.js
import React from 'react';
import ReactDOM from 'react-dom';
+import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));
```


## 実装

まずはApp.jsでルーティングの設定を行います。
各コンポーネントが無いとエラーがでるので、作成の順番に注意してください。

### App.js

```js:App.js
import React from 'react';
import './App.css';

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

//screens
import Index from './screens/Index';
import Create from './screens/Create';
import Show from './screens/Show';
import Edit from './screens/Edit';

class App extends React.Component {
    render() {
        return (
            <Router>
                <Switch>
                    <Route exact path="/" component={Index} />
                    <Route exact path="/create" component={Create} />
                    <Route exact path="/show/:uid" component={Show} />
                    <Route exact path="/edit/:uid" component={Edit} />
                    <Route render={() => <div>Page not found.</div>} />
                </Switch>
            </Router>
        );
    }
}

export default App;
```

### Create.js

ちょっと複雑にはなりますが、ここでは実践で利用する各種Form要素を利用してみたいと思います。
主な要素は

* 普通のInput
* Select
* Radioボタン
* カレンダー
* ファイルアップロード
* チェックボックス

あたりです。

```js:Create.js
import React from 'react';
import { Form, FormGroup, FormFeedback, Label, Input, Button, Spinner } from 'reactstrap';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { Link } from 'react-router-dom';

import firebase, { db } from '../Firebase';

import FileUploader from "react-firebase-file-uploader";

import moment from 'moment';

//react-datepicker
import DatePicker, { registerLocale } from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
//for locale ja
import ja from 'date-fns/locale/ja';
registerLocale('ja', ja);

class Create extends React.Component {

    state = {
        avatarUrl: '',
        avator: '',
        isUploading: false,
        progress: 0,
        spinnerHidden: true,
    }

    handleOnSubmit = async (values) => {
        // alert(JSON.stringify(values));
        this.setState({ spinnerHidden: false });

        //dbへ書き込み
        const docId = db.collection("members").doc().id;
        await db.collection("members").doc(docId).set({
            docId: docId,
            email: values.email,
            area: values.area,
            gender: values.gender,
            birthday: firebase.firestore.Timestamp.fromDate(new Date(values.birthday)),
            avatarUrl: values.avatarUrl,
            agree: values.agree,
            createdAt: firebase.firestore.FieldValue.serverTimestamp(),
        });

        this.setState({ spinnerHidden: true });
        alert("登録しました。");
    }

    //upload
    handleUploadStart = () => this.setState({ isUploading: true, progress: 0 });
    handleUploadError = error => {
        this.setState({ isUploading: false });
        console.log(error);
    }
    handleUploadSuccess = async filename => {
        await this.setState({ avator: filename, isUploading: false });
        const url = await firebase.storage().ref("images").child(filename).getDownloadURL();
        await this.setState({ avatarUrl: url });
        return url;
    }
    handleProgress = progress => this.setState({ progress: progress });

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">新規登録</h3>
                <div className="text-right my-3 mr-5"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    initialValues={{ email: '', area: '', gender: '', birthday: moment(new Date()).format('YYYY/MM/DD'), avatarUrl: '', agree: '' }}
                    onSubmit={this.handleOnSubmit}
                    validationSchema={Yup.object().shape({
                        email: Yup.string().email().required(),
                        area: Yup.string().oneOf(['関東', '関西']).required(),
                        gender: Yup.string().oneOf(['male', 'female']).required(),
                        avatarUrl: Yup.string().required(),
                        agree: Yup.boolean().oneOf([true]).required(),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched, setFieldValue }) => (
                            <Form className="col-8 mx-auto" onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="email">■Email</Label>
                                    <Input
                                        type="email"
                                        name="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label>■お住いの地域</Label>
                                    <Input
                                        type="select"
                                        name="area"
                                        id="area"
                                        value={values.area}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.area && errors.area)}
                                    >
                                        <option value="選択して下さい">選択して下さい</option>
                                        <option value="関東">関東</option>
                                        <option value="関西">関西</option>
                                    </Input>
                                    <FormFeedback>
                                        {errors.area}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup className="mb-4">
                                    <legend className="col-form-label">■性別</legend>
                                    <FormGroup inline check>
                                        <Label check>
                                            男性：<Input
                                                type="radio"
                                                name="gender"
                                                id="male"
                                                value="male"
                                                onChange={handleChange}
                                            />
                                        </Label>
                                    </FormGroup>
                                    <FormGroup inline check>
                                        <Label check>
                                            女性：<Input
                                                type="radio"
                                                name="gender"
                                                id="female"
                                                value="female"
                                                onChange={handleChange}
                                            />
                                        </Label>
                                    </FormGroup>
                                    <span className="text-danger small">{touched.gender && errors.gender ? errors.gender : null}</span>
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■直近の誕生日</legend>
                                    <DatePicker
                                        locale="ja"
                                        name="birthday"
                                        id="birthday"
                                        value={values.birthday}
                                        dateFormat="yyyy/MM/dd"
                                        customInput={<Input invalid={Boolean(errors.birthday)} />}
                                        onChange={date => setFieldValue("birthday", moment(date).format('YYYY/MM/DD'))}
                                    />
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■プロファイル画像</legend>
                                    <FileUploader
                                        accept="image/*"
                                        name="avatarUrl"
                                        randomizeFilename
                                        storageRef={firebase.storage().ref("images")}
                                        onUploadStart={this.handleUploadStart}
                                        onUploadError={this.handleUploadError}
                                        onUploadSuccess={async (filename) => {
                                            const path = await this.handleUploadSuccess(filename); //ここもawaitにしないとurl取得できない
                                            setFieldValue("avatarUrl", path); //値のセットとエラーの削除
                                        }}
                                        onProgress={this.handleProgress}
                                    />
                                    <span className="text-danger small">{touched.avatarUrl && errors.avatarUrl ? errors.avatarUrl : null}</span>
                                    {this.state.isUploading ? <p>Uploading... {this.state.progress}%</p> : null}
                                    {this.state.avatarUrl ? <img src={this.state.avatarUrl} width="120" alt="" className="my-2"/> : null}
                                </FormGroup>
                                <FormGroup className="my-4">
                                    <legend className="col-form-label">■規約に同意して下さい。</legend>
                                    <FormGroup inline check>
                                        <Input
                                            type="checkbox"
                                            name="agree"
                                            id="agree"
                                            value={values.agree}
                                            onChange={handleChange}
                                        />
                                        <Label for="agree" check>同意する。</Label>
                                        <span className="text-danger small">{touched.agree && errors.agree ? errors.agree : null}</span>
                                    </FormGroup>

                                </FormGroup>
                                <div>
                                    <Button type="submit" color="primary">
                                        <Spinner color="light" size="sm" className="mr-1" hidden={this.state.spinnerHidden} />
                                        登録する
                                        </Button>
                                </div>
                            </Form>
                        )
                    }
                </Formik>
            </div>
        );
    }
}

export default Create;
```

### Index.js

ひとまず一覧表示してEditやShowにリンクするだけです。

```js:Index.js
import React from 'react';
import { Button } from 'reactstrap';
import { Link } from 'react-router-dom';

import firebase, { db } from '../Firebase';

class Index extends React.Component {

    state = {
        listData: [],
    }

    //db変化時のコールバック
    onCollectionUpdate = (querySnapshot) => {
        const docs = querySnapshot.docs.map(doc => doc.data());
        this.setState({ listData: docs });
    }

    componentDidMount = () => {
        //dbの変化を監視（変化が無くても初回は実行される）
        this.unsubscribe = db.collection("members")
            .orderBy('createdAt','desc')
            .onSnapshot(this.onCollectionUpdate);
    }

    componentWillUnmount = () => {
        //subscribe停止
        this.unsubscribe();
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">メンバー一覧</h3>
                <Link to="/create"><Button color="primary" className="m-2">新規登録</Button></Link>
                <table className="table">
                    <thead>
                        <tr>
                            <th>UID</th>
                            <th>Email</th>
                            <th>Avatar</th>
                            <th>詳細</th>
                            <th>編集</th>
                        </tr>
                    </thead>
                    <tbody>
                        {
                            this.state.listData.map(member => (
                                <tr key={member.docId}>
                                    <td>{member.docId}d</td>
                                    <td>{member.email}</td>
                                    <td><img src={member.avatarUrl} height="30" width="30" alt="" /></td>
                                    <td><Link to={`/show/${member.docId}`}><Button size="sm" color="primary">詳細</Button></Link></td>
                                    <td><Link to={`/edit/${member.docId}`}><Button size="sm" color="success">編集</Button></Link></td>
                                </tr>
                            ))
                        }
                    </tbody>
                </table>
            </div>
        );
    }
}

export default Index;
```

### Edit.js

Create.jsの画面をベースに指定したメンバー（ドキュメント）の情報をデフォルト値としてセットし、更新があれば更新することになります。削除ボタンもEditに実装します。

```js:Edit.js
import React from 'react';
import { Form, FormGroup, FormFeedback, Label, Input, Button, Spinner } from 'reactstrap';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { Link } from 'react-router-dom';

import firebase, { db } from '../Firebase';

import FileUploader from "react-firebase-file-uploader";

import moment from 'moment';

//react-datepicker
import DatePicker, { registerLocale } from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
//for locale ja
import ja from 'date-fns/locale/ja';
registerLocale('ja', ja);

class Edit extends React.Component {

    state = {
        avatarUrl: '',
        avator: '',
        isUploading: false,
        progress: 0,
        spinnerHidden: true,
        member: { email: '', area: '', gender: '', birthday: '', avatarUrl: '', agree: false }, //初期値ないとWarning出る
    }

    handleOnSubmit = async (values) => {
        // alert(JSON.stringify(values));
        this.setState({ spinnerHidden: false });

        //dbへ書き込み
        const docId = this.props.match.params.uid;
        await db.collection("members").doc(docId).update({
            email: values.email,
            area: values.area,
            gender: values.gender,
            birthday: firebase.firestore.Timestamp.fromDate(new Date(values.birthday)),
            avatarUrl: values.avatarUrl,
            agree: values.agree,
        });

        this.setState({ spinnerHidden: true });
        alert("更新しました。");
    }

    //upload
    handleUploadStart = () => this.setState({ isUploading: true, progress: 0 });
    handleUploadError = error => {
        this.setState({ isUploading: false });
        console.log(error);
    }
    handleUploadSuccess = async filename => {
        await this.setState({ avator: filename, isUploading: false });
        const url = await firebase.storage().ref("images").child(filename).getDownloadURL();
        await this.setState({ avatarUrl: url });
        return url;
    }
    handleProgress = progress => this.setState({ progress: progress });

    //for Edit
    getMember = async (uid) => {
        const docRef = db.collection("members").doc(uid);
        const doc = await docRef.get();
        if (doc.exists) {
            this.setState({
                member: doc.data(),
            });
        } else {
            alert("メンバーが見つかりませんでした。");
        }
    }

    componentDidMount = () => {
        this.getMember(this.props.match.params.uid);
    }

    deleteMember = async (uid) => {
        if (window.confirm('本当に削除しますか？')) {
            await db.collection("members").doc(uid).delete();
            this.props.history.push("/");
        } else {
            return;
        }
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">情報編集</h3>
                <div className="text-right my-3 mr-5"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    enableReinitialize //これがポイント
                    initialValues={{
                        email: this.state.member.email, //各初期値にdbからの値（このためにenableReinitializeが必要）
                        area: this.state.member.area,
                        gender: this.state.member.gender,
                        birthday: moment(this.state.member.birthday.seconds * 1000).format('YYYY/MM/DD'),
                        avatarUrl: this.state.member.avatarUrl,
                        agree: this.state.member.agree,
                    }}
                    onSubmit={this.handleOnSubmit}
                    validationSchema={Yup.object().shape({
                        email: Yup.string().email().required(),
                        area: Yup.string().oneOf(['関東', '関西']).required(),
                        gender: Yup.string().oneOf(['male', 'female']).required(),
                        avatarUrl: Yup.string().required(),
                        agree: Yup.boolean().oneOf([true]).required(),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched, setFieldValue }) => (
                            <Form className="col-8 mx-auto" onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="email">■Email</Label>
                                    <Input
                                        type="email"
                                        name="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label>■お住いの地域</Label>
                                    <Input
                                        type="select"
                                        name="area"
                                        id="area"
                                        value={values.area}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.area && errors.area)}
                                    >
                                        <option value="選択して下さい">選択して下さい</option>
                                        <option value="関東">関東</option>
                                        <option value="関西">関西</option>
                                    </Input>
                                    <FormFeedback>
                                        {errors.area}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup className="mb-4">
                                    <legend className="col-form-label">■性別</legend>
                                    <FormGroup inline check>
                                        <Label check>
                                            男性：<Input
                                                type="radio"
                                                name="gender"
                                                id="male"
                                                value="male"
                                                onChange={handleChange}
                                                checked={values.gender === "male"} //追加
                                            />
                                        </Label>
                                    </FormGroup>
                                    <FormGroup inline check>
                                        <Label check>
                                            女性：<Input
                                                type="radio"
                                                name="gender"
                                                id="female"
                                                value="female"
                                                onChange={handleChange}
                                                checked={values.gender === "female"} //追加
                                            />
                                        </Label>
                                    </FormGroup>
                                    <span className="text-danger small">{touched.gender && errors.gender ? errors.gender : null}</span>
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■直近の誕生日</legend>
                                    <DatePicker
                                        locale="ja"
                                        name="birthday"
                                        id="birthday"
                                        value={values.birthday}
                                        dateFormat="yyyy/MM/dd"
                                        customInput={<Input invalid={Boolean(errors.birthday)} />}
                                        onChange={date => setFieldValue("birthday", moment(date).format('YYYY/MM/DD'))}
                                    />
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■プロファイル画像</legend>
                                    <FileUploader
                                        accept="image/*"
                                        name="avatarUrl"
                                        randomizeFilename
                                        storageRef={firebase.storage().ref("images")}
                                        onUploadStart={this.handleUploadStart}
                                        onUploadError={this.handleUploadError}
                                        onUploadSuccess={async (filename) => {
                                            const path = await this.handleUploadSuccess(filename); //ここもawaitにしないとurl取得できない
                                            setFieldValue("avatarUrl", path); //値のセットとエラーの削除
                                        }}
                                        onProgress={this.handleProgress}
                                    />
                                    <span className="text-danger small">{touched.avatarUrl && errors.avatarUrl ? errors.avatarUrl : null}</span>
                                    {this.state.isUploading ? <p>Uploading... {this.state.progress}%</p> : null}
                                    {/* sateからvaluesへ参照を切り替え */}
                                    {values.avatarUrl ? <img src={values.avatarUrl} width="120" alt="" className="my-2" /> : null}
                                </FormGroup>
                                <FormGroup className="my-4">
                                    <legend className="col-form-label">■規約に同意して下さい。</legend>
                                    <FormGroup inline check>
                                        <Input
                                            type="checkbox"
                                            name="agree"
                                            id="agree"
                                            value={values.agree}
                                            onChange={handleChange}
                                            checked={values.agree === true}
                                        />
                                        <Label for="agree" check>同意する。</Label>
                                        <span className="text-danger small">{touched.agree && errors.agree ? errors.agree : null}</span>
                                    </FormGroup>

                                </FormGroup>
                                <div>
                                    <Button type="submit" color="success">
                                        <Spinner color="light" size="sm" className="mr-1" hidden={this.state.spinnerHidden} />
                                        更新する
                                        </Button>
                                </div>
                            </Form>
                        )
                    }
                </Formik>
                <div className="col-8 mx-auto my-3">
                    <Button color="danger" onClick={() => this.deleteMember(this.props.match.params.uid)}>データを削除</Button>
                </div>
            </div>
        );
    }
}

export default Edit;
```

### Show.js

Editと表示内容はほぼ同じなので、いらないのですが参考まで。
テキストベースで表示するため型変換等が必要になります。

```js:Show.js
import React from 'react';
import firebase, { db } from '../Firebase';
import { Link } from 'react-router-dom';
import moment from 'moment';


class Show extends React.Component {

    state = {
        member: {}
    }

    getMember = async (uid) => {
        const docRef = db.collection("members").doc(uid);
        const doc = await docRef.get();
        if (doc.exists) {
            this.setState({
                member: doc.data(),
            });
        } else {
            alert("メンバーが見つかりませんでした。");
        }
    }

    componentDidMount = async () => {
        await this.getMember(this.props.match.params.uid);
    }

    render() {

        if(this.state.member.createdAt === undefined){
            return <p>Loading...</p>
        }

        return (
            <div className="container">
                <h3 className="text-center my-5">メンバー詳細</h3>
                <div className="text-right my-3 mr-5"><Link to="/">一覧へ戻る</Link></div>
                <table className="table">
                    <tbody>
                        <tr>
                            <th>UID</th>
                            <td>{this.state.member.docId}</td>
                        </tr>
                        <tr>
                            <th>Email</th>
                            <td>{this.state.member.email}</td>
                        </tr>
                        <tr>
                            <th>居住地域</th>
                            <td>{this.state.member.area}</td>
                        </tr>
                        <tr>
                            <th>Avatar</th>
                            <td><img src={this.state.member.avatarUrl} width="200" alt="" /></td>
                        </tr>
                        <tr>
                            <th>性別</th>
                            <td>{this.state.member.gender}</td>
                        </tr>
                        <tr>
                            <th>生年月日？</th>
                            <td>{moment(this.state.member.birthday.seconds * 1000).format('YYYY/MM/DD')}</td>
                        </tr>
                        <tr>
                            <th>同意</th>
                            <td>{String(this.state.member.agree)}</td>
                        </tr>
                        <tr>
                            <th>登録日時</th>
                            <td>{ moment(this.state.member.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:dd:ss')}</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        );
    }
}

export default Show;
```