概要
主に下記3つの事例でをメモ帳書き捨てSPAの作成を通して紹介します
- railsでreactをどうやって乗っけるかの事例
- railsでmaterial-ui(ver. 1 beta)を使う事例
- materia-uiのパーツを使うだけでなく、テーマとスタイルも適用したアプリを作成した事例
環境
- rails 5.1.4
- material-ui v1.0.0-beta.16
アプリ説明
- メモを書き捨てるアプリです
- 保存はstateなので、ブラウザ閉じたらメモは消えます。
動作動画
手順
フォルダ構成を紹介して、rails環境の構築(webpacker, react, material-uiインストール)を行って、react SPAの実装を説明して行きます。
作成・編集したファイル&フォルダ構成
下記のファイルとroutes.rbを編集しました。(細かいこと言えばyarn.lockも。)
javascriptファイル群抜粋
- assets
- style.js
- theme.js
- containers
- common
- Header.jsx
- draft
- DraftApp.jsx
- DraftBody.jsx
- DraftBottomNavigation.jsx
- DraftForm.jsx
- DraftList.jsx
- common
- 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)