Ruby
Rails
reactjs

Rails × React で Rails の scaffold ライクなアプリケーションを実装 - 削除・検索 -

Rails×ReactでRailsのscaffoldで生成されるような簡単なcrudができるアプリケーションを実装したので、備忘録として残しておこうと思います。

アプリケーションの概要

タイトルと本文のあるメッセージをCRUD+絞り込み検索するアプリケーション

やったこと

(1) 環境の構築
(2) メッセージ一覧を表示する。
(3) 新規メッセージを作成する。
(4) メッセージ詳細の表示、編集、更新をする。
(5) メッセージを削除する、メッセージを検索する。 (本エントリ)

やってないこと

  • Redux、MobXのステート管理のためのフレームワークの導入
  • テストの実装
  • CSSの実装
  • セキュリティやデータの整合性を確認する実装
  • 用語や実装の解説

用語や実装の解説については、参考にさせていただいたサイトのリンクを各実装毎に貼っておりますので、そちらをご確認下さい。
参考サイトの作成者の皆様ありがとうございます。

【ご一読いただくにあたって】
・間違いがありましたらコメントにて教えて下さい。(まだまだ勉強中です。)

メッセージの削除

削除するフローは以下のようなものをイメージしています。
1. [destroy] をクリックするとconfirmメッセージが出てくる。
2. OKをクリックすると、メッセージが削除され、一覧が表示される。
3. キャンセルをクリックすると削除されずに、一覧が表示される。

React

List.jsを修正

List.jsを以下のように修正します。

./react_sample/frontend/List.js
~ 省略 ~
const REQUEST_URL_DELETE = 'http://localhost:3030/messages/'

class List extends Component {
  ~ 省略 ~
 hundleDestroy = (event, message_id) => {
    event.preventDefault();
    // stateのmessagesを取得する
    const messages = this.state.messages;
    // 渡ってきたmessage_idとmessage.idが等しいメッセージをspliceする
    const removeMessage = (msg, i) => {
      if (msg.id==message_id) messages.splice(i,1);
    }
    messages.some(removeMessage);
    // 削除されなかったmessagesをsetStateする
    this.setState({
      messages: messages
    })

    fetch(REQUEST_URL_DELETE + message_id, {
      method: 'DELETE',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json'
      }
    })
    .then((response) => {
      if (!response.ok) {
        throw Error(response.statusText);
      }
      this.props.history.push('/');
    })
    .catch((err) => {
      console.error(err);
    });
  }
  render(){
    const { messages } = this.state;
    return(
      <div>
        <table>
          <thead>
            <tr>
              <th>Title</th>
              <th>Content</th>
              <th></th>
              <th></th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            { messages.map((message) => {
              return (
               <tr key={ message.id }>
                 <td>{ message.title }</td>
                 <td>{ message.content }</td>
                 <td><Link to={ '/message/' + message.id }>Show</Link></td>
                 <td><Link to={ '/message/' + message.id + '/edit' }>Edit</Link></td>
                 // javascript:void(0)で遷移を無効化する
                 // react-confirm というモジュールもあるが今回は window.confirm でお手軽に対応する
                 <td><a href="javascript:void(0)" onClick={ () => { if (window.confirm('本当に削除しますか?')) this.hundleDestroy(event, message.id) } }>destroy</a></td>
               </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    );
  }
}
~ 省略 ~
Rails(APIサーバ)側でのデータの削除

それでは、次にRails(APIサーバ)を修正してidが渡ってきたときに、該当するメッセージを削除するようにします。
今回の記事のメインはReact側なので、Rails側の解説は省略したいと思います。

./react_sample/app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  before_action :set_message, only: [:show, :update, :destroy]

  ~ 省略 ~

  def destroy
    @message.destroy!
  end

  ~ 省略 ~
end
./react_sample/config/routes.rb
Rails.application.routes.draw do
  resources :messages, only: [:index, :create, :show, :update, :destroy], format: 'json'
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

それでは挙動を確認します。
AwesomeScreenshot-2018-02-15T01-03-10-064Z.gif
イメージ上ではconfirmのメッセージが表示されていませんが、ブラウザ上では表示されているかと思います。

メッセージの検索

メッセージの検索は以下の様なフローをイメージしています。
1. titleを検索する。
2. 検索窓があってそこに検索ワードを入力するとヒットしたtitleをもつメッセージのみが一覧で表示される。

React

List.jsを修正

List.jsに検索窓を実装します。
コンポーネントを切っても良いかもしれませんが、今回は切らずに実装したいと思います。

./react_sample/frontend/src/List.js
~ 省略 ~
const REQUEST_URL_SEARCH = 'http://localhost:3030/messages/search'

class List extends Component {
  constructor(props) {
    super(props)
    this.state = {
      messages: [],
      // searchTitleというstateを追加する
      searchTitle: '',
    };
  }

  handleInputSearchTitle = (event) => {
    event.preventDefault();
    const searchTitle = event.target.value;
    // 検索ワードをRails側にPOSTで飛ばす
    fetch(REQUEST_URL_SEARCH, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        message: { search: searchTitle }
      })
    })
    .then((response) => {
      if (!response.ok) {
        throw Error(response.statusText);
      }
      return response;
    })
    .then((response) => response.json())
    .then((responseData) => {
      // 検索でヒットしたデータをmessagesにセットする
      this.setState({
        messages: responseData
      })
    })
    .catch((err) => {
      console.error(err);
    });

    // 検索窓に検索ワードをセットする
    this.setState({
      searchTitle: searchTitle
    });
  }

  render(){
    const { messages } = this.state;
    return(
      <div>
        <div>
          <label>SearchTitle:</label>
          <input type="text" name="searchTitle" value={ this.state.searchTitle } onChange={ this.handleInputSearchTitle } />
        </div>
        <table>
          ~ 省略 ~
        </table>
      </div>
    );
  }
}
export default List;

Rails

メッセージの検索(APIサーバ)

今回の記事のメインはReact側なので、Rails側の解説は省略したいと思います。

./react_sample/config/routes.rb
Rails.application.routes.draw do
  resources :messages, only: [:index, :create, :show, :update, :destroy], format: 'json'  do
    post 'search' => "messages#search", on: :collection, format: 'json'
  end
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end
./react_sample/app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  ~ 省略 ~

  def search
    messages = Message.search_by_title(search_params[:search])
    render json: messages
  end

  private

~ 省略 ~

  def search_params
    params.require(:message).permit(:search)
  end
end
./react_sample/app/models/message.rb
class Message < ApplicationRecord
  # validates を追加するのを忘れていたので今更だけど追加する
  validates :title, presence: true

  def self.search_by_title(key)
    where("title LIKE :title", {title: "#{key}%"})
  end
end

Rails側の実装を完了したら、localhost:4000を確認してみます。
以下のような挙動になっていれば成功です。
AwesomeScreenshot-2018-02-15T11-26-49-449Z.gif

Titleのバリデーション

このままだとTitleが空の状態で[submit]をクリックした時に、エラーも何も表示されません。
scaffoldだとこんな感じのエラーが出ます。
AwesomeScreenshot-2018-02-15T11-39-52-109Z.gif

これに似せてエラーを表示させることもできるのですが、今回はReactっぽく title が空の場合は[submit]がクリックできないという実装をしてみたいと思います。 

./react_sample/frontend/src/Form.js
import React, { Component } from 'react';

class Form extends Component {
  constructor(props){
    super(props);
    // Form.jsにclickableというstateを追加する 初期値は'disabled'
    this.state = {
      clickable: 'disabled'
    };
  }

  // Form.jsの内のhandleInputValueで、親のparentInputValueとsetStateの2つの処理を実行する
  handleInputValue = (event) => {
    this.props.parentInputValue(event);
    // event.target.value(=title)が空かどうか判定し、'disabled' or false をセットする
    this.setState({
      clickable: event.target.value.trim() == '' ? 'disabled' : false
    });
  }

  render() {
    const { title, content, submitValue } = this.props;
    const { clickable } = this.state;
    return (
      <div>
        <form onSubmit={ this.props.parentSubmit }>
          <label>title:</label>
          // parentInputValueではなく、From.jsのhandleInputValueを一枚かませる
          <input type="text" name="title" value={ title } onChange={ this.handleInputValue } />
          <label>content:</label>
          <textarea name="content" value={ content } onChange={ this.handleInputValue} />
          <input type="submit" value={ submitValue } disabled={ clickable } />
        </form>
      </div>
    );
  }
}

export default Form;

localhost:4000を確認します。
AwesomeScreenshot-2018-02-16T00-31-59-760Z.gif
少し見にくいですが、[submit]がデフォルトではdisabledになっていて、文字を入力するとabledになっていてることが確認できるかと思います。trim()をしているので、全角・半角スペースのみの場合もdisabledのままなのが確認できるかと思います。

検索結果がなかった場合の表示

このままだと検索結果がなかった時に、何もメッセージが表示されないので、検索結果がなかった場合は「該当するメッセージはありません。」というメッセージを表示したいと思います。
新しくTableコンポーネントを作って、messagesの数で表示/非表示を分岐します。
さらにmessagesの数が0の場合にのみ、「該当するメッセージはありません。」を表示します。
以下のように修正します。

./react_sample/frontend/src/List.js
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
// Tableコンポーネントを新規でインポートする
import Table from './Table';

~ 省略 ~

class List extends Component {
  ~ 省略 ~
  render(){
    const { messages, searchTitle } = this.state;
    return(
      <div>
        <div>
          <label>SearchTitle:</label>
          <input type="text" name="searchTitle" value={ searchTitle } onChange={ this.handleInputSearchTitle } />
        </div>
        // searchTitleが存在していて、messages.length==0 だったら「該当するメッセージはありません。」のメッセージ
        { searchTitle && messages.length == 0 && <div>該当するメッセージはありません。</div> }
        // componentWillMountの処理が終わるまで「Loading...」のメッセージ
        // 通信が完了するまでに初期値で一瞬描画されてしまうらしい...解決策をご存知の方コメントください。
        { messages.length == 0 && <div>Loading...</div> }
        { messages.length > 0 && <Table messages={ messages } parentHundleDestroy={ this.hundleDestroy } /> }
      </div>
    );
  }
}
export default List;
./react_sample/frontend/src/Table.js
import React, { Component } from 'react';
import { Link } from 'react-router-dom';

class Table extends Component {
  constructor(props) {
    super(props)
  }

  render(){
    const { messages, parentHundleDestroy } = this.props;

    return(
      <table>
        <thead>
          <tr>
            <th>Title</th>
            <th>Content</th>
            <th></th>
            <th></th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          { messages.map((message) => {
            return (
             <tr key={ message.id }>
               <td>{ message.title }</td>
               <td>{ message.content }</td>
               <td><Link to={ '/message/' + message.id }>Show</Link></td>
               <td><Link to={ '/message/' + message.id + '/edit' }>Edit</Link></td>                 
               <td><a href="javascript:void(0)" onClick={ () => { if (window.confirm('本当に削除しますか?')) parentHundleDestroy(event, message.id) } }>destroy</a></td>
             </tr>
            );
          }) }
        </tbody>
      </table>
    );
  }
}

export default Table;

localhost:4000を確認してみます。
AwesomeScreenshot-2018-02-16T01-21-23-431Z.gif

以上で、全ての実装が完了しました。
ご覧いただきありがとうございます。

基本的な箇所しか実装していないので、以下の項目も実装した方がベターかと思います。

  • 引数(props)の入力チェックをする
  • ディレクリの整理やファイルの命名をちゃんとする
    • New.js等は何の new なのかファイルが増えてくると分からなくなる可能性が高いので、ディレクトリを切るか、ファイル名を変更した方が良いと思います。
  • リファクタリング
    • REQUEST_URLを各ファイルに記述していますが、configファイル?的なものにまとめた方が良いと思います。

他にも「こうした方が良い」というものはあるかもしれませんが、そちらについてはコメントいただけばと思います。

次のチャレンジとして、このアプリケーションをReduxを用いて実装したらどうなるかを実装予定です。
完了したらまたQiitaに掲載しようと思います。