20200113のReactに関する記事は11件です。

Material UIのヘッダー・フッターナビゲーションの実装【初学者のReact×Railsアプリ開発 第9回】

やったこと

  • Material UIを用いて、ヘッダー・フッターナビゲーションを実装した

成果物

画面の横幅が大きいとき

スクリーンショット 2020-01-13 22.30.23.png

画面の横幅が小さいとき

スクリーンショット 2020-01-13 22.29.47.png

参考にさせていただいたサイト

実装手順

  • 上の記事のコードを参考に改造しただけなので、今回はそんなに工夫点はないです。
  • 上の参考記事をご覧ください。

App.js

App.js
import ResponsiveDrawer from './containers/ResponsiveDrawer';
import RouteRelatedBottomNavigation from './containers/RouteRelatedBottomNavigation';
class App extends Component {
  render() {
    return (
      <div className="App">
        <ResponsiveDrawer className="ResponsiveDrawer">
          <Switch>
            <Route path="/login" component={Login} />
            <Route path="/info" component={Info} />
            <Route path="/term" component={Term} />
            <Auth>
              <Switch>
                <Route exact path="/" component={Home} />
                <Route path='/create' component={Create} />
              </Switch>
            </Auth>
          </Switch>
        </ResponsiveDrawer>
        <RouteRelatedBottomNavigation />
      </div >
    );
  }
}

containers/RouteRelatedBottomNavigation.js

RouteRelatedBottomNavigation.js
import React from 'react';
import PropTypes from 'prop-types';

import { withStyles } from '@material-ui/core/styles';
import BottomNavigation from '@material-ui/core/BottomNavigation';
import BottomNavigationAction from '@material-ui/core/BottomNavigationAction';

import HomeIcon from '@material-ui/icons/Home';
import InfoIcon from '@material-ui/icons/Info';

import { Link, withRouter } from 'react-router-dom';

const styles = theme => ({
  wrapper: {
    display: 'block',
    width: '100%',
    position: 'fixed',
    left: 0,
    bottom: 0,
    zIndex: 1000,
    textAlign: 'center',
  },
  root: {
    [theme.breakpoints.up('md')]: {
      display: 'none',
    },
  },
  button: {
    maxWidth: '100%',
  },
});


class RouteRelatedBottomNavigation extends React.Component {
  buttons_info = [
    { label: 'トップページ', icon: <HomeIcon />, link_to: '/' },
    { label: 'テーマ一覧', icon: <InfoIcon />, link_to: '/postslist' },
    { label: '投稿', icon: <InfoIcon />, link_to: '/create' },
  ];

  buttons = this.buttons_info.map((button_info, index) => {
    return (
      <BottomNavigationAction
        value={button_info.link_to}
        label={button_info.label}
        className={this.props.classes.button}
        icon={button_info.icon}
        component={Link}
        to={button_info.link_to}
      />
    );
  })

  render() {
    const { classes } = this.props;
    return (
      <div className={classes.wrapper}>
        <BottomNavigation
          value={this.props.location.pathname}
          showLabels
          className={classes.root}
          children={this.buttons}
        />
      </div>
    );
  }
}

RouteRelatedBottomNavigation.propTypes = {
  classes: PropTypes.object.isRequired,
};

export default withRouter(
  withStyles(styles, { withTheme: true })(RouteRelatedBottomNavigation)
);

containers/ResponsiveDrawer.js

ResponsiveDrawer.js
import React from 'react';
import PropTypes from 'prop-types';

import { withStyles } from '@material-ui/core/styles';
import Drawer from '@material-ui/core/Drawer';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import List from '@material-ui/core/List';
import IconButton from '@material-ui/core/IconButton';
import Hidden from '@material-ui/core/Hidden';
import Divider from '@material-ui/core/Divider';
import MenuIcon from '@material-ui/icons/Menu';
import Icon from '@material-ui/core/Icon';

import SettingsIcon from '@material-ui/icons/Settings';
import InfoIcon from '@material-ui/icons/Info';
import HomeIcon from '@material-ui/icons/Home';

// import titlepng from '../images/sukiraism_title.png'

import { Link } from 'react-router-dom';

import ResponsiveDrawerListItem from '../components/ResponsiveDrawerListItem';

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

import * as actions from '../actions';

const drawerWidth = 240;
const headerNavigationHeight = 56;
const bottomNavigationHeight = 56;

const styles = theme => ({
  root: {
    flexGrow: 1,
    height: '100vh',
    zIndex: 1,
    overflow: 'hidden',
    position: 'relative',
    display: 'flex',
    width: '100%',
  },
  appBar: {
    position: 'absolute',
    marginLeft: drawerWidth,
    [theme.breakpoints.up('md')]: {
      width: `calc(100% - ${drawerWidth}px)`,
    },
  },
  toolBar: {
    justifyContent: 'space-between',
    minHeight: bottomNavigationHeight,
  },
  navIconHide: {
    [theme.breakpoints.up('md')]: {
      display: 'none',
    },
  },
  toolbar: theme.mixins.toolbar,
  drawerPaper: {
    width: drawerWidth,
    height: '100vh',
    [theme.breakpoints.up('md')]: {
      position: 'relative',
    },
  },
  content: {
    flexGrow: 1,
    backgroundColor: theme.palette.background.default,
    padding: theme.spacing.unit * 3,
    paddingTop: `calc(10px + ${headerNavigationHeight}px)`,
    paddingBottom: `calc(10px + ${bottomNavigationHeight}px)`,
    paddingLeft: 0,
    paddingRight: 0,
    [theme.breakpoints.up('md')]: {
      paddingBottom: 10,
    },
  },

  headerLogo: {
    display: 'flex',
    height: 40,
    width: 180,
  },
  iconLogo: {
    display: 'flex',
    height: 40,
    width: 40,
    borderRadius: '50%',
  },

  searchbar: {
    // flexGrow: 1,
  },
  flex: {
    flexGrow: 1,
  },
});

class ResponsiveDrawer extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      mobileOpen: false,
    };
  }

  closeDrawerNav = () => {
    this.setState({ mobileOpen: false });
  }
  openDrawerNav = () => {
    this.setState({ mobileOpen: true });
  }

  renderUserlink() {
    const { CurrentUserReducer } = this.props;
    const { classes } = this.props;
    const image = CurrentUserReducer.items.image;
    const isLoggedin = CurrentUserReducer.isLoggedin;
    const nicknameLink = "/users/" + CurrentUserReducer.items.nickname;
    if (isLoggedin) {
      return (
        <Link to={nicknameLink}>
          <img src={image} alt="nickname_link" className={classes.iconLogo} />
        </Link>
      )
    } else {
      return (
        <a href={process.env.REACT_APP_API127_URL + "/api/v1/auth/twitter?auth_origin_url=" + process.env.REACT_APP_BASE_URL} >
          <Icon>add_circle</Icon>
        </a >

      )
    }
  }
  renderDeletewithCondition() {
    const { CurrentUserReducer } = this.props;
    const isLoggedin = CurrentUserReducer.isLoggedin;
    if (isLoggedin) {
      return (
        <List>
          <ResponsiveDrawerListItem
            to="/deleteaccount"
            onClick={this.closeDrawerNav}
            icon={<SettingsIcon />}
            text="アカウントの削除"
          />
        </List>
      )
    } else {

    }
  }
  renderLogoutwithCondition() {
    const { CurrentUserReducer } = this.props;
    const isLoggedin = CurrentUserReducer.isLoggedin;
    if (isLoggedin) {
      return (
        <List>
          <ResponsiveDrawerListItem
            to="/logout"
            onClick={this.closeDrawerNav}
            icon={<SettingsIcon />}
            text="ログアウト"
          />
        </List>
      )
    } else {

    }
  }

  render() {

    const { classes, theme, title, search } = this.props;
    const { CurrentUserReducer } = this.props;
    const isloggedin = CurrentUserReducer.isLoggedin;

    const drawer = (
      <div>
        <List>
          <ResponsiveDrawerListItem
            to=""
            onClick={this.closeDrawerNav}
            icon={<InfoIcon />}
            text="ホーム"
          />
        </List>
        <List>
          <ResponsiveDrawerListItem
            to="postslist"
            onClick={this.closeDrawerNav}
            icon={<InfoIcon />}
            text="テーマ一覧"
          />
        </List>
        <List>
          <ResponsiveDrawerListItem
            to="create"
            onClick={this.closeDrawerNav}
            icon={<InfoIcon />}
            text="テーマを投稿する"
          />
        </List>
        <List>
          <ResponsiveDrawerListItem
            to="search"
            onClick={this.closeDrawerNav}
            icon={<InfoIcon />}
            text="テーマを検索する"
          />
        </List>

        <Divider />
        <List>
          <ResponsiveDrawerListItem
            to="info"
            onClick={this.closeDrawerNav}
            icon={<InfoIcon />}
            text="XXXとは"
          />
        </List>
        <List>
          <ResponsiveDrawerListItem
            to="/term"
            onClick={this.closeDrawerNav}
            icon={<HomeIcon />}
            text="利用規約"
          />
        </List>
        <Divider />
        {this.renderDeletewithCondition()}
        {this.renderLogoutwithCondition()}
      </div>
    );

    return (
      <div className={classes.root}>
        <AppBar className={classes.appBar} position="fixed">
          <Toolbar className={classes.toolBar} variant="dense">
            <IconButton
              color="inherit"
              aria-label="Open drawer"
              onClick={() => this.openDrawerNav()}
              className={classes.navIconHide}
            >
              <MenuIcon />
            </IconButton>
            <Link to="/">
              <img src="#" width="150px" alt="title_logo" className={classes.headerLogo} />
            </Link>
            {this.renderUserlink()}
          </Toolbar>
        </AppBar>
        <Hidden mdUp>
          <Drawer
            variant="temporary"
            anchor={theme.direction === 'rtl' ? 'right' : 'left'}
            open={this.state.mobileOpen}
            onClose={this.closeDrawerNav}
            classes={{
              paper: classes.drawerPaper,
            }}
            ModalProps={{
              keepMounted: true, // Better open performance on mobile.
            }}
          >
            {drawer}
          </Drawer>
        </Hidden>
        <Hidden smDown implementation="css">
          <Drawer
            variant="permanent"
            open
            classes={{
              paper: classes.drawerPaper,
            }}
          >
            {drawer}
          </Drawer>
        </Hidden>
        <main className={classes.content}>
          {this.props.children}
        </main>
      </div>
    );
  }
}

ResponsiveDrawer.propTypes = {
  classes: PropTypes.object.isRequired,
  theme: PropTypes.object.isRequired,
};

const mapState = (state, ownProps) => ({
  CurrentUserReducer: state.CurrentUserReducer
});
function mapDispatch(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(mapState, mapDispatch)(
  withStyles(styles, { withTheme: true })(ResponsiveDrawer)
);

components/ResponsiveDrawerListItem.js

conponents/ResponsiveDrawerListItem.js
import React from 'react';
import PropTypes from 'prop-types';

import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';

import { Link } from 'react-router-dom'

const ResponsiveDrawerListItem = ({ to, onClick, icon, text }) => (
  <ListItem button component={Link} to={to} onClick={onClick}>
    <ListItemIcon>
      {icon}
    </ListItemIcon>
    <ListItemText primary={text} />
  </ListItem>
);

ResponsiveDrawerListItem.propTypes = {
  to: PropTypes.string.isRequired,
  onClick: PropTypes.func.isRequired,
  icon: PropTypes.object.isRequired,
  text: PropTypes.string.isRequired,
};

export default ResponsiveDrawerListItem;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

typescript-fsa-redux-sagaことはじめ

typescript-fsaしゅごい :sunflower:

そんな便利なtypescript-fsaには以下のCompanion Packagesが同じ作者より提供されています(リンク省略)

  • typescript-fsa-redux-saga
  • typescript-fsa-redux-observable
  • typescript-fsa-redux-thunk
  • typescript-fsa-reducers

この記事ではtypescript-fsa-redux-sagaの使い方について簡単にまとめた記事になります(一部typescript-fsa及びtypescript-fsa-reducersが入ります)。

本記事執筆時におけるバージョンは以下の通りです。

redux@4.0.5
redux-saga@1.1.3
typescript-fsa@3.0.0
typescript-fsa-reducers@1.2.1
typescript-fsa-redux-saga@2.0.0

提供されているAPI :hugging:

提供されているAPIはreadmeにも書かれていますがbindAsyncActionのみです。

bindAsyncActionAsyncActionCreatorを受け取ってHigherOrderSagaを返す関数です。

以下のような処理を行います。

  1. AACを受け取る
  2. 受け取ったSagaIteratorに引数paramsを渡して起動
  3. put(AAC.started({params}))(省略可)
  4. 受け取ったSagaIteratorが処理を終えreturn/throwすると
    • returnの場合、yieldしたresultをput(AAC.done({params, result}))
    • throwの場合、catchthrowされたerrorを使ってput(AAC.failed({params, error}))

AsyncACtionCreator

AsyncActionCreator(AAC)はtypescript-fsaactionCreator#asyncで作られるオブジェクトです。
作り方等は私の記事で恐縮ですが:poop:こちら:poop:を参照ください。

HigherOrderSaga

HigherOrderSagaSagaIteratorを受け取る関数です。

SagaIteratorredux-sagaにて以下のように定義されています。

export type SagaIterator<RT = any> = Iterator<StrictEffect, RT, any>

Iteratorの定義も見てみます。

interface Iterator<T, TReturn = any, TNext = undefined> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
    return?(value?: TReturn): IteratorResult<T, TReturn>;
    throw?(e?: any): IteratorResult<T, TReturn>;
}

つまりnext()した際にIteratorYieldResult<StrictEffect>が返ってくるIterableオブジェクトが来れば良さそうです。

次にStrictEffectについて見てみます。

export type StrictEffect<T = any, P = any> = SimpleEffect<T, P> | StrictCombinatorEffect<T, P>;

export interface SimpleEffect<T, P = any> extends Effect<T, P> {
  combinator: false
}
export interface StrictCombinatorEffect<T, P> extends Effect<
  T, CombinatorEffectDescriptor<StrictEffect>
> {
  combinator: true
}

export interface Effect<T = any, P = any> {
  '@@redux-saga/IO': true
  combinator: boolean
  type: T
  payload: P
}

ここから以下のことが分かりました

  • StrictEffectSimpleEffectStrictBombinatorEffectの共用体型ということ
  • SimpleEffectStrictBombinatorEffectEffectを継承しているということ
  • EffectはFSAを拡張したものということ

使ってみる :punch:

型パワーを実感するために使ってみます。

1秒かけてstringの文字数を返すasync関数をsagaで動かすことを考えてみます。また与えられた文字数が3文字の場合Errorthrowします。

まずはAACとそのasync関数を定義します。

// いつもの
import actionCreatorFactory from 'typescript-fsa'
const actionCreator = actionCreatorFacory()

// AAC定義
const getLengthAction = actionCreator.async<string, number, Error>('getLengthAction')

// async関数
const getLength = async (s: string): Promise<number> => {
  await new Promise(resolve => globalThis.setTimout(resolve, 1000))
  if (s.length !== 3) {
    return s.length
  } else {
    throw new Error('error')
  }
}

次にsaga workerを定義します。

typescript-fsa-redux-sagaを使わない場合は似たような処理を以下のように書くと思います。

function* getLengthWorker(s: string) {
  try {
    const result = yield call(getLength, s)
    yield put({type: 'SomeActionDone', payload: result})
  } catch (e) {
    yield put({type: 'SomeActionFailed', payload: e, error: true})
  }
}

typescript-fsa-redux-sagaを使う場合は以下のように書きます。

// saga worker
function* getLengthWorker(s: string) {
  return yield call(getLength, s)
}

// saga workerをwrapしたworker
const boundGetLengthWorker = bindAsyncAction(getLengthAction)(getLengthWorker)

処理結果をreturnしていないことや例外をcatchしていないのでだいぶシンプルになっていると思います。

参考までに、getLengthWorkerSimpleEffectを継承したCallEffectを返すcallyieldし、その結果をreturnしています。これによりIterator<SimpleEffect>のシグネチャを満たしています。

なので

function* getLengthWorker(s: string) {
  // return yield call(getLength, s)
  return yield s
}

のようにgetLengthWorkerを書き換えるとgetLengthWorkerの型はIterator<string>になりbindAsyncActionのシグネチャを満たさずコンパイルエラーになります。

次にboundGetLengthWorkerの起動方法を見てみます。

function* getLengthHandler(): SagaIterator {
  yield takeEvery(getLengthAction.started, function*({payload}) {
    yield call(boundGetLengthWorker, payload)
  })
}

export function* rootSaga(): SagaIterator {
  yield fork(getLengthHandler)
}

getLengthHandlergetLengthAction.startedを受け取るとboundGetLengthWorkerを起動します。
payloadgetLengthAction.startedのpayloadの型なのでここでの型チェックを行うことができます。

以上が大まかな流れです。

注意点 :warning:

AAC.startedの罠 :hole:

さて、上記のサンプルには致命的なミスがあります。getLengthHandlergetLengthAction.startedを受け取りboundGetLengthWorkerを起動していますが、そのboundGetLengthWorker内部でもgetLengthAction.startedを発火させています。
そのためこのまま実行すると無限ループに陥ってしまいます。 :scream:

これを回避するためには以下の2つの手段が取り得ます。

  • saga workerでput(getLengthAction.started())しない
  • takeEveryで受け取るActionを変える

saga workerでput(getLengthAction.started())しない :skier:

この方法は非常にスッキリ書けます。以下のようにbindAsyncActionにoptionを渡すだけです。

const boundGetLengthWorker = bindAsyncAction(getLengthAction, {skipStartedAction: true})(getLengthWorker)

takeEveryで受け取るActionを変える :snowboarder:

この方法は書き方が冗長になりますがもう少し自由度をもたせることが可能です。
以下のようにsaga起動用のactionを別に定義します。

// AAC定義
type getLengthProp = string
const getLengthAction = actionCreator.async<getLengthProp, number, Error>('getLengthAction')

// trigger用Action
const getLengthOp = actionCreator<getLengthProp>('GetLengthOp')

// ...

function* getLengthHandler(): SagaIterator {
  yield takeEvery(hogeAsyncOp, function*({payload}) {
    yield call(boundGetLengthWorker, payload)
  })
}

このようにすることで例えばtakeEveryした後様々な前処理を行い、その後実行されるboundGetLengthWorkerが起動したタイミングを正確にputしたいというニーズを満たすことができます(すいません、このシチュエーションは相当なレベルのコーナーケースだと思います。他に良い例があれば教えてください・・・)。

なおこの場合でも仮にgetLengthActionのPropsの型とgetLengthOpのPropsの型が異なった場合getLengthHandlerにて型エラーを検知できます。

個人的には特段の事情が無い限りは{skipStartedAction: true}してAAC.starteddispatchするのが良いと思います。

AAC.failedは型安全ではない :unlock:

実際に使用する際はtypescript-fsa-reducersを合わせて以下のように使うと思います。

import { reducerWithInitialState } from 'typescript-fsa-reducers'
import { ErrorState } from '../containers/Error'

const failedHandler = (_: unknown, { error }: { error: Error }): ErrorState => {
  return {
    error: { type: error.name, message: error.message },
  }
}

export const errorReducer = reducerWithInitialState({
  error: null,
} as ErrorState)
  .case(getLengthAction.failed, failedHandler)

reducer内では型安全が保証されています。getLengthAction.failedのpayloadは{ error: Error }が含まれています。これは一見問題なさそうです。

しかし例えばgetLengthActionでbindされたsaga workerでcallしているasync関数(getLength)が以下のようになっている場合どうなるでしょうか。

const getLength = async (s: string): Promise<number> => {
  throw 'a'
}

この場合は型エラーが出ません。javascriptでは全てのオブジェクトがthrowableなためです。
そのため丁寧にthrowしないと実行時エラーが起きてしまう危険があります。

終わりに :notebook:

以上を元にtypescript-fsa-redux-sagaについて簡単にまとめてみます。

  • メリット
    • try...catch...のようなボイラープレートを削減できる
    • 型チェックできる部分が増える
  • デメリット
    • saga worker内でthrowしないといけない

個人的な所感ですがthrowする事を許容する/しないという点が採用にあたってのポイントになると思います。
特にsaga内でcallする非同期関数で謎のerrorをthrowされる危険を許容できないのであればtypescript-fsa-redux-sagaを採用せずtry...catch...で手動でAACをputするのも有りだと思います。

また、現状bindAsyncActionthrowされた時のみAAC.failedputするという点も覚えておきたいところです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

API叩いてReactとサーバーサイドでやり取りしてみる

Laravel6 x React.js x Material-Ui で環境構築しモダン技術で開発

以前の記事で、構築した環境で、一覧表示機能を実装したので、記事を書いてみたいと思います。
Reactからaxiosでリクエスト投げて、LaraveでJSONを返して、そのデータを一覧表の中で表示してみたいと思います。

React側で、まずデータを受け取るためのstateを定義する

表示したいコンポーネントの中にstateを定義します。
今回の場合は受け取ったデータがarrayのため、初期値も空配列で設定しています。
setPostsはpostsの値を変更する唯一のメソッドです。

Home.js
function Home() {

    const [posts, setPosts] = useState([]);
--------------------------------

Reactからaxiosを使いリクエストを投げる

表示したいコンポーネントからaxiosを使用し、サーバーサイドにリクエストを飛ばします。

Home.js
        axios
            .get('/api/posts')             //リクエストを飛ばすpath
            .then(response => {
                setPosts(response.data);
            })                               //成功した場合、postsを更新する(then)
            .catch(() => {
                console.log('通信に失敗しました');
            });                             //失敗した場合(catch)

Routeで呼び出すアクションを指定

Laravelのroutes/api.phpを開き、下記のコードを記載しcontrollerの、どのアクションを指定するか指定します。

api.php
Route::group(['middleware' => 'api'], function(){
    Route::get('posts', 'Api\PostController@index');
});

呼び出したindex()でJSONを返す処理を記載

app/Http/Controllers/api/PostController.phpのindex()にJSONを返す記載

api.php
<?php

namespace App\Http\Controllers\api;

use App\Http\Controllers\Controller;
use App\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index()
    {
        $posts = Post::all();
        return response()->json($posts, 200);  //型をJSONに指定します。
    }


サーバーサイドから、データを確認してみる

DBのデータは下記の通り。

dbのデータ.png

こちらのデータがJSON形式でサーバーサイドからresponse.dataとして返ってきて、reactのstateであるpostsを更新することになります。
確認してみます。

Home.js
        axios
            .get('/api/posts')
            .then(response => {
                setPosts(response.data);
                console.log(response.data);

            })

chromeで確認してみます。

response.png

ちゃんとデータとして返ってきている事がわかりますね。
このレスポンスデータの形でstateのpostsが更新されるため、例えばタスク1を表示する場合posts[0].name
で取得できるということです。

表示するデータを含む配列を作ってJSXの中で使用し、表示

Home.js
    let rows = [];
    posts.map((rowData) =>
        rows.push({
            user_name: rowData.name,
            post: rowData.content,
            btn: <Button color="secondary" variant="contained" key={rowData.id} href={`/post/edit/${rowData.id}`}>編集</Button>,
            deleteBtn: <Button color="primary" variant="contained" href="/" onClick={() => deletePost(rowData)}>完了</Button>
        })
    );

    return(
        <TableBody>
        {rows.map((row, index) => (
            <TableRow key={index}>
                {Object.keys(row).map(function (key, index) {
                    return (
                        <TableCell align="center" key={index}>{row[key]}</TableCell>
                            );
                        })}
                </TableRow>
        <TableBody>
    );

画面を確認しましょう。

index.png

きちんと表示されていることが確認できました。

リクエストを投げるタイミングを最初にページにアクセスしたときのみに変更する。

今の状態でも表示はできていますが、繰り返しaxiosのリクエストが走ってしまう設定担っています。
useeffectを使用することで、ページにアクセスしたときのみ、リクエストを送ることができます。

axiosを投げる処理を関数化しuseEffect()の中で呼び出します。
第二引数を空配列に指定することで、空配列が渡ってきたときのみ実行することができます。

Home.js
    useEffect(() => {
        getPostsData();
    },[])

    function getPostsData(){
        axios
            .get('/api/posts')
            .then(response => {
                setPosts(response.data);
            })
            .catch(() => {
                console.log('通信に失敗しました');
            });
    }

今回の記事は以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

react にて、画像リンク切れの際、imgタグを非表示にする

通常のhtmlにおいては、onerrorハンドラにてリンク切れのimgタグを非表示にできます。

<img src="original.png" alt="title" onerror="this.style.display='none'"/>

reactではこれができないため、以下のようにonErrorハンドラを作成します。

<img src={`${process.env.PUBLIC_URL}/logo192_.png`} onError={e => e.target.style.display = 'none'} />

サンプル

?以下のサンプルは、create-react-app直後のApp.jsを編集したものです。

App.js
import React from 'react';

function App() {
  return (
    <div className="App">
      <img border="1" src={`${process.env.PUBLIC_URL}/logo192.png`} />
      <img border="1" src={`${process.env.PUBLIC_URL}/logo192_.png`} />
      <img border="1" src={`${process.env.PUBLIC_URL}/logo192_.png`} onerror="this.style.display='none'" />
      <img border="1" src={`${process.env.PUBLIC_URL}/logo192_.png`} onError={e => e.target.style.display = 'none'} />
    </div>
  );
}

export default App;

参考

?Hide broken image link in Semantic UI React
?リンク切れの時に代替画像を表示したいときはこちら

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel6 x React.js x Material-Ui で環境構築しモダン技術で開発

Laravelの環境でReact.jsを使ってみたいとの思いから、環境構築したのでその手順を残しておく。ついでにUIフレームワークも、Material-Uiを使用しましたので、その手順も残しておきます。Reactを使用することで、フロントエンドとバックエンドの責務を分離しAPIを用いて、やり取りします。今回はその環境構築部分です。

使用技術の簡単な紹介

  • Laravel   近年人気が高まっているPHPフレームワーク。MVCで設計されており、筆者はRuby on Railsとも似ていると思います。
  • React.js  FACEBOOK発のJavaScriptライブラリ。Vue.jsと並び人気の高いライブラリである。
  • Material-Ui  素早くシンプルで美しいデザインを構成できるgoogle発のUiフレームワーク。importすることで、簡単にボタンや表などの見た目を整える事ができます。Bootstrapのような見た目をReactで構成するようなイメージでいいと思います。(https://material-ui.com/ja/)

Laravel6の実行環境を構築する

初期画面が表示できれば、XAMPP等でも大丈夫なのですが、筆者はdockerを用いて開発しました。こちらの記事を参照することで、簡単に構築することができたので、紹介させていただきます。(https://qiita.com/ucan-lab/items/56c9dc3cf2e6762672f4)
すでに、書籍等で何かしらのLaravel環境構築方法をご存知のかたはそちらで大丈夫ですが、Laravel6以降でreactの導入方法が異なっているため、Laravelのヴァージョン指定する場合は6.0以降にしてください。
下記画面が表示できたら、Reactの導入に進みましょう。

laravel初期画面.png

React導入のため、node.jsの実行環境を用意する。

ターミナル.
$ node -v
v10.10.0

$ npm -v
6.4.1

上記のコマンド実行し、バージョン確認できればOKです。
Node.jsの実行環境がない場合はこちら
https://qiita.com/PolarBear/items/62c0416492810b7ecf7c

また、yarnで管理している方は適宜、npmと読み替えながら進めてください。

React.jsの環境構築

Laravelの初期画面が表示できたら、プロジェクト直下で下記コマンドを実行します。

ターミナル.
$ php artisan ui react       //package.jsonにreact関連の記述がされます。

$ npm install                //パッケージのインストールを行います。

$ npm run dev                 //ビルドできるか確認します。
DONE  Compiled successfully    //こちらが表示されたら成功です。

次にjsファイルを読み込む設定をします。

welcome.blade.phpを開き、bodyタグの中の一番下、つまりbodyタグの閉じタグの直前に下記のコード2行(divとscript)を記載します。

welcome.blade.php
   ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー抜粋
                <div class="links">
                    <a href="https://laravel.com/docs">Docs</a>
                    <a href="https://laracasts.com">Laracasts</a>
                    <a href="https://laravel-news.com">News</a>
                    <a href="https://blog.laravel.com">Blog</a>
                    <a href="https://nova.laravel.com">Nova</a>
                    <a href="https://forge.laravel.com">Forge</a>
                    <a href="https://github.com/laravel/laravel">GitHub</a>
                </div>
            </div>
        </div>      //下記の追記します。
        <div id="root"></div>
        <script src="{{mix('js/app.js')}}"></script>
    </body>

こちらのコードを追加することで、デフォルト設定のExample.jsのコンポーネントが読み込まれるため、画面左下に下記の記述が追記されます。
Example Component
I'm an example component!

画像は以下

react導入確認.png

表示できていればLaravelの環境にreactを導入できています。

Material-Uiの導入

次にMaterial-Uiを導入します。
https://material-ui.com/ja/
公式に基づきnpmインストールします。
また使い方は公式に詳しく掲載されていますので、今回はButtonタグのみ用いて環境構築の確認を行います。

ターミナル.
$  $ npm install @material-ui/core      //package.jsonにmaterial-Ui関連の記述がされます。

$ npm install                //パッケージのインストールを行います。

次にresources/js/components/Example.jsを開き、ファイル上部にMaterial-Uiのボタンタグをインポートし、先程Laravelの初期画面に表示された部分の下にimportした環境構築確認用ボタンを追記します。

Example.js
import React from 'react';
import { Button } from '@material-ui/core';  //追記するコード

function Example() {
    return (
        <div className="container">
            <div className="row justify-content-center">
                <div className="col-md-8">
                    <div className="card">
                        <div className="card-header">Example Component</div>
                        <div className="card-body">I'm an example component!</div>
                        <Button color="primary" variant="contained">環境構築</Button>  //追記するコード
                    </div>
                </div>
            </div>
        </div>
    );
}

export default Example;

設置できたらファイルを保存しビルド

ターミナル.
$ npm run build

成功したら画面をリロード(キャッシュを消したほうが確実です。)し、初期画面を確認します。

Material-Uiの環境構築確認用.png

先程のReactの環境構築確認用のコンポーネント部分に青色の環境構築用ボタンが確認できました。
ちなみにButtonタグの中のcolorをprimaryからsecondaryに変更することでボタンの色が赤色に変わります。Material-Uiの機能がきちんと動作していることが確認できましたね。。
環境構築は以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Tinder風UIで「好き」「嫌い」を投票できる画面を実装する【React×Railsアプリ開発 第8回】

やったこと

  • Tinder風UIで投稿に対して、「好き」か「嫌い」が投票できる画面を実装した。
  • モジュールはreact-swipe-card-chsstmを使って、Tinder風UIを実装した。

今回の成果

v1n1g-k17jj.gif

できなかったこと

  • react-swipe-card-chsstmのコードを修正して使いたかったが、どうしても修正後のモジュールが上手くインストールできなかった。
  • Github上でForkしたあとにコード修正、再度インストールまでは良かったが、トランスパイル(コンパイル)が上手くいってないのかな〜...

実装手順

モジュールインストール

ここでインストール済み。npm install react-swipe-card-chsstmで行けるはず。

Rails APIの調整

  • ログイン中ユーザーが「好き」か「嫌い」か投票していないポストをランダムで10個ずつ返すAPIを作る
  • answered_posts_idでログイン中ユーザーが「投票した」ポストのidを呼び出している。
  • Posw.where.not("id ~ で、投票したpost_id以外のidのポストの中から10個をランダムで返している。

users_controllerにnot_answered_postsを追加する

users_controller.rb
      def not_answered_posts
        @user = current_api_v1_user
        answered_posts_id = "SELECT post_id from likes WHERE user_id = :user_id"
        not_answered_posts = Post.where.not("id IN (#{answered_posts_id})", user_id: @user.id).order('RANDOM()').limit(10)
        json_data = {
          'posts': not_answered_posts,
        }

        render json: { status: 'SUCCESS', message: 'Loaded the not_answered_posts', data: json_data}
      end 

React

Home.js

  • CardのonSwipeLeft、onSwipeRightで左右にスワイプ時に呼び出す関数を指定している。
  • submitSuki, submitKiraiで「好き」「嫌い」を投票している。
  • 再読み込みボタンで、リロードし、追加のnot_answered_posts(ログイン中のユーザーがまだ投票していないポスト)を10個ずつ読み込んでいる。
Home.js
import React from 'react';
import PropTypes from 'prop-types';
import './HomeStyles.css'

import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as actions from '../actions';
import queryString from 'query-string';
import _ from 'lodash';
import axios from 'axios';

import Cards, { Card } from 'react-swipe-card-chsstm';

import "normalize.css";
//import "./styles.css";

const styles = theme => ({

  // ヘッダーロゴ
  homeimg: {
    height: '20%',
    width: '60%',
    display: 'block',
    margin: 'auto',
  },
  conceptimg: {
    display: 'flex',
    width: '80%',
    display: 'block',
    margin: '10px auto',
  },
  button: {
    margin: '0px 5px',
  },
  sukibutton: {
    margin: '0px 15px',
    backgroudColor: '#000000',
  },
  kiraibutton: {
    margin: '0px 15px',
    backgroudColor: '#ffffff'
  },
});

class Home extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      not_answered_posts: []
    }
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    //新着順
    axios.get(process.env.REACT_APP_API_URL + `/api/v1/not_answered_posts`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then((response) => {
        const data = response.data.data;
        this.setState({
          not_answered_posts: data.posts,
        });
      })
      .catch(() => {

      });
    this.reload = this.reload.bind(this);
  }

  reset = () => {
    this.setState(state => ({
      id: state.id + 1
    }));
  };


  componentDidMount() {
  }


  submitSuki(post) {
    const { CurrentUserReducer } = this.props
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid

    const data = {
      user_id: CurrentUserReducer.items.id,
      post_id: post.id,
      suki: 1,
    }
    axios.post(process.env.REACT_APP_API_URL + '/api/v1/likes', data, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(response => { })
      .catch(error => { })
  }

  submitKirai(post) {
    const { CurrentUserReducer } = this.props
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid

    const data = {
      user_id: CurrentUserReducer.items.id,
      post_id: post.id,
      suki: 0,
    }
    axios.post(process.env.REACT_APP_API_URL + '/api/v1/likes', data, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(response => { })
      .catch(error => { })
  }

  reload() {
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    //新着順
    axios.get(process.env.REACT_APP_API_URL + `/api/v1/not_answered_posts`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then((response) => {
        const data = response.data.data;
        var not_answered_posts = this.state.not_answered_posts
        data.posts.forEach(post => {
          not_answered_posts.push(post);
        });
        this.setState({
          not_answered_posts: not_answered_posts,
        });


      })
      .catch(() => {

      });
  }

  render() {
    const { CurrentUserReducer } = this.props;
    const { classes } = this.props;
    let cards;

    return (
      <div className="home">
        <div className="background">
          <Button size="large" variant="contained" color="blue" onClick={this.reload} className={classes.sukibutton}>
            再読み込み
            </Button>
        </div>
        <Cards
          onEnd={this.endSwipe}
          className="master-root"
          likeOverlay={<h1>スキ</h1>}
          dislikeOverlay={<h1>キライ</h1>}

          ref={(ref) => cards = ref}
        >
          {this.state.not_answered_posts.map(item =>
            <Card
              onSwipeLeft={() => this.submitKirai(item)}
              onSwipeRight={() => this.submitSuki(item)}>

              <h2>{item.content}</h2>
            </Card>
          )}

        </Cards>
        <div className="buttonArea">
          <Button size="large" variant="contained" color="blue" onClick={() => { cards.dislike(); }} className={classes.sukibutton}>
            キライ
          </Button>
          <Button size="large" variant="contained" color="red" onClick={() => { cards.like(); }} className={classes.kiraibutton}>
            スキ
        </Button>
        </div>

      </div >
    )
  }
}
Home.propTypes = {
  classes: PropTypes.object.isRequired,
  post: PropTypes.object.isRequired,
};

const mapState = (state, ownProps) => ({
  CurrentUserReducer: state.CurrentUserReducer,
});
function mapDispatch(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(mapState, mapDispatch)(
  withStyles(styles, { withTheme: true })(Home)
);

HomeStyles.css

  • Home.jsのスタイルを別途HomeStyles.cssに記述しています。
  • これ、結構うまくやらないとちゃんと配置してくれなくて苦労しました。Cardを10枚ずつ重ねて表示していて、z-indexを使っているから、ここで配置がずれたりする。
  • ここを参考にしました。https://github.com/chsstm/react-swipe-card/blob/master/stories/style.css
HomeStyles.css
html {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  -khtml-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}

body {
  margin: 0 auto;
  padding: 0;
  font-family: sans-serif;
  text-align: center;
}

img {
  width: 100%;
}

li {
  list-style: none;
}

h2 {
  text-align: left;
  word-wrap: break-word;
  margin: 20px;
  font-size: 20pt;
}


.home {
  position: relative;
  overflow: hidden;
  width: 100%;
  height: 100%;
  min-height: 500px;
}

.master-root {
  margin: 10px 0px;
  position: absolute;
  height: 50%;
  width: 100%;
  /* z-index:2; */
}
.card {
  background-color: white;
  background-size: cover;
  position: absolute;
  left: 0;
  right:0;
  top:0;
  bottom:0;
  background: #f8f3f3;
  height: 80%;
  width: 80%;
  margin: auto;
  transition: box-shadow 0.3s;
  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
  border: 10px solid #212121;
  cursor: pointer;
}

.buttonArea{
  position: absolute;
  bottom: 20%;
  width: 100%;
  margin: 0 auto;
}

.animate {
  transition: transform 0.3s;
  box-shadow: none;
}

.inactive {
  box-shadow: none;
}

.alert {
  width: 45%;
  min-height: 10%;
  position: absolute;
  z-index: 9999;
  opacity: 0;
  transition: opacity 0.5s;
  color: white;
  vertical-align: middle;
  line-height: 3rem;
}

.alert-visible {
  opacity: 1;
}

.alert-right {
  top: 0;
  right: 0;
  background: red;
  border-top-left-radius: 50px;
  border-bottom-left-radius: 50px;
}

.alert-left {
  top: 0;
  left: 0;
  background: blue;
  border-top-right-radius: 50px;
  border-bottom-right-radius: 50px;
}

.alert-top {
  background: black;
  border-radius: 50px;
  transform: translate(-50%, 0);
  margin-left: 50%;
}

.alert-bottom {
  bottom: 0;
  background: black;
  border-top-left-radius: 50px;
  border-radius: 50px;
  transform: translate(-50%, 0);
  margin-left: 50%;
}

.action-button {
  cursor: pointer;
  padding: 10px 50px;
  border: none;
  outline: none;
  background-color: lightgrey;
  margin: 0px 10px;
}

.action-button:active {
  background-color: black;
  color: white;
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

redux-formを使って投稿画面を実装する【初学者のReact×Railsアプリ開発 第6回】

やったこと

  • redux-formを使ってReactで投稿画面を実装した
  • axiosを使って、Railsで実装したAPIと連携させ、投稿できるようにした。

成果物

ohy01-019mn.gif

実装手順

モジュールのインストール

アプリ開発全体で使うモジュールはここでインストールしています。

App.js

App.js
import Login from './containers/Create';

            <Auth>
              <Switch>
                <Route exact path="/" component={Home} />
                <Route path='/create' component={Create} />
              </Switch>
            </Auth>

reducers/rootReducer.js

  • redux-formからformReducerを読み込んでいる。
rootReducer.js
import { combineReducers } from 'redux'
import { reducer as formReducer } from 'redux-form'
import { routerReducer } from 'react-router-redux'
import CurrentUserReducer from './CurrentUserReducer'

const rootReducer = combineReducers({
  CurrentUserReducer,
  form: formReducer,
  router: routerReducer,
})

export default rootReducer

containers/Create.js

  • これが投稿画面。
  • フォームCreateForm.jsをインポートしている
  • submitPostで送信の処理を書いている。
  • フォーム内の改行などはreplaceなどを使って消去している
  • axiosの使い方はここで書いている通りで、localStorageからauth_tokenなどを呼び出して、headerにつけて送っていることで認証できる。
  • 送信後のフォームの処理は、本当はactionを経由しなければいけないのかな〜...(この実装方法だと、タイミングは微妙に変)
Create.js
import React from 'react'
import CreateForm from '../components/CreateForm'
import axios from 'axios'

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

import * as actions from '../actions';

class Create extends React.Component {
  constructor(props) {
    super()
    this.state = {
      postSuccess: false,
    }
  }
  submit = values => {
    const data = {
      content: values.notes,
      user_id: 1
    }
    axios.post(process.env.REACT_APP_API_URL + '/api/v1/posts', data)
  }

  submitPost = values => {
    const { CurrentUserReducer } = this.props;
    var value_content = values.notes
    var send_content = value_content.replace(/\r?\n/g, "");

    const data = {
      content: send_content,
      user_id: CurrentUserReducer.items.id
    }
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid

    axios.post(process.env.REACT_APP_API_URL + '/api/v1/posts', data, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(() => {
      })
      .catch(() => {
        values.notes = value_content
      })
    values.notes = ""

  }

  render() {
    const { actions } = this.props;
    const { CurrentUserReducer } = this.props;

    const { classes } = this.props;
    return (
      <div>
        <h3>テーマを投稿する</h3>
        <CreateForm onSubmit={this.submitPost} />
      </div>
    )
  }
}

const mapState = (state, ownProps) => ({
  CurrentUserReducer: state.CurrentUserReducer
});

function mapDispatch(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(mapState, mapDispatch)(Create);

components/CreateForm.js

  • validateで必須項目や50字以内にするように指定している。
  • const CreateForm内のFieldタグで色々オプションを付けている。multilineは複数行のフォームにするかどうか?で、rowsMaxで最大の行数を指定できる。Enter押すと自動で行数が増えていく。
CreateForm.js
import React from 'react'
import { Field, reduxForm } from 'redux-form'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles(theme => ({
  button: {
    margin: theme.spacing(1),
  },
  field: {
    width: '80%',
    height: '200px',
  }
}));

const validate = values => {
  const errors = {}
  const requiredFields = [
    'notes'
  ]
  requiredFields.forEach(field => {
    if (!values[field]) {
      errors[field] = 'Required'
    }
  })
  if (
    values.notes &&
    values.notes.length > 51
  ) {
    errors.notes = '50字以内にしてください'
  }
  return errors
}

const renderTextField = ({
  label,
  input,
  meta: { touched, invalid, error },
  rows = 3,
  ...custom
}) => (
    <TextField
      label={label}
      placeholder={label}
      error={touched && invalid}
      helperText={touched && error}
      {...input}
      rows={rows}
      {...custom}
      variant="outlined"
    />
  )

const CreateForm = props => {
  const { handleSubmit, pristine, reset, submitting } = props
  const classes = useStyles();
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <Field
          name="notes"
          component={renderTextField}
          className={classes.field}
          label=""
          multiline
          rowsMax="7"
          margin="normal"
        />
      </div>
      <div>
        <Button
          variant="contained"
          color="primary"
          type="submit"
          className={classes.button}
        //endIcon={<Icon>send</Icon>}
        >
          Send
      </Button>
      </div>
    </form>
  )
}

export default reduxForm({
  form: 'CreateForm', // a unique identifier for this form
  validate,
})(CreateForm)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

redux-formを使って投稿画面を実装する【初学者のReact×Railsアプリ開発 第7回】

やったこと

  • redux-formを使ってReactで投稿画面を実装した
  • axiosを使って、Railsで実装したAPIと連携させ、投稿できるようにした。

成果物

ohy01-019mn.gif

実装手順

モジュールのインストール

アプリ開発全体で使うモジュールはここでインストールしています。

App.js

App.js
import Login from './containers/Create';

            <Auth>
              <Switch>
                <Route exact path="/" component={Home} />
                <Route path='/create' component={Create} />
              </Switch>
            </Auth>

reducers/rootReducer.js

  • redux-formからformReducerを読み込んでいる。
rootReducer.js
import { combineReducers } from 'redux'
import { reducer as formReducer } from 'redux-form'
import { routerReducer } from 'react-router-redux'
import CurrentUserReducer from './CurrentUserReducer'

const rootReducer = combineReducers({
  CurrentUserReducer,
  form: formReducer,
  router: routerReducer,
})

export default rootReducer

containers/Create.js

  • これが投稿画面。
  • フォームCreateForm.jsをインポートしている
  • submitPostで送信の処理を書いている。
  • フォーム内の改行などはreplaceなどを使って消去している
  • axiosの使い方はここで書いている通りで、localStorageからauth_tokenなどを呼び出して、headerにつけて送っていることで認証できる。
  • 送信後のフォームの処理は、本当はactionを経由しなければいけないのかな〜...(この実装方法だと、タイミングは微妙に変)
Create.js
import React from 'react'
import CreateForm from '../components/CreateForm'
import axios from 'axios'

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

import * as actions from '../actions';

class Create extends React.Component {
  constructor(props) {
    super()
    this.state = {
      postSuccess: false,
    }
  }
  submit = values => {
    const data = {
      content: values.notes,
      user_id: 1
    }
    axios.post(process.env.REACT_APP_API_URL + '/api/v1/posts', data)
  }

  submitPost = values => {
    const { CurrentUserReducer } = this.props;
    var value_content = values.notes
    var send_content = value_content.replace(/\r?\n/g, "");

    const data = {
      content: send_content,
      user_id: CurrentUserReducer.items.id
    }
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid

    axios.post(process.env.REACT_APP_API_URL + '/api/v1/posts', data, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(() => {
      })
      .catch(() => {
        values.notes = value_content
      })
    values.notes = ""

  }

  render() {
    const { actions } = this.props;
    const { CurrentUserReducer } = this.props;

    const { classes } = this.props;
    return (
      <div>
        <h3>テーマを投稿する</h3>
        <CreateForm onSubmit={this.submitPost} />
      </div>
    )
  }
}

const mapState = (state, ownProps) => ({
  CurrentUserReducer: state.CurrentUserReducer
});

function mapDispatch(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(mapState, mapDispatch)(Create);

components/CreateForm.js

  • validateで必須項目や50字以内にするように指定している。
  • const CreateForm内のFieldタグで色々オプションを付けている。multilineは複数行のフォームにするかどうか?で、rowsMaxで最大の行数を指定できる。Enter押すと自動で行数が増えていく。
CreateForm.js
import React from 'react'
import { Field, reduxForm } from 'redux-form'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles(theme => ({
  button: {
    margin: theme.spacing(1),
  },
  field: {
    width: '80%',
    height: '200px',
  }
}));

const validate = values => {
  const errors = {}
  const requiredFields = [
    'notes'
  ]
  requiredFields.forEach(field => {
    if (!values[field]) {
      errors[field] = 'Required'
    }
  })
  if (
    values.notes &&
    values.notes.length > 51
  ) {
    errors.notes = '50字以内にしてください'
  }
  return errors
}

const renderTextField = ({
  label,
  input,
  meta: { touched, invalid, error },
  rows = 3,
  ...custom
}) => (
    <TextField
      label={label}
      placeholder={label}
      error={touched && invalid}
      helperText={touched && error}
      {...input}
      rows={rows}
      {...custom}
      variant="outlined"
    />
  )

const CreateForm = props => {
  const { handleSubmit, pristine, reset, submitting } = props
  const classes = useStyles();
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <Field
          name="notes"
          component={renderTextField}
          className={classes.field}
          label=""
          multiline
          rowsMax="7"
          margin="normal"
        />
      </div>
      <div>
        <Button
          variant="contained"
          color="primary"
          type="submit"
          className={classes.button}
        //endIcon={<Icon>send</Icon>}
        >
          Send
      </Button>
      </div>
    </form>
  )
}

export default reduxForm({
  form: 'CreateForm', // a unique identifier for this form
  validate,
})(CreateForm)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactで作成した静的webアプリをサブドメインを用いてapacheにデプロイするまで

はじめに

今日,AWSとかAzureとか,webサービスをデプロイするのにクラウドサービスが流行っていますが,僕は勉強も兼ねてVPSを使っているのでapacheにデプロイする方法を書こうかと思います.

時代遅れな記事ですみません...

ReactAppをビルド

以下のコマンドでビルドします.

npm run build

ルートディレクトリにbuildフォルダが出来上がると思うので,scpかなんかを使ってVPSに転送します.
転送先はとりあえずどこでもいいです.

サブドメインの作成

もともとブログも管理していたということもあって,今回はサブドメインを作ろうかと思いました.
サブドメインの作り方はレンタル先によると思いますが,僕はConohaVPSしかわからないのでそれについて説明します.
ここで,メインドメインは取得して登録済みであることは前提とします.

  1. ログインする
  2. 左の欄から「DNS」をクリックする
  3. メインドメインをクリックする
  4. 右上の方にある編集ボタンをクリックする
  5. 追加ボタンをクリックし,タイプ「A」,名称「<サブドメインにしたいワード>」,TTL「<キャッシュ期間>」,値「<サーバのIP>」をれぞれ入力する
  6. 保存する

Conoha側の設定は以上です.

Apacheの設定

ssh とかでターミナルからサーバ内に接続します.

# httpdのconf.dディレクトリに移動
cd /etc/httpd/conf.d

# virtual.confを作成(.conf拡張子であれば名前は何でも大丈夫です)
vim virtual.conf

virtual.confには以下の内容を記述します.

<VirtualHost *:80>
    ServerAdmin root@localhost
    ServerName <サブドメイン>
    DocumentRoot <buildファイルまでのパス>

    <Directory "<DocumentRootと同じパス>">
        Options Indexes FollowSymLinks
        AllowOverride None
        Options -MultiViews
        RewriteEngine On
        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteCond %{REQUEST_FILENAME} !-d
        RewriteRule ^ index.html [QSA,L]
        Require all granted
    </Directory>

    CustomLog /etc/httpd/logs/hoge_access_log common
    ErrorLog /etc/httpd/logs/hoge_error_log
</VirtualHost *:80>

DocumentRoot ですが, scp したときのbuildファイルまでのパスを指定します.
基本的には/var/www/html/配下とかが良いかと思います.

もちろん,応用してSSLもできます.
そのときは,<VirtualHost *:443>を追加して,その中に証明書のkeyとか中間証明書とかをSSLCertificateFileとかSSLCertificateKeyFileとかに指定する必要があります.mod_sslも必要です.

あと,もともと運営しているサイトが合ったとしたら,そのドメインもこの際に<VirtualHost *:80>内に定義してしまったほうが良さそうです.
httpd.conf内にDocumentRootを記述しちゃうと,メインドメインに直で飛ばされてしまうということが多々ありました...

動作確認

httpdをリロードします.

systemctl restart httpd

これでアクセスしてみて確認します.

ハマったこと

apacheの設定で結構ハマってしまいました.
httpd.confとかデフォルトのまま使ったり,色々なモジュールをインストールしていると,自動生成されるファイルのせいでうまく行かなかったりと色々大変でした...
やっぱりデフォルトのファイルはバックアップしといて,自分で1つ1つ調べながら設定ファイルも1から作るのが手っ取り早いですね.
僕はまだ全部勉強しきれていないですが,これから頑張ろうかと思います.

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「環境構築メモ」 create-react-app

自分への備忘録

Windows10のVagrant環境下でCentOS7を立ち上げて、create-react-appを実行できるようにします。
Vagrant initが実行できる状態から始めます。

VagrantでCentOS7をインストールする。

$ vagrant init centos/7

CentOS7を立ち上げて接続する。

(vagrantコマンドで接続する場合)

$ vagrant up
$ vagrant ssh

vimをインストールする。

$ sudo yum -y install git gcc ncurses-devel
$ git clone https://github.com/vim/vim.git
$ cd vim/src
$ make
$ sudo make install

nvmとnodejsをインストールする。

$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.2/install.sh | bash
$ source ~/.bashrc
$ command -v nvm
nvm
$ nvm install --lts
$ node --version

yarnをインストールする。

$ curl -o- -L https://yarnpkg.com/install.sh | bash
$ source ~/.bashrc
$ yarn --version

gitのインストール

$ sudo yum remove git
$ sudo yum -y install wget
$ sudo wget https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.9.5.tar.gz
$ sudo tar xzvf git-2.9.5.tar.gz
$ sudo yum -y install openssl-devel curl-devel expat-devel perl-CPAN
$ cd git-2.9.5
$ sudo make prefix=/usr all
$ sudo make prefix=/usr install
$ git --version

(Option)BashプロンプトにGitのブランチを表示する。

git-prompt.shの場所を確認する。

$ find ~/ -name git-prompt.sh
/home/vagrant/git-2.9.5/contrib/completion/git-prompt.sh

プロンプトの設定を.bashrcに追加する。

~/.bashrc
source /home/vagrant/git-2.9.5/contrib/completion/git-prompt.sh
PS1="\u@\h:\W$(__git_ps1)\$ "

.bashrcの設定をBashに反映する。

~/.bashrc
$ source ~/.bashrc

create-react-app

$ mkdir -p ~/environment/react-app
$ cd ~/environment/react-app
$ npx create-react-app my-app
$ cd my-app
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Form要素以外をクリックした時にイベントを発火させる方法

環境

  • react:v16.12.0
  • コマンドラインツール:create-react-app

完成形

qiita-1.gif

リポジトリ

実装手順

onChangeイベントで値が更新されるFormが実装されているアプリに実装する順序を説明します。

body全体にクリックイベントにより発火する関数の設定

CommentComponentがunmountする時に、イベントを削除する。

Comment.jsx
useEffect(() => {
  if(isActive)
    document.body.addEventListener("click", e => handleClick(e));
  return () => {
    document.body.removeEventListener("click", e => handleClick(e));
  };
}, [isActive]);
/* 
  isActiveは編集中orテキストを表示しているかの状態を管理している
  true  : 編集中
  false : 表示のみ
*/

クリックイベントにより発火する関数の定義

ここで注意するべきはif文で処理を飛ばしている箇所です。

Comment入力部分(textarea)と保存を完了する部分(button)がクリックされた時はすでに別の処理が割り当てられているのでこの関数を実行してはいけません。

よって関数では引数にイベントを渡し、クリックしたDOMがe.targetそれ以外であるか判定します。
それ以外であるならば状態をテキストの表示のみに戻します。

Comment.jsx
/* 
* textArea.current  ... Comment入力部分(textarea)のDOMの参照
* submitBtn.current ... Commentを保存する部分(button)のDOMの参照
*/ 

const handleClick = e => {
    if (textArea.current === e.target || submitBtn.current === e.target) return;
    toggleActive(false);
};

Comment関連部分の参照を保持する

ReactHooksのuseRefを利用する。

Comment入力部分(textarea)と保存を完了する部分(button)の参照を得ることができ、上記の条件分岐を利用することが出来る。

Comment.jsx
const textArea = useRef(null);
const submitBtn = useRef(null);

<form>
 <textarea
  ref={textArea}
 />
 <br />
 <button
  type="submit"
  ref={submitBtn}
 >
  保存
 </button>
</form>

完成

元のForm
https://github.com/taiki-fw/ReactHooksForm/tree/master

完成後のForm
https://github.com/taiki-fw/ReactHooksForm/tree/1feat/FireEvent

あとがき

今回の場合だとFormの値保存を別箇所で行なっているので特に問題ではないのですが、ReduxなどのActionなどを利用される際は、stateが変更されるまでの遅延に注意して頂きたいです。
実際にやらかしました...

DOMに直接Clickイベントで発火する関数を設定しているのであまり推奨はされないかもしれないです。
参考程度になれば幸いです。
また、間違いなどあれば指摘して頂けると幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む