記事について
前回まででUIをそれっぽくしてみましたが、そろそろ付箋の中身を書き換えたくなってきました。
ということで、やってみます。
関連する記事
今までの記事です。
その1(環境構築〜モデル作成編)
その2(API作成編)
その3(UI作成編1)
その4(UI作成編2)
その5(react-contenteditable導入編)
おまけ(モデルのテスト編)
divのまま編集してみたい。
またしても手段を目的として面倒臭いことになるパターンです。
これまで、タスクのタイトルや説明などをdivタグで作ってきてしまったので、そのまま編集可能にできたら修正が少なくなって楽かもしれない?
divタグを編集可能にするにはcontentEditable=true
という属性を使います。
参考(MDN Web Docs - contentEditable)
なんだ、簡単そうだ。
早速、以前作ったSticky.jsのタイトル表示部分で試してみます。
// contentEditable="true"を追加
<div className="TaskTitle" contentEditable="true">{this.props.task.title}</div>
実際にブラウザで表示するとどうなるか!?
Reactから「contentEditableはあなたの責任で使いなさいよ」という警告メッセージが出ていました。
はーい、がんばりまーす。
って言うほど頑張れないので、メッセージ出さない方法ないかなぁ?
と調べた結果、react-contenteditableというものを見つけたので、使ってみたいと思います。
react-contenteditableの追加
以下のとおり、yarnで追加できました。
bundle exec yarn add react-contenteditable
react-contenteditableを使ってみる。
インストールができたので、ソースコードに追加してみます。
対象は、先ほどの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 |
動作確認
おぉ!編集できる!!
ならば、この値を取得して保存する流れを作ってみます。
CotentEditableで作成した要素から値を取得する。
reactで編集可能な要素の値を取得するには、onChangeを使ってstateに値を保存するなどの方法が考えられますが、ここではrefを使った方法を使ってみます。
ContentEditableコンポーネントでは、innerRefというプロパティを使うことで実現します。
具体的には以下のようなソースコードとなります。(変更箇所に1〜5の番号をふってあります。)
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>
{ /* 以後、色々省略 */ }
);
}
}
表示する要素が増えてしまったので、ついでに、スタイルシートも追加しておきます。
// 付箋のフッター
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;
}
試してみます。
タイトルを編集(hogegeってしたり、updatedってしたり)して、"save"を押してみると。
おぉ、ログが出た。
実際にDBに保存させてみる。
編集した値が取得できることが分かったので、実際にDBに反映する流れを作ってみます。
シーケンス図で考える。
Railsで作ってるAPIにて
@task = Task.find(params[:task][:id]);
@task.update(params[:task]);
みたいな感じになれば良いわけですが。
誰がこのAPIを呼ぶ?
みたいなことを考えねばなりません。
で、以前作っているWhiteBoard.jsですでにAPI呼び出しを行っているので、
こいつにまとめてしまおう。
ちょっと複雑になるので、シーケンス図を起こしてみました。
図にするとわかりやすいですね。
最終的にWhiteBoardのonTaskSave()が呼ばれるようにpropsを引き継いでいけば実装できそうです。
APIを用意してみる。
コントローラの準備
まずは、コントローラの準備をします。
# タスク情報更新処理
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を用意します。
# 更新後のデータを返せば良いかなぁ。と。
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
エラー時はこんな感じでいいかしらね。
# エラーメッセージをJSON形式で返します。
json.errors @errmsgs do | msg |
json.message(msg);
end
ルーティングを追加する。
コントローラにアクションを追加したので、ルーティングを追加します。
updateだから、putで。
namespace :api do
put 'tasks/update'
end
とりあえずテスト
ブラウザでいちいち動作確認するのめんどいので、テストコードを追加しときます。
エラー系は、とりあえず後で。。
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()呼び出し時の引数を修正します。
<%
# 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()"をコールバックしてもらうための処理を追加します。
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というメソッドを追加してあげます。
// ユーザ毎の箱を表示します。
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の中身をちゃんと書いてみます。
// 保存ボタンクリックイベントハンドラ
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 "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
まとめ
- Reactでレンダリングした要素でcontentEditable=trueを使いたい場合は、react-contenteditableを使うと良い。
- しかし、今回の場合はinputタグでスタイル指定してそれっぽく見せたほうが良かったのではないかと。
- ContentEditableで描画した部分も普通にCapybaraでテストできる。