3
5

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 3 years have passed since last update.

Rails6+Reactで付箋アプリっぽいページを作ってみた。5(react-contenteditable導入編)

Last updated at Posted at 2020-05-25

記事について

前回まででUIをそれっぽくしてみましたが、そろそろ付箋の中身を書き換えたくなってきました。
ということで、やってみます。

関連する記事

今までの記事です。
その1(環境構築〜モデル作成編)
その2(API作成編)
その3(UI作成編1)
その4(UI作成編2)
その5(react-contenteditable導入編)
おまけ(モデルのテスト編)

divのまま編集してみたい。

またしても手段を目的として面倒臭いことになるパターンです。
これまで、タスクのタイトルや説明などをdivタグで作ってきてしまったので、そのまま編集可能にできたら修正が少なくなって楽かもしれない?

divタグを編集可能にするにはcontentEditable=trueという属性を使います。
参考(MDN Web Docs - contentEditable)

なんだ、簡単そうだ。
早速、以前作ったSticky.jsのタイトル表示部分で試してみます。

app/javascript/components/Sticky.js(変更箇所のみ)
// contentEditable="true"を追加
<div className="TaskTitle" contentEditable="true">{this.props.task.title}</div>

実際にブラウザで表示するとどうなるか!?

おやおや???
スクリーンショット 2020-05-23 20.26.49.png

Reactから「contentEditableはあなたの責任で使いなさいよ」という警告メッセージが出ていました。

はーい、がんばりまーす。
って言うほど頑張れないので、メッセージ出さない方法ないかなぁ?
と調べた結果、react-contenteditableというものを見つけたので、使ってみたいと思います。

react-contenteditableの追加

以下のとおり、yarnで追加できました。

shell
bundle exec yarn add react-contenteditable

react-contenteditableを使ってみる。

インストールができたので、ソースコードに追加してみます。
対象は、先ほどのSticky.jsです。

ソースコードの変更

app/javascript/components/Sticky.js
import React from "react"
import PropTypes from "prop-types"

// ContentEditableコンポーネントをimportします。
import ContentEditable from "react-contenteditable"

class Sticky extends React.Component {
  // (中略)
  // レンダラです。
  render () {
    return (
          { /* 先ほどのdivをContentEditableに変更します。 */ }
          { /* 表示内容はhtml=で指定します。 */ }
          <ContentEditable className="TaskTitle" html={this.props.task.title} />
          { /* 以後、色々省略 */ }

    );
  }
}

export default Sticky

コメントしてあるとおり、最初のimport文でContentEditableをインポートし、render()内で編集可能にしたい要素をContentEditableに置き換えます。

なお、propsとして指定できるのは、以下のもののようです。

お名前 ご説明
innerRef refに指定する値(あとで値の取得等に使うためのものです。) Object または Function
html 要素の値です。名前の通りhtmlが利用できます。必須要素です。(null値はエラーになる。。) String
disabled trueにすると編集不可にできます。 Boolean
onChange innerHTMLの内容が変更されたときに呼び出されるハンドラを指定します。 Function
onBlur フォーカスが離れた時のハンドラです。 Function
onKeyUp キーを離した際のハンドラ Function
onKeyDown キーを押した際のハンドラ Function
className そのまんまクラス名です。 String
style スタイルも指定できるんですね。 Object

動作確認

ソースコードを直したら、早速反映されたか確認してみます。
スクリーンショット 2020-05-24 10.33.18.png

おぉ!編集できる!!
ならば、この値を取得して保存する流れを作ってみます。

CotentEditableで作成した要素から値を取得する。

reactで編集可能な要素の値を取得するには、onChangeを使ってstateに値を保存するなどの方法が考えられますが、ここではrefを使った方法を使ってみます。
ContentEditableコンポーネントでは、innerRefというプロパティを使うことで実現します。

具体的には以下のようなソースコードとなります。(変更箇所に1〜5の番号をふってあります。)

app/javascript/components/Sticky.js
import React from "react"
import PropTypes from "prop-types"

// ContentEditableコンポーネントをimportします。
import ContentEditable from "react-contenteditable"

class Sticky extends React.Component {
  // コンストラクタです。
  constructor(props) {
    // おまじないです。
    super(props);

    /*
    ((中略))
    */

    // createRefで要素参照用のインスタンス変数を作ります。 -- 1
    this.taskTitle = React.createRef();

    // 保存ボタンクリック時のハンドラをバインド -- 2
    this.onSaveButtonClick = this.onSaveButtonClick.bind(this);

  }

  /*
   ((さらに中略))
  */

  // 保存ボタンクリックイベントハンドラ -- 3
  onSaveButtonClick(event){
    // 以下のように"current.textContent"で要素の値が取得できます。
    // ここでは、とりあえず取得した値をconsole.logに吐いてみます。
    console.log(this.taskTitle.current.textContent);
  }

  // レンダラです。
  render () {
    return (
          { /* innerRef={this.taskTitle}という記述を追加しました。 -- 4 */ }
          <ContentEditable className="TaskTitle" html={this.props.task.title} innerRef={this.taskTitle} />
          { /* 保存ボタンとして使う要素を追加します。 -- 5 */ }
          <div className="TaskFooter">
            <div className="TaskUpdateButton" onClick={this.onUpdateButtonClick} >save</div>
          </div>
          { /* 以後、色々省略 */ }

    );
  }
}

表示する要素が増えてしまったので、ついでに、スタイルシートも追加しておきます。

app/assets/stylesheets/white_board.scss
// 付箋のフッター
div.TaskFooter {
  grid-row: 8;
  grid-column: 1 / 3;
  display: flex;
  justify-content: space-between;
}

// 保存ボタン
div.TaskUpdateButton {
  color: #0000FF;
  font-weigt: bold;
  font-size: 10px;
  text-align: right;
}

試してみます。
スクリーンショット 2020-05-24 15.13.44.png
タイトルを編集(hogegeってしたり、updatedってしたり)して、"save"を押してみると。
おぉ、ログが出た。
スクリーンショット 2020-05-24 15.04.09.png

実際にDBに保存させてみる。

編集した値が取得できることが分かったので、実際にDBに反映する流れを作ってみます。

シーケンス図で考える。

Railsで作ってるAPIにて

APIの処理イメージ
@task = Task.find(params[:task][:id]);
@task.update(params[:task]);

みたいな感じになれば良いわけですが。
誰がこのAPIを呼ぶ?
みたいなことを考えねばなりません。

で、以前作っているWhiteBoard.jsですでにAPI呼び出しを行っているので、
こいつにまとめてしまおう。
ちょっと複雑になるので、シーケンス図を起こしてみました。
スクリーンショット 2020-05-24 16.28.50.png

図にするとわかりやすいですね。
最終的にWhiteBoardのonTaskSave()が呼ばれるようにpropsを引き継いでいけば実装できそうです。

APIを用意してみる。

コントローラの準備

まずは、コントローラの準備をします。

app/controllers/api/tasks_controller.rb
  # タスク情報更新処理
  def update
    # エラーメッセージリストを初期化
    @errmsgs = [];

    # 無害化したパラメータを取得
    updparam = update_params();

    begin
      # タスク情報を取得
      @task = Task.find(updparam[:id]);

      # アップデート実行
      if ! @task.update(updparam) then
        # エラー時はエラーメッセージリストにエラーメッセージを追加しておいて
        @task.errors.each do | key |
          @task.errors[key].each do | message |
            @errmsgs.push(key.to_s + ":" + message);
          end
        end
        # エラー表示用レンダラーを指定します。
        render :show_error
      end

    rescue => ex
      # こちらも、例外情報をメッセージリストに追加して
      @errmsgs.push(ex.to_s);
      # エラー表示用レンダラーを指定します。
      render :show_error
    end

    # 更新がうまくいったら、json形式で更新結果をお知らせするので、
    # app/views/api/tasks/update.json.jbuilderを用意しておきます。
    # Railsのデフォルト動作では、コントローラと同じ名前のviewを表示しようとするので、"render :update"などと書く必要はありません。
    # 必要はありませんが、知らないと分からないですよね。。

  end

  private
    # update時パラメータの取得
    def update_params()
      return params.require(:task).permit(:id, :title, :description, :due_date, :user_id);
    end

Viewを作る

次にviewを用意します。

app/views/api/tasks/update.json.jbuilder
# 更新後のデータを返せば良いかなぁ。と。
json.task do
  json.id(@task.id);
  json.title(@task.title);
  json.description(@task.description);
  json.due_date(@task.due_date.strftime("%Y-%m-%d"));
  json.user_id(@task.user.id);
end

エラー時はこんな感じでいいかしらね。

app/views/api/tasks/show_error.json.jbuilder
# エラーメッセージをJSON形式で返します。
json.errors @errmsgs do | msg |
  json.message(msg);
end

ルーティングを追加する。

コントローラにアクションを追加したので、ルーティングを追加します。
updateだから、putで。

config/route.rb
namespace :api do
  put 'tasks/update'
end

とりあえずテスト

ブラウザでいちいち動作確認するのめんどいので、テストコードを追加しときます。
エラー系は、とりあえず後で。。

test/controllers/api/tasks_controller_test.rb
  test "should success to update" do
    task2 = tasks(:task2);
    due_date = Date.new(1894,2,11);

    put(api_tasks_update_url(:json), params: { task: { id: task2.id, title: "title-updated", description: "description-updated", due_date: due_date }});
 
    assert_response :success

    json_data = ActiveSupport::JSON.decode(@response.body);
    assert_equal(task2.id, json_data['task']['id']);
    assert_equal("title-updated", json_data['task']['title']);
    assert_equal("description-updated", json_data['task']['description']);
    assert_equal(due_date, Date.parse(json_data['task']['due_date']));
  end

フロントエンド側に処理を追加していく

Rails側の修正

Reactで作ったコンポーネントから、追加したアクション(今回はtasks/update)を呼び出してもらうため、react_compnent()呼び出し時の引数を修正します。

app/views/white_board/main.html.erb
<%
# update_task_url: api_tasks_update_url(:json)
# を追加しました。
%>
<%= react_component('WhiteBoard', { 
  title: 'You can let others do your task', 
  user_tasks_url: api_users_user_task_list_url(:json), 
  switch_user_url: api_tasks_switch_user_url(:json),
  update_task_url: api_tasks_update_url(:json),
  secure_token: form_authenticity_token
}) %>

WhiteBoard.jsの修正

ここには、updateを呼び出すための処理の追加と、自身の"onTaskSave()"をコールバックしてもらうための処理を追加します。

app/javascript/components/WhiteBoard.js
import React from "react"
import PropTypes from "prop-types"

// 自作コンポーネントはこのように呼び出せます。
import UserBox from "./UserBox"

// WhiteBoardコンポーネントの定義
class WhiteBoard extends React.Component {
  // コンストラクタ
  constructor(props) {
    // おまじない
    super(props);

    // いくつか省略

    // イベントハンドラのバインド
    this.onTaskSave = this.onTaskSave.bind(this); // タスク更新時の処理
  }

  // またまた省略

  // タスク更新イベント処理
  onTaskSave(task) {
    // タスク更新処理(tasks/update)を呼び出します。
    fetch(this.props.update_task_url, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
        "X-CSRF-Token": this.props.secure_token
      },
      body: JSON.stringify(task)
    })
    .then(response => response.json())
    .then(json => {
      /* 実は返されたデータの使い道を見失った。。 */
      console.log(JSON.stringify(json));
    })
    .catch(error_response => console.log(error_response));
  }

  // レンダリング
  // UserBoxのpropsにonTaskSaveを追加しました。
  render () {
    return (
      <React.Fragment>
        <div id="WhiteBoardFlame">
          <div id="WhiteBoardTitle">{this.props.title}</div>
          <div id="AddUserButton" onClick={this.onAddUserClick} >+Add User</div>
          { this.state.show_add_user && <UserForm onAddButtonClick={this.ExecuteAddUser} onCancelButtonClick={this.CancelAddUser} /> }
          <div id="WhiteBoard">
            { ! this.state.loading && this.state.users.map((user) => <UserBox user={user} key={user.id} dropHandlerRegister={this.dropHandlerRegister} onTaskDrop={this.onTaskDrop} onTaskSave={this.onTaskSave} /> )}
          </div>
        </div>
      </React.Fragment>
    );
  }
}

UserBox.jsの修正

ここにも、onTaskSaveというメソッドを追加してあげます。

app/javascript/components/UserBox.js
// ユーザ毎の箱を表示します。
class UserBox extends React.Component {

  // コンストラクタです。
  constructor(props) {
    // おまじない
    super(props);

    // 色々省略
    // イベントハンドラのバインド
    this.onTaskSave = this.onTaskSave.bind(this); // <-- onTaskSaveメソッドのバインドを追加します。

  }

  // タスク更新処理
  onTaskSave(task) {
    // stateに保持しているタスクデータを更新
    var tasks = this.state.tasks;
    tasks[task.id] = task;

    // さらにコールバック(この中でDB反映が行われます。)
    this.props.onTaskSave(task);

    // stateを更新
    this.setState({tasks: tasks});

  }

  // レンダリング
  render () {
    return (
      <React.Fragment>
        <div id={"user-" + this.props.user.id} className="UserBox" onDrop={this.onDrop} onDragOver={this.preventDefault} >
          <div className="UserName">{this.props.user.name}</div>
          <div className="TaskArea">
            {
              Object.keys(this.state.tasks).map(
               (key) => <Sticky 
                          user_id={this.props.user.id} 
                          task={ this.state.tasks[key] } 
                          key={ key }
                          onTaskSave={this.onTaskSave} /> 
              ) 
            }
          </div>
        </div>
      </React.Fragment>
    );
  }

Sticky.jsの修正

やっとここまできましたー。
最初に書いたonSaveButtonClickの中身をちゃんと書いてみます。

app/javascript/components/Sticky.js
  // 保存ボタンクリックイベントハンドラ
  onSaveButtonClick(event){
    // 以下のように"current.textContent"で取得した値を使って、
    // UserBoxのonTaskSave()を呼び出して(コールバックして)あげます。
    var task = this.props.task;
    task.title = this.taskTitle.current.textContent;
    this.props.onTaskSave(task);
    
    // title以外の値も同じように更新できます。

  }

テストじゃぁ!!

ここでも、しつこくテストを追加していきます。
UI側は、systemテストでやるのが楽です。

test/system/whiteboards_test.rb
  test "sticky is able to update" do
    # fixutreで登録したデータを取得しておきます。
    task1 = tasks(:task1);

    # divのidを設定します。
    task1_id = "task-" + task1.id.to_s;

    # white_board/mainを開く。
    visit white_board_main_url;

    # task1の要素を取得します。
    div_task1 = find(id: task1_id);

    # title要素を取得します。
    div_title1 = div_task1.find("div", class: "TaskTitle");

    # title要素の中身を書き換えます。(text=でできるかと思ったら、setでした。)
    div_title1.set("task1_updated!!");

    # saveボタンを押しちゃいます。
    div_task1.find("div", class: "TaskUpdateButton").click();

    # 表示されている値が更新されていますように。
    assert_equal("task1_updated!!", div_title1.text);

    # DBに反映されていますように!!
    task1_updated = Task.find(task1.id);
    assert_equal("task1_updated!!", task1_updated.title); 

  end

まとめ

  1. Reactでレンダリングした要素でcontentEditable=trueを使いたい場合は、react-contenteditableを使うと良い。
  2. しかし、今回の場合はinputタグでスタイル指定してそれっぽく見せたほうが良かったのではないかと。
  3. ContentEditableで描画した部分も普通にCapybaraでテストできる。
3
5
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
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?