LoginSignup
5
4

More than 3 years have passed since last update.

material-uiでRedux-Formのフォーム画面と確認ダイアログとスナックバーを作ってみた

Last updated at Posted at 2020-06-09

はじめに

React+Reduxアプリケーションにプロフィール編集フォームみたいなものを実装する機会があったのですが、Redux-Formで作って自作でスタイル調整していたが、自身のデザインセンスが微塵もないので、クソみたいな見た目になっていました。

そこでmaterial-uiを適応してみたら、ものすごくアプリっぽいまともな感じになったので、共有したいと思いましたのでまとめてみます。また、保存前に確認するダイアログと保存成功を表示するスナックバーも実装してみたので、こちらも併せて紹介します!!

使ったもの

"@material-ui/core": "^4.10.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.55",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.1",
"redux": "^4.0.5",
"redux-form": "^8.3.6",
"redux-thunk": "^2.3.0"

作ったもの

デモはこちら

プロフィール編集フォーム

2020-06-07_23h02_45.png

保存確認ダイアログ

2020-06-07_23h03_47.png

保存成功を表示するスナックバー
2020-06-07_23h03_56.png

プロジェクト作成

create-react-appでReactのプロジェクトを作成して、上記のとおり必要なパッケージをインストールします。(react以外)

create-react-app app
npm install --save 

src/index.js

まずReact+Reduxの基本となる部分を実装します。
redux-thunkを使用しています。

src/index.js
import React from "react";
import ReactDOM from "react-dom";
import { createStore, applyMiddleware } from "redux";
import { Provider } from "react-redux";
import thunk from "redux-thunk";
import reducer from "./reducers";

import App from "./App";

const enhancer = applyMiddleware(thunk);

const store = createStore(reducer, enhancer);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

src/actions/index.js

アクションクリエーターを実装します。
実際はAPIで通信してデータのやりとりをしますが、ここでは割愛します。

getSelectDataではセレクトボックスの選択肢用の役職のデータをデータベースからもってきます。
changeProfileは保存するデータを送信する処理です。

src/actions/index.js
export const GET_SELECT_DATA = "GET_SELECT_DATA";
export const CHANGE_PROFILE = "CHANGE_PROFILE";

export const getSelectData = () => (dispatch) => {
  // 本当はAPIで通信してデータを持ってくるが、割愛して適当に配列のデータを返す
  const response = {
    data: ["一般社員", "係長", "課長", "部長", "役員", "社長"],
  };
  dispatch({ type: GET_SELECT_DATA, response });
};

export const changeProfile = (values) => (dispatch) => {
  // 本当はAPIで通信して保存するが、割愛して成功を返す
  const response = {
    data: "success",
  };
  dispatch({ type: CHANGE_PROFILE, response });
};

src/reducers/select.js

セレクトボックスの選択肢用のデータを返すreducerを実装。

src/reducers/index.js
import { GET_SELECT_DATA } from "../actions";

export const select = (events = {}, action) => {
  switch (action.type) {
    case GET_SELECT_DATA:
      return action.response.data;
    default:
      return events;
  }
};

src/reducers/change.js

保存処理の成否を返すreducerを実装。

src/reducers/change.js
import { CHANGE_PROFILE } from "../actions";

export const change = (events = {}, action) => {
  switch (action.type) {
    case CHANGE_PROFILE:
      return action.response.data;
    default:
      return events;
  }
};

src/reducers/index.js

combineReducersで分割していたreducerを結合する。
redux-formもここで結合。

src/reducers/index.js
import { combineReducers } from "redux";
import { reducer as form } from "redux-form";
import { select } from "./select";
import { change } from "./change";

export default combineReducers({
  select,
  change,
  form,
});

src/components/renderfield.js

テキストフィールドとセレクトボックスの部分をコンポーネント化しておく。

src/components/renderfield.js
import React from "react";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";

export const RenderField = (props) => {
  const {
    input,
    label,
    type,
    disabled,
    meta: { touched, error },
  } = props;

  let isError = false;
  if (touched) {
    isError = error === undefined ? false : true;
  }

  return (
    <TextField
      {...input}
      type={type}
      error={isError}
      helperText={touched && error}
      variant="outlined"
      required
      fullWidth
      disabled={disabled}
      label={label}
    />
  );
};

export const RenderSelect = (props) => {
  const {
    input: { value, onChange },
    onFieldChange,
    children,
    label,
  } = props;

  return (
    <Select
      required
      fullWidth
      variant="outlined"
      value={value}
      label={label}
      onChange={(e) => {
        onChange(e.target.value);
        onFieldChange && onFieldChange(e.target.value);
      }}
    >
      {children}
    </Select>
  );
};

src/components/alert.js

確認ダイアログの部分をコンポーネント化しておく。
propsで送信するデータや各種関数を受け取っておく。

src/components/alert.js
import React from "react";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import Button from "@material-ui/core/Button";

export const RenderConfirm = (props) => {
  const values = {
    name: props.name,
    email: props.email,
    kaisha: props.kaisha,
    busho: props.busho,
    yakushoku: props.yakushoku,
  };

  return (
    <Dialog
      open={props.isConfirmOpen}
      onClose={props.handleClose}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogContent>
        <DialogContentText id="alert-dialog-description">
          {props.message}
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button
          onClick={() => props.handleSubmit(values)}
          color="primary"
          autoFocus
        >
          {props.okMessage}
        </Button>
        <Button onClick={props.handleClose}>戻る</Button>
      </DialogActions>
    </Dialog>
  );
};

src/App.js

import部分

必要なパッケージとコンポーネントとアクションをインポート

src/App.js
import React, { Component } from "react";
import { connect } from "react-redux";
import { Field, reduxForm } from "redux-form";
import Button from "@material-ui/core/Button";
import Avatar from "@material-ui/core/Avatar";
import InputLabel from "@material-ui/core/InputLabel";
import FormControl from "@material-ui/core/FormControl";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import Snackbar from "@material-ui/core/Snackbar";
import { withStyles } from "@material-ui/core/styles";
import Alert from "@material-ui/lab/Alert";
import MenuItem from "@material-ui/core/MenuItem";

import { RenderConfirm } from "./components/alert";
import { RenderField, RenderSelect } from "./components/renderfield";
import { getSelectData, changeProfile } from "./actions";
import "./style.css";

material-uiのスタイルカスタマイズ部分

上部のアバターとフォームコントロールに以下スタイル適応しています。

src/App.js
const styles = (theme) => ({
  avatar: {
    margin: "0 auto",
    backgroundColor: theme.palette.primary.main,
  },
  formControl: {
    margin: "12px 0",
    width: "100%",
  },
});

Profileクラスコンポーネントのコンストラクタ部分

Redux使ってますが、一部state使ってます。

src/App.js
class Profile extends Component {
  constructor(props) {
    super(props);
    this.handleConfirmOpen = this.handleConfirmOpen.bind(this);
    this.handleConfirmClose = this.handleConfirmClose.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);

    this.state = {
      isConfirmOpen: false,
      isAlertOpen: false,
      message: "",
      isSuccess: false,

      name: "",
      email: "",
      kaisha: "",
      busho: "",
      yakushoku: "",
    };
  }
・・・
}

マウント時

セレクトボックスの選択肢データを取得しています。

src/App.js
  componentDidMount() {
    this.props.getSelectData();
  }

確認ダイアログの開閉処理

Open時にstateにフォームの値を格納しています。

src/App.js
  handleConfirmOpen = (values) => {
    this.setState({
      isConfirmOpen: true,
      name: values.name,
      email: values.email,
      kaisha: values.kaisha,
      busho: values.busho,
      yakushoku: values.yakushoku,
    });
  };
  handleConfirmClose = () => {
    this.setState({ isConfirmOpen: false });
  };

データ送信処理

データを送信して保存する処理。
返ってきた値で成否を分けてスナックバーのメッセージと色を変更している。
今回はAPIを実装していないので、成功しか返ってきません。

src/App.js
async handleSubmit(values) {
    await this.props.changeProfile(values);

    if (this.props.changes === "success") {
      this.setState({
        message: "プロフィール変更を保存しました!",
        isSuccess: true,
      });
      this.handleAlertOpen();
      this.handleConfirmClose();
    } else {
      this.setState({
        message: "プロフィール変更に失敗しました!",
        isSuccess: false,
      });
      this.handleAlertOpen();
      this.handleConfirmClose();
    }
  }

スナックバー開閉処理

src/App.js
  handleAlertOpen = () => {
    this.setState({ isAlertOpen: true });
  };
  handleAlertClose = () => {
    this.setState({ isAlertOpen: false });
  };

render部分

src/App.js
  render() {
    const { handleSubmit, pristine, submitting, invalid, classes } = this.props;
    const severity = this.state.isSuccess ? "success" : "error";
    const dataset = this.props.select;

    return (
      <div>
        <div className="form">
          <Avatar className={classes.avatar}>
            <AccountCircleIcon />
          </Avatar>
          <p className="form__title">プロフィール変更</p>

          <form onSubmit={handleSubmit(this.handleConfirmOpen)}>
            <FormControl className={classes.formControl}>
              <Field
                label="名前"
                name="name"
                type="text"
                component={RenderField}
              />
            </FormControl>
            <FormControl className={classes.formControl}>
              <Field
                label="メールアドレス"
                name="email"
                type="email"
                component={RenderField}
              />
            </FormControl>

            <FormControl className={classes.formControl}>
              <Field
                label="会社名"
                name="kaisha"
                type="text"
                component={RenderField}
              />
            </FormControl>

            <FormControl className={classes.formControl}>
              <Field
                label="部署名"
                name="busho"
                type="text"
                component={RenderField}
              />
            </FormControl>

            <FormControl variant="outlined" className={classes.formControl}>
              <InputLabel id="demo-simple-select-outlined-label">
                役職
              </InputLabel>
              <Field label="役職" name="yakushoku" component={RenderSelect}>
                {Array.isArray(dataset) &&
                  dataset.map((data, index) => (
                    <MenuItem key={index} value={data}>
                      {data}
                    </MenuItem>
                  ))}
              </Field>
            </FormControl>

            <Button
              type="submit"
              variant="contained"
              color="primary"
              disabled={pristine || submitting || invalid}
              fullWidth
            >
              保存
            </Button>

            <RenderConfirm
              isConfirmOpen={this.state.isConfirmOpen}
              handleClose={this.handleConfirmClose}
              handleSubmit={this.handleSubmit}
              name={this.state.name}
              email={this.state.email}
              kaisha={this.state.kaisha}
              busho={this.state.busho}
              yakushoku={this.state.yakushoku}
              message="プロフィール変更を保存しますか?"
              okMessage="保存"
            />
          </form>
        </div>
        <Snackbar
          open={this.state.isAlertOpen}
          autoHideDuration={3000}
          onClose={this.handleAlertClose}
          anchorOrigin={{ vertical: "top", horizontal: "center" }}
        >
          <Alert onClose={this.handleAlertClose} severity={severity}>
            {this.state.message}
          </Alert>
        </Snackbar>
      </div>
    );
  }

const { handleSubmit, pristine, submitting, invalid, classes } = this.props;の部分で必要なpropsを格納しています。

上部のアイコンもmaterial-uiのアバターとアイコンを使用しています。

src/App.js
<Avatar className={classes.avatar}>
  <AccountCircleIcon />
</Avatar>

テキストフィールド部分は上で作成したコンポーネントを使用しています。

src/App.js
<FormControl className={classes.formControl}>
  <Field
    label="名前"
    name="name"
    type="text"
    component={RenderField}
  />
</FormControl>

セレクトボックス部分も上で作成したコンポーネントを使用しています。
マウント時に取得した役職データをmapでループしてアイテムを追加する。

src/App.js
<FormControl variant="outlined" className={classes.formControl}>
  <InputLabel id="demo-simple-select-outlined-label">
    役職
  </InputLabel>
  <Field label="役職" name="yakushoku" component={RenderSelect}>
    {Array.isArray(dataset) &&
      dataset.map((data, index) => (
        <MenuItem key={index} value={data}>
          {data}
        </MenuItem>
      ))}
  </Field>
</FormControl>

ボタン部分のdisabledにはredux-formの機能を使用しています。

src/App.js
<Button
  type="submit"
  variant="contained"
  color="primary"
  disabled={pristine || submitting || invalid}
  fullWidth
>
  保存
</Button>

スナックバー部分はthis.state.messageをpropsで受け渡してメッセージを表示しています。
また、成否はconst severity = this.state.isSuccess ? "success" : "error";をレンダーの最初に実行してseverityをそのまま受け渡しています。

src/App.js
<Snackbar
  open={this.state.isAlertOpen}
  autoHideDuration={3000}
  onClose={this.handleAlertClose}
  anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
  <Alert onClose={this.handleAlertClose} severity={severity}>
    {this.state.message}
  </Alert>
</Snackbar>

validateやreduxのコネクト部分

initialValuesでセレクトボックスの初期選択を指定しています。
コネクトするときにenableReinitialize: true,が必要になります。

initialValuesで値を設定しないと、セレクトボックスで見た目は選択されている(1番目)ように見えますが、そのまま送信すると値が設定されていないことになるので、設定が必要です。

src/App.js
const validate = (values) => {
  const errors = {};

  if (!values.name) errors.name = "名前を入力してください。";
  if (!values.email) errors.email = "メールアドレスを入力してください。";
  if (!values.kaisha) errors.kaisha = "会社名を入力してください。";
  if (!values.busho) errors.busho = "部署名を入力してください。";
  if (!values.yakshoku) errors.yakshoku = "役職を選択してください。";

  return errors;
};

const mapStateToProps = (state) => {
  return {
    select: state.select,
    changes: state.change,
    initialValues: {
      yakushoku: state.select[0],
    },
  };
};

const mapDispatchToProps = { getSelectData, changeProfile };

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(
  reduxForm({
    validate,
    enableReinitialize: true,
    form: "form",
  })(withStyles(styles, { withTheme: true })(Profile))
);

まとめ

redux-formを使用しつつ、stateも一部使用しているので無理やり感が結構ありますが、とりあえずやりたいことはこれで実装できました。
material-uiを使うだけで、デザインセンスのない人(自分)でもちゃんとしたアプリっぽい見た目になってくれるので、Reactアプリには今後ほぼmaterial-uiを使ってしまいそうです。

間違っていたり、もっといい方法とかありましたら、コメントにお願いします!!

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4