8
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-10-10

概要

主に下記3つの事例でをメモ帳書き捨て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

手順

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

作成・編集したファイル&フォルダ構成

下記のファイルとroutes.rbを編集しました。(細かいこと言えばyarn.lockも。)
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で表示するまで」を参照

material-uiのインストール

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

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

エントリポイントの設定

ルーティング

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で指定します。アイコンはこちらからえらべます。
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)

参考

8
13
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
8
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?