この記事について
前回までRailsを利用してバックエンド側を作り込んでみたので、フロントエンド側の実装に入っていきたいと思います。
果たして、分量が多くなりすぎるのではないかと心配してますが、きっと多くなります。
関連する記事
書いているうちに分量がすごくなって記事を分割したので、リンク先をまとめておきます。
その1(環境構築〜モデル作成編)
その2(API作成編)
その3(UI作成編1)
その4(UI作成編2)
その5(react-contenteditable導入編)
おまけ(モデルのテスト編)
まずは基点となるコンポーネントを追加するのじゃ。
react-railsを使う場合、viewのテンプレートで以下のようにreact_component()メソッドを呼んで、Reactコンポーネントのレンダリングを行います。
<%= react_component(コンポーネント名, {propsを表すハッシュ}) %>
ここで、呼び出すコンポーネント名ですが、うちの職場で付箋がたくさん貼ってある場所といえば、ホワイトボードなので、WhiteBoardという名前で作ることにしました。
ということで、以下のコマンドで最初のコンポーネントを作ってみます。
# propsとしてtitleを受け取るWhiteBoardという名前のReactコンポーネントを作ってください。という意味です。
# propsは後から、色々追加になると思いますが、最初の段階ではなかなか思いつかないもの。。
bundle exec rails g react:component WhiteBoard title:string
うまくいったら、実際に表示できるのか試しておきます。
。。。そういえば、APIのことばかりやってて、UIのためのコントローラがない!!
ので、追加します。
# コントローラ名もWhiteBoardとし、アクション名は適当に"main"としてみました。
bundle exec rails g controller WhiteBoard main
続いて、作成されたmain.html.erbを以下のように書き換えて、WhiteBoardコンポーネントを呼び出してもらうようにします。
<%= react_component('WhiteBoard', { title: 'You can let others do your task' }) %>
ここで、一応動作確認してみます。
今まで、テストで確認してましたが、画面でみてみないと不安な事ってありますね。
# サーバを起動
bundle exec rails s
起動できたら、localhost:3000/white_board/mainにアクセスしてみます。
おぉ、ちょっと時間かかったけど表示できた。
確認できたら、Ctrl+Cでサーバを停止しちゃいます。
中身を書いていきます。
先ほどまでの手順でWhiteBoardコンポーネントというのを作成しています。
が、今回はさらに、以下のコンポーネントを追加します。
コンポーネント名 | 説明 |
---|---|
UserBox | ユーザ毎の枠(箱?) |
Sticky | タスクを表示するもの(付箋) |
それぞれ、以下のコマンドで追加できます。
bundle exec rails g react:component UserBox
bundle exec rails g react:component Sticky
WhiteBoardコンポーネントの実装
WhiteBoardでは、主にAPIとのやりとりと、UserBoxの表示までを行うようにしてみました。
実装は以下のようになりました。(長いです。)
import React from "react"
import PropTypes from "prop-types"
// 自作コンポーネントはこのように呼び出せます。
import UserBox from "./UserBox"
class WhiteBoard extends React.Component {
// コンストラクタ
constructor(props) {
// おまじない
super(props);
// stateの初期化
this.state = { users: {}, loading: true, dropHandlers: {}, need_render: false };
// イベントハンドラのバインド
this.dropHandlerRegister = this.dropHandlerRegister.bind(this);
this.onTaskDrop = this.onTaskDrop.bind(this);
}
// コンポーネントがマウントされたらデータの取得にいきます。
componentDidMount() {
this.getData();
}
// need_renderがtrueの場合だけレンダリングを行うようにしました。
shouldComponentUpdate(nextProps, nextState){
if (nextState.need_render) {
return true;
}
console.log("** skip rendering **");
return false;
}
// propsで指定されたURLに向かってユーザ毎のタスク一覧をくださいとリクエストを投げます。
getData() {
fetch(this.props.user_tasks_url)
.then((response) => response.json())
.then((json) => {
// うまくいったら表示データを更新します。
this.setState({users: json.users, loading: false, need_render: true});
})
.catch((response) => {
console.log('** error **');
})
}
// ユーザの変更をDBに通知します。
callSwitchUser(task_id, user_id) {
var switch_info = { switch_info: { task_id: task_id, user_id: user_id } };
// APIとして作成したswitch_userアクションを呼び出します。
// propでもらったCSRFトークンをリクエストヘッダに含めることで、更新リクエストを可能としています。
// エラー処理がログ吐くだけというお粗末なものですが、すいません。
fetch(this.props.switch_user_url, {
method: "PUT",
headers: {
"Content-Type": "application/json; charset=utf-8",
"X-CSRF-Token": this.props.secure_token
},
body: JSON.stringify(switch_info)
})
.then(response => response.json())
.then(json => console.log(JSON.stringify(json)))
.catch(error_response => console.log(error_response));
}
// 各UserBoxにStickyがドロップされた際の処理を登録する処理です。
dropHandlerRegister(user_id, func) {
var handlers = this.state.dropHandlers;
// 該当ユーザIDのハンドラが存在しなければ、stateに追加します。
// このstate変更による再レンダリングは不要なため、need_renerにはfalseを設定しておきます。
if ( ! handlers[user_id] ) {
handlers[user_id] = func;
this.setState({dropHandlers: handlers, need_render: false});
}
}
// Stickyがドロップされた際のイベント処理です。
onTaskDrop(prev_user_id, next_user_id, task) {
// 各UserBoxのハンドラを呼び出します。
Object.keys(this.state.dropHandlers).map((key) => {
this.state.dropHandlers[key](prev_user_id, next_user_id, task);
});
// swich_userアクションを呼んで更新を反映します。
this.callSwitchUser(task.id, next_user_id);
}
// レンダラーです。
// ユーザ毎にUserBoxを生成しています。
// dropHandlerRegsterはonTaskDrop時に呼び出す関数を登録してもらうための関数です。(わかりづらくてすみません。。)
// onTaskDropは、UserBox内でStickyがドロップされた時に呼び出(CallBack)してもらう関数です。
// ちなみに、ループして同じコンポーネントをいくつも使う時は、key属性に一意の値を設定しなければなりませんので、ここではユーザIDを設定しています。
render () {
return (
<React.Fragment>
<div id="WhiteBoardTitle">{this.props.title}</div>
{ ! this.state.loading && this.state.users.map((user) => <UserBox user={user} key={user.id} dropHandlerRegister={this.dropHandlerRegister} onTaskDrop={this.onTaskDrop} /> )}
</React.Fragment>
);
}
}
// この下に型チェック用の記述がありましたが、削除してしまいました。
export default WhiteBoard
この実装に合わせて、viewから呼び出す際の引数も以下のように変更しました。
ユーザ一覧取得APIとユーザ切り替えAPIのURLを渡すようにしています。
さらに、CSRFトークンも渡しています。(form_authenticity_token()が出力してくれます。)
<%= 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),
secure_token: form_authenticity_token
}) %>
UserBoxコンポーネントの実装
続いて、ユーザ事のタスク一覧を表示するUserBoxの実装です。
ここは、付箋が貼り付けられた(Stickyがドロップされた)場合の処理がちょっとトリッキーです。
import React from "react"
import PropTypes from "prop-types"
import Sticky from "./Sticky"
// ユーザ毎の箱を表示します。
class UserBox extends React.Component {
// コンストラクタです。
constructor(props) {
// おまじない。
super(props);
// 一つもタスクを持たないユーザの場合、user.tasksがnullになってしまうため、
// nullの場合は空のハッシュを割り当てています。
var tasks = this.props.user.tasks ? this.props.user.tasks : {};
// タスクのリストをstateに突っ込みます。
this.state = { tasks: tasks };
// イベントハンドラのバインド
this.onDrop = this.onDrop.bind(this);
this.updateTaskList = this.updateTaskList.bind(this);
this.preventDefault = this.preventDefault.bind(this);
// WhiteBoardに対して、自身のupdateTaskList関数を登録します。
// (これにより、タスクの所有者変更を通知してもらおうという算段です。)
this.props.dropHandlerRegister(this.props.user.id, this.updateTaskList);
}
// ドラッグオーバー時の通常イベント処理を抑止するための処理です。
// これやらないとドロップできないようです。
preventDefault(event) {
event.preventDefault();
}
// ドロップイベント処理
onDrop(event) {
// dataTransferにセットされたデータ(変更前ユーザIDと対象タスク情報)を取得します。
var dropData = JSON.parse(event.dataTransfer.getData('text/plain'));
// WhiteBoardのonTaskDropを呼び出してあげます。
// こうすると、WhiteBoardからupdateTaskListが呼ばれるのでした。
this.props.onTaskDrop(dropData.now_user_id, this.props.user.id, dropData.task);
}
// タスク一覧更新処理
// prev_user_id: 以前のユーザID
// next_user_id: 変更後のユーザID
// task: 対象タスク
updateTaskList(prev_user_id, next_user_id, task) {
// ユーザIDが変わらない時は何もしません。
if (prev_user_id == next_user_id) {
return;
}
// 以前のユーザIDと自分のユーザIDが等しい時。
// それは、自分からそのタスクを削除する時です。
if (prev_user_id == this.props.user.id) {
// 自分のタスクを押し付けたので、自分のタスク一覧から削除しよう。
this.deleteTask(task.id);
}
// 変更後のユーザIDが自身のユーザIDの時。
// それはあなたに仕事が押し付けられた時です。
if (next_user_id == this.props.user.id) {
// 押し付けられた仕事を自分のタスク一覧に追加しよう。
this.addTask(task);
}
}
// タスク削除処理
deleteTask(task_id) {
var tasks = this.state.tasks;
// 削除対象IDのタスクをリストから削除します。
// タスク一覧をKey-Value形式で持ってたのはこのためです。
// ハッシュにしておくことで検索する手間を省いてます。
delete tasks[task_id];
// stateを更新します。(これで再描画してもらおう)
this.setState({tasks: tasks});
}
// タスク追加処理
addTask(task) {
var tasks = this.state.tasks;
// イヤイヤながらタスクを追加します。
tasks[task.id] = 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 } /> ) }
</div>
</div>
</React.Fragment>
);
}
}
export default UserBox
Stickyコンポーネントの実装
最後にタスクの内容を表示するStickyの実装です。
大事なのは、ドラッグ開始時にdataTranser.setDataを行っていることです。
これにより、drop時のデータ引継ぎができます。
import React from "react"
import PropTypes from "prop-types"
class Sticky extends React.Component {
// コンストラクタです。
constructor(props) {
// おまじないです。
super(props);
// ドラッグ開始イベントハンドラをバインドします。
this.onDragStart = this.onDragStart.bind(this);
}
// ドラッグ開始イベントハンドラ
onDragStart(event) {
// ドラッグを開始したら、現在のpropsに設定されたユーザIDとタスク情報をJSON形式のテキストデータに直してdataTransferにセットします。
// text/plainですが、JSON.stringify()を使うことでハッシュデータを引き継ぐことができます。
// (https://stackoverflow.com/questions/9533585/drag-drop-html-5-jquery-e-datatransfer-setdata-with-json)
var dragData = { now_user_id: this.props.user_id, task: this.props.task };
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
// レンダラです。
render () {
return (
<React.Fragment>
<div id={"task-" + this.props.task.id} className="Sticky" draggable="true" onDragStart={this.onDragStart} >
<div className="TaskTitle">{this.props.task.title}</div>
<div className="TaskDescription">{this.props.task.description}</div>
<div className="TaskDueDate">{this.props.task.due_date}</div>
</div>
</React.Fragment>
);
}
}
export default Sticky
軽く動作確認
まずは実際に画面表示してみる。
ここまでで、DBからユーザ情報とそれぞれのタスク情報を取得して画面に表示する流れと、
タスクの書かれた付箋をドラッグドロップして所有者を変更する流れが出来上がっているはずです。
ということで、動作確認用にサーバを起動(bundle exec rails s
)して、テスト用ページ(localhost:3000/white_board/main)にアクセスしてみましょう。
・・・あ、味気ねぇが、一応ドラッグドロップも動きます。
seedでデータを用意しておいたので、こういうときに簡単に確認できますね。
テストしてみます。
ドラッグドロップなんてテストできるの?
Rails環境にはCapybaraが住んでいるのでできます。
実際のUIを使ってテストするので、systemテスト扱いかなぁ、とシステムテストを追加します。
# WhiteBoardのテストということで、whiteboardsにしてみました。
bundle exec rails g system_test whiteboards
以下のテストを追加しました。
test "sticky is able to drag and drop" do
# fixutreで登録したデータを取得しておきます。
alice = users(:alice);
bob = users(:bob);
task2 = tasks(:task2);
# divのidを設定します。(idを設定しておくことで、テストが格段に楽になりますね。)
task2_id = "task-" + task2.id.to_s;
bob_id = "user-" + bob.id.to_s;
alice_id = "user-" + alice.id.to_s;
# white_board/mainを開く。
visit white_board_main_url;
# 一応各ユーザのタスク数を確認しておきます。
assert_equal(1, alice.tasks.count);
assert_equal(0, bob.tasks.count);
# task2を表示しているエレメントを取得
div_task2 = find(id: task2_id);
# bobのUserBoxを表示しているエレメントを取得
div_bob = find(id: bob_id);
# aliceのUserBoxを表示しているエレメントを取得
div_alice = find(id: alice_id);
# aliceのタスクは1つ、bobのタスクはなし。
div_alice.assert_selector("div", class: "Sticky", count: 1);
div_bob.assert_selector("div", class: "Sticky", count: 0);
# alice said "Hey bob, I think you want to do my job 'task2'."
# drag_to(ドラッグ先エレメント)
div_task2.drag_to(div_bob);
# タスク所有者が入れ替わったことを確認
div_alice.assert_selector("div", class: "Sticky", count: 0);
div_bob.assert_selector("div", class: "Sticky", count: 1);
# 本当かどうか、スクリーンショットを撮ってもらう。
take_screenshot();
# データをリロードしてDBにも反映されたことを確認
alice.tasks.reload;
bob.tasks.reload;
assert_equal(0, alice.tasks.count);
assert_equal(1, bob.tasks.count);
end
UIがテストできると言うのは素晴らしいですね。
それにしても、見た目が寂しいので次回はスタイルを直してみます。