以前も同じ記事を書いたのですが、人の記事をなぞっただけなので自分で書いてみました。
さらに、実案件を想定して、ファイルアップロードやカレンダーも含めたCRUDを試してみました。
追記
ここの内容はCRUDとしては少々Formが複雑すぎるので、簡単版を作成しました。そちらもご利用ください。
仕様・完成イメージ
CRUDといいつつ、Editページに詳細表示機能を兼ねさせたり削除機能を追加することにより、以下の3ページを作成することでことは足ります。
- Create(新規登録)
- Index(一覧)
- Edit(編集+詳細画面を兼ねる+削除機能追加)
- Show(いらないけど参考実装)
完成版のgithubのリポジトリはこちら。
実装の手順
- データがないと始まらないのでまずCreateを実装します。
- 次に一覧を実装します。
- 最後にEditを実装します。
準備
作業場所作成とモジュールのインストール
作業場所を作成し、必要なモジュールをインストールします。
create-react-app crud
cd crud
yarn add bootstrap firebase formik moment react-datepicker react-firebase-file-uploader react-router-dom reactstrap yup
必要なファイルの作成
必要なファイルを生成しておきます。ファイルはsrc以下に作成します。
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を提供する
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
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ボタン
- カレンダー
- ファイルアップロード
- チェックボックス
あたりです。
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にリンクするだけです。
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に実装します。
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と表示内容はほぼ同じなので、いらないのですが参考まで。
テキストベースで表示するため型変換等が必要になります。
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;