rails+material-uiでメモ帳書き捨てSPA作る


# 概要
主に下記３つの事例でをメモ帳書き捨てSPAの作成を通して紹介します

* railsでreactをどうやって乗っけるかの事例
* railsでmaterial-ui（ver. 1 beta）を使う事例
* materia-uiのパーツを使うだけでなく、テーマとスタイルも適用したアプリを作成した事例

# 環境
* rails 5.1.4
* material-ui v1.0.0-beta.16

# アプリ説明
* メモを書き捨てるアプリです
	* 保存はstateなので、ブラウザ閉じたらメモは消えます。

## 動作動画
[![https://gyazo.com/d8e16951ecbd8bf0ee6482911a71341e](https://i.gyazo.com/d8e16951ecbd8bf0ee6482911a71341e.gif)](https://gyazo.com/d8e16951ecbd8bf0ee6482911a71341e)

# 手順
フォルダ構成を紹介して、rails環境の構築（webpacker, react, material-uiインストール）を行って、react SPAの実装を説明して行きます。

## 作成・編集したファイル＆フォルダ構成
下記のファイルとroutes.rbを編集しました。（細かいこと言えばyarn.lockも。）
[![https://gyazo.com/6a0df4eb11e17b667a95356f84d3571c](https://i.gyazo.com/6a0df4eb11e17b667a95356f84d3571c.png)](https://gyazo.com/6a0df4eb11e17b667a95356f84d3571c)


### javascriptファイル群抜粋
* assets
	* style.js
	* theme.js
* containers
	* common
		* Header.jsx
	* draft
		* DraftApp.jsx
		* DraftBody.jsx
		* DraftBottomNavigation.jsx
		* DraftForm.jsx
		* DraftList.jsx
* modules
	* Common.jsx
* packs
	* draft.jsx

## railsでwebpacker, reactの用意
[拙記事「rails 5.1.1 をvueとreactをインストールしてAWS Elastic Beanstalkで表示するまで」を参照](https://qiita.com/junara/items/ae09c94da5223c9c3aff)

## material-uiのインストール
material-uiとアイコンをインストール
material-uiは現在betaなのでmaterial-ui@nextでインストールする

```console
yarn add material-ui@next  
yarn add material-ui-icons
```

## エントリポイントの設定
### ルーティング
* ルート（http://localhost:3000/）からアクセスできるようにする

```config/routes.rb
Rails.application.routes.draw do
  root to: 'drafts#home'
end
```
### コントローラを用意。データ不要なので今回は空。

```app/controllers/drafts_controller.rb
class DraftsController < ApplicationController
  def home
  end
end
```
### viewにreactのアプリを乗っける場所のid（draftIndexRoot）を用意

```app/views/drafts/home.html.erb
<%= javascript_pack_tag 'draft' %>
<div id="draftIndexRoot"></div>
```

### viewにのっけるアプリを用意

```app/javascript/packs/draft.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import DraftApp from '../containers/draft/DraftApp'


document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <DraftApp/>
    ,
    document.getElementById('draftIndexRoot'),
  )
});
```

## テーマとスタイルの設定
material-uiはアプリ全体の見た目を調整する「テーマ」とテーマでは調整しきれない個別の見た目を調整する「スタイル」という概念があります。これらを利用できるように用意します。

### テーマ用

```app/javascript/assets/theme.js
import {createMuiTheme} from 'material-ui/styles';
import blue from 'material-ui/colors/blue';

// material-uiのblueをベースにprimary colorをブランドの色にする
const theme = createMuiTheme({
  palette: {
    primary: {
      ...blue,
      500: 'rgb(65, 139, 182)'
    },
  },
});

export default theme
```

### スタイル用。

```app/javascript/assets/style.js
export const commonStyle = {
  dFlex: {
    display: 'flex',
  },
  positionBottomFixed: {
    position: 'fixed',
    bottom: '0',
    width: '100%'
  },
  justifyContentCenter: {
    justifyContent: 'center'
  },
  justifyContentSpaceBetween: {
    justifyContent: 'space-between'
  }
};

export const headerStyle = (theme) => ({
  ...commonStyle
});

export const bottomStyle = (theme) => ({
  ...commonStyle,
});
```

## ルートの作成
* 今回のアプリで使うデータやメソッドを持つルートを作ります
* reactでは、statelessが理想とされています。これに従い、アプリのstateを全てこのルートに集約しました。
* 下書き完了時のコメントを下部にピッカーで表示するようにSnackbarを使っています。

```app/javascript/containers/draft/DraftApp.jsx
import React from 'react'
import Header from '../common/Header'
import DraftBody from './DraftBody'
import DraftBottomNavigation from './DraftBottomNavigation'
import {MuiThemeProvider} from 'material-ui/styles';
import theme from '../../assets/theme'
import Snackbar from 'material-ui/Snackbar';
import {loginApi, logoutApi, signUpApi} from '../../modules/Api';


const initialDraftState = {
  draftTitle: '',
  draftMemo: ''
};

const initialBottomNavigationValue = {
  bottomNavigationValue: 0
};

const initialSnackbarState = {
  snackbarOpen: false,
  snackbarMessage: '',
};

class DraftApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      bottomNavigationValue: 0,
      drafts: [],
      id: 0,
      ...initialDraftState,
      ...initialSnackbarState,
      listDialogOpen: false,
      selectedDraftId: 0,
      user: null,
    }
  }

  handleChange = name => (event, value) => {
    this.setState({
      [name]: value
    });
  };

  handleDialog = ({name, open, stateToBeChanged}) => (event) => {
    event.preventDefault();
    this.setState({
      [name]: open,
      ...stateToBeChanged
    });
  };

  handleFormChange = name => (event) => {
    this.setState({
      [name]: event.target.value
    });
  };

  handleSubmit = (event) => {
    event.preventDefault();
    const id = this.state.id + 1;
    const draft = {
      id: id,
      title: this.state.draftTitle,
      memo: this.state.draftMemo,
      createdAt: this.getCreatedAt(new Date())
    };
    const drafts = this.state.drafts.concat(draft);
    this.setState({
      id: id,
      drafts: drafts,
      ...initialDraftState,
      ...initialBottomNavigationValue,
    });
    this.handleSnackbar({message: '保存しました'});
  };

  getCreatedAt = (date) => {
    return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日${date.getHours()}時${date.getMinutes()}分${date.getSeconds()}秒`;
  };

  handleSnackbar = ({message}) => {
    this.setState({
      snackbarOpen: true,
      snackbarMessage: message
    })
  };

  handleSnackbarClose = () => (event) => {
    event.preventDefault();
    this.setState({
      ...initialSnackbarState
    })
  };

  render() {
    return (
      <MuiThemeProvider theme={theme}>
        <div>
          <Header
            handleDialog={this.handleDialog}
            handleFormChange={this.handleFormChange}
            {...this.state}
          />
          <DraftBody
            {...this.state}
            handleFormChange={this.handleFormChange}
            handleDialog={this.handleDialog}
            handleChange={this.handleChange}
          />
          <DraftBottomNavigation
            bottomNavigationValue={this.state.bottomNavigationValue}
            handleChange={this.handleChange}
          />
          <Snackbar
            open={this.state.snackbarOpen}
            onRequestClose={this.handleSnackbarClose()}
            message={this.state.snackbarMessage}
          />
        </div>
      </MuiThemeProvider>
    )
  }
}

export default DraftApp
```

## ヘッダーの作成
* アプリのタイトルを表示します。
* 将来的に、ログインなどの操作に使えるように、右端にアイコンボタンを表示しています。（現在は動作していません）
* アイコンはmaterial-ui-iconsで指定します。アイコンは[こちら](https://material.io/icons/)からえらべます。

```app/javascript/containers/common/Header.jsx
import React from 'react';
import AppBar from 'material-ui/AppBar';
import Toolbar from 'material-ui/Toolbar';
import Typography from 'material-ui/Typography';
import UserButton from '../common/UserButton'
import {withStyles} from 'material-ui/styles';
import {headerStyle} from '../../assets/style'

function Header(props,
                handleFormChange,
                handleDialog) {
  const {classes} = props;
  return (
    <div>
      <AppBar position="static">
        <Toolbar className={`${classes.dFlex} ${classes.justifyContentSpaceBetween}`}>
          <Typography type="title" color='inherit'>
            メモ帳
          </Typography>
          <UserButton
            handleDialog={handleDialog}
            handleFormChange={handleFormChange}
            {...props}
          />
        </Toolbar>
      </AppBar>
    </div>
  );
}

export default withStyles(headerStyle)(Header)
```

## ボトムナビゲーションの作成
* ページ下部に表示する操作用のナビゲーションです。


```app/javascript/containers/draft/DraftBottomNavigation.jsx
import React from 'react';
import {withStyles} from 'material-ui/styles';
import {bottomStyle} from '../../assets/style';
import BottomNavigation, {BottomNavigationButton} from 'material-ui/BottomNavigation';
import RestoreIcon from 'material-ui-icons/Restore';
import FavoriteIcon from 'material-ui-icons/Favorite';

class DraftBottomNavigation extends React.Component {
  render() {
    let name = 'bottomNavigationValue'
    const {classes} = this.props;
    return (
      <div className={classes.positionBottomFixed}>
        <BottomNavigation
          value={ this.props[name]}
          onChange={this.props.handleChange(name)}
          showLabels
        >
          <BottomNavigationButton label="リスト" icon={<RestoreIcon/>}/>
          <BottomNavigationButton label="新規追加" icon={<FavoriteIcon/>}/>
        </BottomNavigation>
      </div>

    );
  }
}

export default withStyles(bottomStyle)(DraftBottomNavigation)
```

## 下書きページとメモリストページの分岐の作成
* ボトムナビゲーションの値（bottomNavigationValue）に従って下書きページとメモリストページの表示を分岐します。

```app/javascript/containers/draft/DraftBody.jsx
import React from 'react';
import DraftForm from './DraftForm';
import DraftList from './DraftList';

class DraftBody extends React.Component {
  render() {
    const content = () => {
      switch (this.props.bottomNavigationValue) {
        case 0:
          return (
            <DraftList
              {...this.props}
              handleDialog={this.props.handleDialog}
              handleListDialogClose={this.props.handleListDialogClose}
              handleChange={this.props.handleChange}
            />
          );
          break;
        case 1:
          return (
            <DraftForm
              {...this.props}
              handleFormChange={this.props.handleFormChange}
              handleSubmit={this.props.handleSubmit}
            />
          );
          break;
      }
    };
    return (
      <div>
        {content()}
      </div>
    )
  }
}

export default DraftBody;
```

## 下書きページの作成
フォームの内容はリアルタイムでdraftTitleとdrafMemoに反映させて、「保存する」ボタンでstate.draftsに格納します。 

```app/javascript/containers/draft/DraftForm.jsx
import React from 'react';
import Typography from 'material-ui/Typography';
import TextField from 'material-ui/TextField';
import Button from 'material-ui/Button';

class DraftForm extends React.Component {
  render() {
    return (
      <div>
        <Typography type="display1">下書き</Typography>
        <TextField
          fullWidth
          label="タイトル"
          value={this.props.draftTitle}
          helperText="改行不可"
          onChange={this.props.handleFormChange('draftTitle')}
        />
        <TextField
          fullWidth
          multiline
          rowsMax="4"
          label="メモ"
          value={this.props.draftMemo}
          helperText="改行可"
          onChange={this.props.handleFormChange('draftMemo')}
        />
        <Button raised color='primary' onClick={this.props.handleSubmit}>保存する</Button>

      </div>
    );
  }
}

export default DraftForm
```

## メモリストのページ
* 下書きから保存されたメモのリスト表示です
* リストはボタンになっており、押すとダイアログでメモの内容を表示します

```app/javascript/containers/draft/DraftList.jsx
import React from 'react';
import {withStyles} from 'material-ui/styles';
import List, {ListItem, ListItemText} from 'material-ui/List';
import Avatar from 'material-ui/Avatar';
import FileIcon from 'material-ui-icons/AttachFile';
import Typography from 'material-ui/Typography';
import Dialog, {
  DialogContent,
  DialogTitle,
  DialogContentText,
} from 'material-ui/Dialog';
import {display_formatted_text} from '../../modules/Common'

const styles = theme => ({
  root: {
    overflow: 'auto',
  }
});

const initialSelectedDraft = {
  id: 0,
  title: '',
  memo: '',
  createdAt: ''
};

class DraftList extends React.Component {
  selectedDraft = () => {
    const draft = this.props.drafts.filter(function (draft, index) {
      if (draft.id === this.props.selectedDraftId) return true;
    }.bind(this))[0];
    if (draft) {
      return (draft);
    } else {
      return (initialSelectedDraft)
    }
  };

  render() {
    const listItems = this.props.drafts.map(function (draft) {
        return (
          <ListItem key={draft.id}
                    button
                    onClick={this.props.handleDialog({
                      name: 'listDialogOpen',
                      open: true,
                      stateToBeChanged: {
                        selectedDraftId: draft.id
                      }
                    })} value={draft.id}>
            <Avatar>
              <FileIcon/>
            </Avatar>
            <ListItemText primary={draft.title} secondary={draft.createdAt}/>
          </ListItem>
        )
      }.bind(this)
    );

    return (
      <div>
        <Typography type="display1">メモリスト</Typography>
        <Dialog onRequestClose={this.props.handleDialog({
          name: 'listDialogOpen',
          open: false,
          stateToBeChanged: {
            selectedDraftId: ''
          }
        })}
                open={this.props.listDialogOpen}>
          <DialogTitle>
            {this.selectedDraft().title}
          </DialogTitle>
          <DialogContent>
            <DialogContentText>
              {display_formatted_text(this.selectedDraft().memo)}
            </DialogContentText>
          </DialogContent>
        </Dialog>
        <List className={this.props.classes.root}>
          {listItems}
        </List>

      </div>
    )
  }
}

export default withStyles(styles)(DraftList)
```

# 参考
* [Material-UI(α版)でCSS in JS！](https://qiita.com/vimyum/items/f89b963b7f253237e482) 
* [Material-UI@1.0.0-beta.13](https://qiita.com/sakana/items/44ccc364bc3fad5ae6de)
* [RyukyuInteractive × Tech Blog Material-UI - Style](https://tech.ryukyu-i.co.jp/2017/05/11/mateirla-ui-style/)
* [React Components that Implement Google's Material Design.](https://github.com/callemall/material-ui)
* [Material-UI](https://material-ui-next.com/getting-started/installation/)