JavaScript
CakePHP
AWS
rest
勉強会

CakePHPでデータ操作〜担当者アサイン機能の実装 - AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~【第6回】マニュアル

More than 3 years have passed since last update.

:warning: 第5回でusersテーブルを追加しましたが、前回不参加の方に、最初伝え漏れていましたので追記いたしました(事前準備のところ)。すいませんでした!

:large_blue_circle: はじめに

本投稿は、2015/6/26に行われた、CakePHPでデータ操作〜担当者アサイン機能の実装 - connpassの内容についてまとめた資料です。

:warning:今後の予定
AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~ - connpass

今回は、TODOに担当者を割り当てる機能を追加します。
前回ログイン機能を付けたので、複数ユーザで使用する感じにしてみます!

CakePHPの「アソシエーション」について学びます。

:large_blue_circle: CakePHPのアソシエーションとは?

前回まで、

  • users
  • todo_lists

の2テーブルを扱っていました。

しかし、テーブル1つの単純なSelectしかしていませんでした。
今回作成するアプリケーションでは、usersとtodo_listsを結合してSelectしたりする必要がありますが、CakePHPではこういった場合にSQLを直接書くわけではなく、「アソシエーション」という仕組みを使用します。
テーブルを結合したりするのを、SQLではなくコードで表現します。

今回学ぶアソシエーション

今回は下記3つのアソシエーションを学びます。

  • belongsTo
  • hasOne
  • hasMany

:warning: 「hasAndBelongsToMany」というのもありますが、今回は割愛します。

1つずつ説明します。

belongsTo

A belongsTo B、つまり、AはBに属している、というテーブル間の関係がある場合、belongsToアソシエーションを使用します。
例えば、「ユーザ(users)」テーブルに、「国籍」というフィールドがあり、「国籍マスタ(countries)」テーブルがあるとします。

  • countries(id, name)

とします。

userは一つの国籍をもちますので、

  • users(id, username, country_id)

という感じです。
users.country_id と countries.id で joinしてSelectすれば取得できますね。
SQLで書くとこんな感じです。

select users.id, users.username, countries.name as country_name
from users inner join countries on users.country_id = countries.id

こういう場合に使用するのが「belongsTo」アソシエーションです。
コードは後ほど見ていきます!

hasOne

hasOneは、相手側テーブルが1件、と言う意味でbelongsToの親戚みたいなものです。
belongsToの例では、users側にcountriesのレコードを示すcountry_idを持っていました。
例えば、ユーザのプロフィールを保持するテーブル(profiles)があるとします。

  • profiles(id, user_id, 自己紹介文, 好きな食べ物, etc...)

この場合、users側ではなく、profiles側にuser_idを持っていますので、
users.id と profiles.user_id で joinしてSelectすれば取得できますね。
こういう場合に使用するのが「hasOne」アソシエーションです。

:warning: users側にprofile_idをもつ、という実装も出来ますが、「プロフィールを未登録」の場合に

  • users.profile_idをnullにする実装にする
  • users.id = profies.user_idとなるprofilesのレコードがないだけ のどちらにするかと言う設計思想の違いですね。

SQLで書くとこうです。

select users.id, profiles.自己紹介文, profiles.好きな食べ物
from users inner join profiles on users.id = profiles.user_id

コードは後ほど見ていきます!

hasMany

hasManyは1:nの関係を表す場合に使用します。
例えば、ユーザの職歴を保持するテーブル(careers)があるとします。

  • careers(id, user_id, 入社年度, 会社名, etc...)

この場合、usersは複数の職歴(career)レコードを持てるので、
users.id と careers.user_id で joinしてSelectすれば取得できますね。
hasOneとの違いは、この時複数件のレコードがとれてくる、と言う部分のみです。

SQLで書くと、通常はこうです。

select users.id, careers.入社年度, careers.会社名
from users inner join careers on users.id = careers.user_id

:warning:実際にはCakePHPの場合、usersのselectとcareersのselectがそれぞれ一回、計2回のselectが発行される実装になっています。

コードは後ほど見ていきます!

:large_blue_circle: 今回の内容

今回は、以下の機能を実現します。

機能リスト

  • Todoの追加
    • TODOの内容に加え「そのTODOを誰が担当するか」を全ユーザのリストから選択して指定する
    • ユーザのリストは、ログインユーザがデフォルトで選択状態とする。
  • TODO一覧の表示
    • TODO追加を実行したユーザを「オーナ」、TODOを割り当てたユーザを「担当」として表示します。
    • 下記の操作制限を実装します。
      • 「削除」はオーナのみ可能。ログインユーザがオーナでないTODOの行の「削除」リンクは表示しない。
      • 「詳細」の表示はオーナまたは担当のみ可能。それ以外の場合は「詳細」リンクは表示しない。
  • TODO詳細
    • 担当者の変更を可能とする。

画面

  • TODO一覧画面

todo_lists.png

  • TODO詳細画面

todo_detail.png

:large_blue_circle: 事前準備

事前準備は毎回同じなので、別エントリにまとめています。
全12回の勉強会でやっているGitの使い方 - AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~ - Qiitaを参照してください。
やることは、

  • gitのブランチを整えて今回用ブランチvol/06を作成する

です。まずこれをやりましょう。
それと、前回usersテーブルを追加しましたが、前回不参加の方はそっちもやっておく必要があります。こちらを参照下さい。

今回はさらに、
todo_listsテーブルに、

  • オーナのユーザID(owner)
  • 担当のユーザID(assignee)

を追加します。

TODO一覧テーブルへの列追加

まず、phpMyAdminにログインしましょう。

URLは、http://(PublicIP)/phpMyAdmin/です。
変更していなければ、ユーザ名study、パスワードstudyでログインできます。

各列の設定
名前 データ型 長さ/値 デフォルト値 照合順序 属性 NULL インデックス A_I(AutoIncrement) コメント
owner INT 10 なし   UNSIGNED       オーナ
assignee INT 10 なし   UNSIGNED       担当

準備

では、準備を始めましょう!

  • :white_check_mark: Gitのブランチを整えて、vol/06ブランチを作成。
  • :white_check_mark: TODO一覧(todo_lists)テーブルに列を追加。

準備ができたら、Lesson1です!

:large_blue_circle: Lesson1 サーバサイド

Lesson1では、サーバサイドのプログラムを実装します。
プログラムのテストには、第1回でインストールした「POSTMAN」を使用してAPIの動作確認を行います。

ポイント

今回のテーマは「アソシエーション」ですが、前述の3つのうち、実は「belongsTo」だけで今回の機能は実現できてしまいます。
それだけでは面白く無いので、「hasOne」と「hasMany」も試してみます。
まずは、belognsToを使用して、アプリケーションの実装をします。

編集するファイル一覧

編集 file 編集概要
修正 app/Controller/TodoListsController.php findのパラメータの設定
修正 app/Model/TodoList.php アソシエーションの設定
修正 app/Controller/UsersController.php ユーザ一覧の取得

TodoListsController.php

TodoListモデルのfind関数実行じのパラメータを設定します。

TodoListsController.php
〜略〜
 class TodoListsController extends AppController {

    public function index() {
-       $res = $this->TodoList->find('all');
+       $query = array (
+           'fields' => array (
+               'TodoList.id',
+               'TodoList.todo',
+               'TodoList.status',
+               'Owner.id',
+               'Owner.name',
+               'Assignee.id',
+               'Assignee.name'
+           ),
+           'order' => "TodoList.id"
+       );
+       $res = $this->TodoList->find('all', $query);
+       // 整形
+       if (count($res) > 0) {
+           $loginUserId = $this->Auth->user()['id'];
+           foreach ( $res as $key => $row ) {
+               //「ログインユーザがオーナである」フラグ
+               $res[$key]['TodoList']['owned'] = $row['Owner']['id'] === $loginUserId;
+               //「ログインユーザが担当である」フラグ
+               $res[$key]['TodoList']['assigned'] = $row['Assignee']['id'] === $loginUserId;
+           }
+       }
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

    public function view($id = null) {
        $res = $this->TodoList->findById($id);
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

    public function add() {
        $data = $this->request->data;
+       $data['owner'] = $this->Auth->user()['id'];
        $res = $this->TodoList->save($data);
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

〜以下略〜

find関数に引数追加

  • 第二引数$queryを追加しています。$queryは配列で、下記を設定しています。
    • fields
      • 取得するテーブルの列。これを指定しないと、ユーザのパスワード等もとれてしまうため、必要な物のみ設定しています。
    • order
      • ついでにデータの並び順も指定しました。todo_listsテーブルのidの昇順です。

取得したデータの整形

  • 取得した1件のレコードについて、
    • ログインユーザがオーナである場合、owned = trueに設定、そうでない場合owned = falseに設定。
    • ログインユーザが担当である場合、assigned = trueに設定、そうでない場合assigned = falseに設定。

TodoList.php

アソシエーションの設定

TodoList.php
 class TodoList extends AppModel {
+   public $belongsTo = array (
+       'Owner' => array (
+           'className' => 'User',
+           'foreignKey' => 'owner',
+       ),
+       'Assignee' => array (
+           'className' => 'User',
+           'foreignKey' => 'assignee'
+       )
+   );
 }

belongsToアソシエーション

  • TODO一件に対する、
    • オーナのユーザ情報
    • 担当のユーザ情報

を設定しています。詳細は下記の通り。

  • モデルの変数に$belongsToという変数名で配列を設定します。
    • Owner(オーナ), Assignee(担当)という名前で2つのusersテーブルを紐つけています。
    • その下のパラメータ詳細(Owner)
      • className -> モデル名(User)
      • usersに対する外部キー(todo_lists.ownerを指定)
    • その下のパラメータ詳細(Assignee)
      • className -> モデル名(User)
      • usersに対する外部キー(todo_lists.assigneeを指定)

addメソッド

  • ownerにログインユーザのIDを設定しています。
    • これで、TODOの追加操作をした際のログインユーザがオーナとして設定されます。

UsersController.php

ログイン、ログイン済みチェック、ログアウト、ユーザ登録の実装です。

UsersController.php
 class UsersController extends AppController {

+   public function index() {
+       $res = $this->User->find('all', array (
+           'fields' => array (
+               'User.id',
+               'User.username',
+               'User.name'
+           )
+       ));
+       $this->set(compact('res'));
+       $this->set('_serialize', 'res');
+   }
+
    public function login() {
        $user = $this->Auth->user();
        $res = array();

〜以下略〜

index関数

  • TodoListController.phpと同じ容量で、find関数で取得するフィールドを絞っています。

 動作確認

POSTMANで、TODO一覧取得API(http://(PublicIP)/rest-study/todo_lists.json)をテストします。
下記のように取得できればOKです。
belongsToが効いています。

[
    {
        "TodoList": {
            "id": "62",
            "todo": "牛乳を買う",
            "status": "0",
            "owned": true,
            "assigned": true
        },
        "Owner": {
            "id": "67",
            "name": "山田太郎"
        },
        "Assignee": {
            "id": "67",
            "name": "山田太郎"
        }
    },
    {
        "TodoList": {
            "id": "78",
            "todo": "犬の散歩",
            "status": "0",
            "owned": true,
            "assigned": false
        },
        "Owner": {
            "id": "67",
            "name": "山田太郎"
        },
        "Assignee": {
            "id": "68",
            "name": "田中花子"
        }
    },
    {
        "TodoList": {
            "id": "83",
            "todo": "家に電話",
            "status": "1",
            "owned": false,
            "assigned": false
        },
        "Owner": {
            "id": "68",
            "name": "田中花子"
        },
        "Assignee": {
            "id": "68",
            "name": "田中花子"
        }
    }
]

実装

では、実際に修正して上記のテストをしてみましょう。

  • :white_check_mark: app/Controller/TodoListsController.php を上記の通り修正。
  • :white_check_mark: app/Model/TodoList.php を上記の通り修正。
  • :white_check_mark: app/Controller/UsersController.php を上記の通り修正。
  • :white_check_mark: 動作確認!
  • :white_check_mark: Gitにコミット

GitHubでのdiff表示へのリンク
第6回 Lesson1 サーバサイド · suzukishouten-study/rest-study@d4fe1a4

Lesson1.5へ!

:large_blue_circle: Lesson1.5 他のアソシエーションを試す。

今回実装するアプリケーションとしてはLesson1の実装でOKです。
Lesson2のクライアントの実装に入る前に、hasOnehasManyのアソシエーションも試してみましょう。

ソースを修正して動作確認後、元に戻してLesson2へ行きましょう。

テストでは、下記のデータの状態で実行した結果を載せています。

testdata.png

:warning:ユーザは、

  • 山田太郎
  • 田中花子
  • 佐藤一郎

が登録されていますが、佐藤一郎さんはまだTODOが1件もない(自分がオーナのものも、担当としてアサインされたものも)状態です。

hasOneをテストする

あるユーザがオーナとなっているTODOを取得するケースを考えます。
:warning:user : todo1 : nになるので本来はhasManyを使用する場面です。
ちょっと無理矢理ですが、「田中花子さん」がオーナのTODOは1件なので、田中花子さんのTODOがちゃんと取れるか確認してみましょう。

ソース修正箇所

app/Controller/UsersController.phpindex関数を修正。
app/Controller/UsersController.php
    public function index() {
        $res = $this->User->find('all');    //fieldsの指定を外す。下記はコメントアウト
//      $res = $this->User->find('all', array (
//          'fields' => array (
//              'User.id',
//              'User.username',
//              'User.name'
//          )
//      ));
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

app/Model/User.phpにアソシエーションを設定。

app/Model/User.php
class User extends AppModel {
    public $hasOne = array (
        'TodoList' => array (
            'className' => 'TodoList',
            'foreignKey' => 'owner'
        )
    );
}

さて、ユーザ取得API(http://(PublicIP)/rest-study/users.json)を叩いてみましょう!

実行結果
[
    {
        "User": {
            "id": "67",
            "username": "yamada",
            "password": "$2a$10$KQTujh9rcjx7/7b83Hw7jOg8brjOAyT/S.H4VgY7Uknjy5jg1.iD.",
            "name": "山田太郎"
        },
        "TodoList": {
            "id": "62",
            "todo": "牛乳を買う",
            "status": "0",
            "owner": "67",
            "assignee": "67"
        }
    },
    {
        "User": {
            "id": "67",
            "username": "yamada",
            "password": "$2a$10$KQTujh9rcjx7/7b83Hw7jOg8brjOAyT/S.H4VgY7Uknjy5jg1.iD.",
            "name": "山田太郎"
        },
        "TodoList": {
            "id": "78",
            "todo": "犬の散歩",
            "status": "0",
            "owner": "67",
            "assignee": "68"
        }
    },
    {
        "User": {
            "id": "68",
            "username": "tanaka",
            "password": "$2a$10$ig3ul1GQlZPbQDODLt6.SuYqJVv94wNlZJwMqSPvK5H/p2qEBih6e",
            "name": "田中花子"
        },
        "TodoList": {
            "id": "83",
            "todo": "家に電話",
            "status": "1",
            "owner": "68",
            "assignee": "68"
        }
    },
    {
        "User": {
            "id": "69",
            "username": "sato",
            "password": "$2a$10$rBdmyhZHwlv4h.EvacI34OSRuE9Q3t7pYx1HysS1zxw99NOGLUVBi",
            "name": "佐藤一郎"
        },
        "TodoList": {
            "id": null,
            "todo": null,
            "status": null,
            "owner": null,
            "assignee": null
        }
    }
]

ユーザに対して1件のTODOが紐ついていますね!
1件もTODOを持っていない場合は、各項目がnullの配列が一個出来るようです。

この時実行されたSQLは下記です。hasManyでは一回の実行でSQLが2回実行されます。

SELECT
    `User`.`id`,
    `User`.`username`, 
    `User`.`password`, 
    `User`.`name`, 
    `TodoList`.`id`, 
    `TodoList`.`todo`, 
    `TodoList`.`status`, 
    `TodoList`.`owner`, 
    `TodoList`.`assignee` 
FROM `study`.`users` AS `User` 
        LEFT JOIN `study`.`todo_lists` AS `TodoList` ON (`TodoList`.`owner` = `User`.`id`)  
WHERE 1 = 1

謎のWHERE 1 = 1がついてますがこういうSQLです。普通にJOINしてるだけですね。

hasManyをテストする

user : todo1 : nになるので本来のhasManyの使いドコロです。

ソーズ修正箇所

app/Controller/UsersController.phpはhasOneで修正した状態で置いておきましょう。

app/Model/User.phpにアソシエーションを設定。

app/Model/User.php
class User extends AppModel {
    public $hasMany = array (
        'TodoList' => array (
            'className' => 'TodoList',
            'foreignKey' => 'owner'
        )
    );
}

hasOneをhasManyに変えただけです!
では実験。

実行結果
[
    {
        "User": {
            "id": "67",
            "username": "yamada",
            "password": "$2a$10$KQTujh9rcjx7/7b83Hw7jOg8brjOAyT/S.H4VgY7Uknjy5jg1.iD.",
            "name": "山田太郎"
        },
        "TodoList": [
            {
                "id": "62",
                "todo": "牛乳を買う",
                "status": "0",
                "owner": "67",
                "assignee": "67"
            },
            {
                "id": "78",
                "todo": "犬の散歩",
                "status": "0",
                "owner": "67",
                "assignee": "68"
            }
        ]
    },
    {
        "User": {
            "id": "68",
            "username": "tanaka",
            "password": "$2a$10$ig3ul1GQlZPbQDODLt6.SuYqJVv94wNlZJwMqSPvK5H/p2qEBih6e",
            "name": "田中花子"
        },
        "TodoList": [
            {
                "id": "83",
                "todo": "家に電話",
                "status": "1",
                "owner": "68",
                "assignee": "68"
            }
        ]
    },
    {
        "User": {
            "id": "69",
            "username": "sato",
            "password": "$2a$10$rBdmyhZHwlv4h.EvacI34OSRuE9Q3t7pYx1HysS1zxw99NOGLUVBi",
            "name": "佐藤一郎"
        },
        "TodoList": []
    }
]

こうなりました!ちゃんとユーザに対してTODOが複数件紐ついています。
これがhasManyですね。
この時実行されたSQLは下記2つです。hasManyでは一回の実行でSQLが2回実行されます。

1回目
SELECT `User`.`id`, `User`.`username`, `User`.`password`, `User`.`name` FROM `study`.`users` AS `User`   WHERE 1 = 1
2回目
SELECT `TodoList`.`id`, `TodoList`.`todo`, `TodoList`.`status`, `TodoList`.`owner`, `TodoList`.`assignee` FROM `study`.`todo_lists` AS `TodoList`   WHERE `TodoList`.`owner` IN (67, 68, 69)

2回目は、1回目で取得したuserのidをownerに持つTodoListをselectしています。
こういう実装になってるんですね。

では、修正したところを元に戻して、lesson2、 画面を作りましょう。

:large_blue_circle: Lesson2 クライアントサイド

Lesson2では、クライアントサイドのプログラムを実装します。

ポイント

前回までの知識でほぼ開発できますが、今回、下記の「ユーザ一覧を取得して表示する」部分だけ少し解説します。

  • TODO一覧画面のユーザ一覧表示

userlist_todo_list.png

  • TODO詳細画面のユーザ一覧表示

userlist_todo_detail.png

今回の実装

Backbone、Marionetteをフル活用するなら、本来はユーザ一覧表示部分をViewにし、「Behavior」機能を使うなどして共通化するといい感じなのですが、
今回のメインは「CakePHPのアソシエーション」なので、ちょっと簡単(手抜き?)の実装になっています。
ご了承下さい。

具体的には、

  • ユーザ取得API(http://(PublicIP)/rest-study/users.json)を叩いてcollectionにユーザ一覧を取得
  • そのcollectonをループで処理し、テンプレートで用意している<select></select>タグの中に、<option value="ユーザのid">ユーザの氏名</option>というタグをJQueryを使ってはめ込んでいく。

というやり方です。

これを、TODO一覧とTODO詳細の両画面で「コピペ」しています。
(DRYではないですが今回はご勘弁!)

では、ソース解説です。

編集するファイル一覧

編集 file 編集概要
修正 app/View/Layouts/default.ctp テンプレートの修正
新規 app/webroot/js/collections/user-collection.js ユーザ一覧用collection
修正 app/webroot/js/models/todo-model.js 追加項目のparse
修正 app/webroot/js/views/todo-layout-view.js ユーザ一覧読み込み処理
修正 app/webroot/js/views/todo-composite-view.js ユーザ一覧表示
修正 app/webroot/js/views/todo-item-view.js 削除・詳細ボタンの表示制御
修正 app/webroot/js/views/todo-detail-layout-view.js ユーザ一覧読み込み処理
修正 app/webroot/js/views/todo-detail-item-view.js ユーザ一覧表示

default.ctp

テンプレートを修正。

default.ctp
〜 略 〜
  <title>シンプルTODOアプリ</title>
 </head>
 <body>
    <!-- ヘッダ -->
    <div id="header"></div>
    <!-- コンテンツ -->
    <div id="main"></div>

〜 略 〜

    <!-- TODO一覧表示のテンプレート -->
    <script type="text/template" id="todo-composite-template">
    <textarea style="width:300px;height:50px"id="new-todo" placeholder="Todo?" autofocus></textarea>
+   <select name="assignee" id="user-list">
    <input type="button" id="addTodo" value="追加">
    <hr>
    <div>
        <table border="1" width="350px">
+           <thead>
+               <tr>
+                   <td>完了</td>
+                   <td>ToDo</td>
+                   <td>オーナ</td>
+                   <td>担当</td>
+                   <td colspan="2"></td>
+               </tr>
+           </thead>
            <tbody></tbody>
        </table>
    </div>
    </script>

    <!-- TODO一行分のテンプレート(上のtbody部分に挿入される) -->
    <script type="text/template" id="todo-item-template">
    <td><input type="checkbox" class="toggle" <%- status === '1' ? 'checked' : '' %>></td>
    <td style="margin:0px">
        <span class="todo-edit" style="margin:0px"><%- todo %></span>
    </td>
    <td>
+       <span><%- Owner.name %></span>
+   </td>
+   <td>
+       <span><%- Assignee.name %></span>
+   </td>
+   <td>
        <a class="remove-link" href="#">削除</a>
+   </td>
+   <td>
        <a class="detail-link" href="#todo-lists/<%- id %>">詳細</a>
    </td>
    </script>

    <!-- 詳細画面のレイアウトテンプレート -->
    <script type="text/template" id="todo-detail-layout-template">
    <div id="todo-item"></div>
    </script>

    <!-- 詳細画面の表示内容テンプレート -->
    <script type="text/template" id="todo-detail-item-template">
    <h2>Todo #<%- id %></h2>
    <div>
    <textarea style="width:300px;height:50px" id="edit-todo" autofocus placeholder="Todo?"><%- todo %></textarea>
+   <select name="assignee" id="user-list">
+   </select>
    <input type="button" id="updateTodo" value="更新"></input>
    <input type="button" id="updateCancel" value="キャンセル"></input>
    </div>
    </script>

〜 略 〜

下記の通り修正。

  • TODO一覧画面
    • ユーザ一覧表示用<select></select>を追加
    • tableに<thead></thead>を追加(項目が増えてきたので)
    • [削除]と[詳細]を別の<td></td>に分割
  • TODO詳細画面
    • ユーザ一覧表示用<select></select>を追加

user-collection.js

user-collection.js
//User一覧表示用コレクション
define(function(require) {
    var UserModel = require('models/user-model');

    var UserCollection = Backbone.Collection.extend({
        url : '/rest-study/users.json',
        model : UserModel,

        parse : function(response) {
            return response;
        }
    });

    return UserCollection;
});

必要最低限のcollectionの実装です。
これはもう説明不要ですね!

todo-model.js

追加項目のOwnerAssigneeをパースしています。

todo-model.js
 //Todoデータ1件を表すモデル
 define(function() {
    var TodoModel = Backbone.Model.extend({
        urlRoot : '/rest-study/todo_lists',
        parse : function(response) {
            //モデルをパース
            console.log("モデルをパース");
            console.log(response);
-           return response.TodoList;
+           var parsed = response.TodoList;
+           if (response.Owner) {
+               parsed.Owner = response.Owner;
+               parsed.Assignee = response.Assignee;
+           }
+           return parsed;
        },
        toggle : function() {
            this.set('status', this.get("status") === '1' ? '0' : '1');
            this.save();
        }
    });
    return TodoModel;
 });

サーバからはCakePHP任せの下記フォーマットでTODO1件のJSONが来るので、

サーバから来た生の状態
{
    "TodoList": {
        "id": "62",
        "todo": "牛乳を買う",
        "status": "0",
        "owned": false,
        "assigned": false
    },
    "Owner": {
        "id": "67",
        "name": "山田太郎"
    },
    "Assignee": {
        "id": "67",
        "name": "山田太郎"
    }
}

こんなフォーマットに変更しています。

クライアントで処理しやすいよう整形
{
    "id": "62",
    "todo": "牛乳を買う",
    "status": "0",
    "owned": false,
    "assigned": false,
    "Owner": {
        "id": "67",
        "name": "山田太郎"
    },
    "Assignee": {
        "id": "67",
        "name": "山田太郎"
    }
}

todo-layout-view.js

ユーザ一覧読み込み処理を追加しています。

todo-layout-view.js
 define(function(require){
    var TodoCompositeView = require('views/todo-composite-view');
    var TodoCollection = require('collections/todo-collection');
+   var UserCollection = require('collections/user-collection');

    var TodoLayoutView = Marionette.LayoutView.extend({
        //テンプレート
        template: '#todo-layout-template',

        regions : {
            listRegion : '#todo-lists',
        },

-       onRender : function(){
+       onRender : function() {
+           this.userCollection = new UserCollection();
+           this.listenTo(this.userCollection, 'reset', this.onLoadUsers, this);
+           this.userCollection.fetch({reset : true});
+       },
+
+       onLoadUsers : function(userCollection) {
            var todoCollection = new TodoCollection();
            this.listenTo(todoCollection , 'reset', this.showTodoList, this);
            todoCollection.fetch({reset : true});
        },

        showTodoList : function(todoCollection){
            this.listRegion.show( new TodoCompositeView({
-               collection : todoCollection
+               collection : todoCollection,
+               userList : this.userCollection.models
            }));
        },

    });
    return TodoLayoutView;
 });
  • collections/user-collectionrequireで読み込む
  • onRender関数内で、this.userCollection.fetch()でサーバからユーザ一覧取得。
  • fetchが終わると(ListenToが効いて)onLoadUsers関数を実行、続いてshowTodoListが実行される
  • showTodoList関数内で、TodoCompositeViewの初期化時に、取得したユーザ一覧のcollectionを渡すようにしている
    • このユーザ一覧collectionをTodoCompositeViewの処理で表示するわけです。

todo-composite-view.js

ユーザ一覧を表示します。

todo-composite-view.js
 //Todo一覧表示用ビュー
 define(function(require) {
    var TodoItemView = require('views/todo-item-view');

    var TodoCompositeView = Marionette.CompositeView.extend({
        template: '#todo-composite-template',

        childView : TodoItemView,

        childViewContainer : 'tbody',

        ui : {
            addTodo : '#addTodo',
-           newTodo : '#new-todo'
+           newTodo : '#new-todo',
+           userList : '#user-list'
        },

        events : {
            'click @ui.addTodo' : 'onCreateTodo',
        },

-       initialize: function(){
+       initialize: function(options){
            _.bindAll( this, 'onCreatedSuccess' );
+           this.userList = options.userList;
        },

+       onRender : function() {
+           //ユーザ一覧を表示
+           this.showUserList(this.ui.userList, this.userList);
+           //ログインユーザをデフォルトで選択状態にする
+           this.ui.userList.val(window.application.loginUser.id);
+       },
+                   
+       //ユーザ一覧を表示
+       showUserList : function($list, userList){
+           $.each(userList, function(index, userModel) {
+               $list.append(
+                   "<option value='" 
+                   + userModel.attributes.id + "'>"
+                   + userModel.attributes.name + "</option>");
+           });
+       },
+           
        onCreateTodo : function() {
            this.collection.create(this.newAttributes(), {
                  silent:  true ,
                  success: this.onCreatedSuccess
            });
            this.ui.newTodo.val('');
        },

        newAttributes : function() {
            return {
                todo : this.ui.newTodo.val().trim(),
-               status : 0
+               status : 0,
+               assignee : this.ui.userList.val()
            };
        },

        onCreatedSuccess : function(){
            this.collection.fetch({ reset : true });
        },

    });
    return TodoCompositeView;
 });
  • ユーザ一覧のセレクトボックスを処理するためui変数にuserListを追加。
  • initialize関数内で、todo-layout-viewから渡されたユーザ一覧collection(options.userListに入ってくる)を自身の中(this.userListに保持)
  • onRender関数内で、showUserList関数を実行してユーザ一覧の表示を行う
  • ユーザ一覧表示後、ログイン時に細んしているwindow.application.loginUserのユーザ情報からログインユーザのidを取得し、ユーザ一覧表示で自分(ログインユーザ)をデフォルトで選択された状態に設定。
  • showUserList関数では、jqueryを使用し、ui変数で定義済みの<select></select>タグの中に<option></option>タグを埋め込んでいます。
  • todoの新規追加時(newAttributes関数)は、ユーザ一覧で選択したユーザを担当者(assignee)をして設定します。

todo-item-view.js

削除・詳細ボタンの表示制御を行っています。

todo-item-view.js
 //Todo一覧の1件表示用ビュー
 define(function(){
    var TodoItemView = Marionette.ItemView.extend({
        //DOMに要素追加のタグ名
        tagName : 'tr',

        //テンプレート
        template : '#todo-item-template',

        ui : {
            checkBox : '.toggle',
-           removeLink : '.remove-link'
+           removeLink : '.remove-link',
+           detailLink : '.detail-link'
        },

        //DOMイベントハンドラ設定
        events : {
            //チェックボックスクリック時
            'click @ui.checkBox' : 'onStatusToggleClick',
            //削除ボタンクリック時
            'click @ui.removeLink' : 'onRemoveClick',
        },

        onStatusToggleClick : function() {
            this.model.toggle();
        },

        onRemoveClick : function() {
            this.model.destroy({
                wait : true
            });
        },

+       onRender : function() {
+           if (!this.model.attributes.owned) {
+               //オーナでない場合は削除リンク非表示
+               this.ui.removeLink.css({
+                   display : 'none'
+               });
+               if (!this.model.attributes.assigned) {
+                   //担当者でもない場合は、
+                   //詳細リンクも非表示
+                   this.ui.detailLink.css({
+                       display : 'none'
+                   });
+                   //チェックボックスも変更不可
+                   this.ui.checkBox.prop('disabled', true);
+               }
+           }
+       }
    });
    return TodoItemView;
 }); 
  • ui変数に削除リンク用の変数detailLinkを追加
  • onRender関数で、削除、詳細の各リンクの表示制御を下記の仕様で行っています。
    • オーナでない場合は削除リンクを非表示にする
      • さらに、担当者でもない場合は、
        • 詳細リンクも表示にする
        • チェックボックスをdisableにして変更不可とする

※表示制御の実装

  • リンクの非表示
    • jqueryのcss関数で、display : 'none'のスタイルを設定
  • チェックボックスの変更不可
    • jqueryのprop関数で、disabledtrueに設定

todo-detail-layout-view.js

ユーザ一覧読み込み処理を追加しています。
やっていることは、todo-layout-view.jsとほぼ同じです。

todo-detail-layout-view.js
 define(function(require) {
     var TodoDetailItemView = require('views/todo-detail-item-view');
     var TodoModel = require('models/todo-model');
+    var UserCollection = require('collections/user-collection');

    var TodoDetailLayoutView = Marionette.LayoutView.extend({
        //テンプレート
        template : '#todo-detail-layout-template',

        regions : {
            itemRegion : '#todo-item',
        },

        onRender : function() {
-           var todoModel = new TodoModel({
-               id : this.options.modelId
-           });
-           //モデルのサーバからのデータ取得完了時、描画を行う
-           this.listenTo(todoModel, 'sync', this.showItem, this);
-           //サーバからデータ取得
-           todoModel.fetch({
-               wait : true
+           this.userCollection = new UserCollection();
+           this.listenToOnce(this.userCollection, 'reset', this.onLoadUsers, this);
+           this.userCollection.fetch({
+               reset : true
            });
        },

-       showItem : function(todoModel) {
-           this.itemRegion.show( new TodoDetailItemView({
-               model : todoModel
-           }));
-       },
+       onLoadUsers : function(userCollection){
+           var todoModel = new TodoModel({
+               id : this.options.modelId
+           });
+           //モデルのサーバからのデータ取得完了時、描画を行う
+           this.listenToOnce(todoModel, 'sync', this.showItem, this);
+           //サーバからデータ取得
+           todoModel.fetch({
+               wait : true
+           });
+       },
+
+       showItem : function(todoModel) {
+           this.itemRegion.show(new TodoDetailItemView({
+               model : todoModel,
+               userList : this.userCollection.models
+           }));
+       },

    });
    return TodoDetailLayoutView;
 });
  • collections/user-collectionrequireで読み込む
  • onRender関数内で、this.userCollection.fetch()でサーバからユーザ一覧取得。
    • 元々onRender内で行っていた、model1件のサーバからの取得処理は、onLoadUsers内に移動しています。 - fetchが終わると(ListenToが効いて)onLoadUsers関数を実行、todoModel.fetch()でTODO1件の取得を行う。
  • showItem関数内で、TodoDetailItemViewの初期化時に、取得したユーザ一覧のcollectionを渡すようにしている
    • このユーザ一覧collectionをTodoDetailItemViewの処理で表示します。

todo-detail-item-view.js

ユーザ一覧を表示します。

todo-detail-item-view.js
 //詳細ビュー
 define(function() {
    var TodoDetailItemView = Marionette.ItemView.extend({

        //テンプレート
        template: "#todo-detail-item-template",

        ui : {
            todoStatus   : '#edit-todo',
            updateButton : '#updateTodo',
-           cancelButton : '#updateCancel'
+           cancelButton : '#updateCancel',
+           userList     : '#user-list'
        },

        //DOMイベントハンドラ設定
        events : {
            //更新ボタンクリック時
            'click @ui.updateButton' : 'onUpdateClick',
            //キャンセルボタンクリック時
            'click @ui.cancelButton' : 'onCancelClick',
        },

        //初期化
-       initialize: function(){
+       initialize: function(options){
            _.bindAll( this, 'onSaveSuccess' );
+           this.userList = options.userList;
+       },
+
+       onRender : function() {
+           //ユーザ一覧を表示
+           this.showUserList(this.ui.userList, this.userList);
+           //担当者を選択状態にする
+           this.ui.userList.val(this.model.attributes.assignee);
+       },
+
+       //ユーザ一覧を表示
+       showUserList : function($list, userList){
+           $.each(userList, function(index, userModel) {
+           $list.append(
+               "<option value='" 
+               + userModel.attributes.id + "'>"
+               + userModel.attributes.name + "</option>");
+           });
        },

        //更新ボタンクリックのイベントハンドラ
        onUpdateClick : function() {
            //テキストボックスから文字を取得
-           var todoString = this.ui.todoStatus.val();
+           var todoString = this.ui.todoStatus.val();  // Todo
+           var assigneeId = this.ui.userList.val();    // 担当者
            this.model.save({
-               todo : todoString
+               todo : todoString,
+               assignee : assigneeId
            }, {
                silent : true,
                success : this.onSaveSuccess,
            });
        },

        //キャンセルボタンクリックのイベントハンドラ
        onCancelClick : function() {
            this.backTodoLists();
        },

        //更新成功
        onSaveSuccess : function() {
            this.backTodoLists();
        },

        //TODOリスト画面に戻る
        backTodoLists : function() {
            Backbone.history.navigate('#todo-lists', true);
        }

    });
    return TodoDetailItemView;
 });

ユーザ一覧の表示に関しては、todo-composite-view.jsでやったことと同様です。

  • ユーザ一覧のセレクトボックスを処理するためui変数にuserListを追加。
  • initialize関数内で、todo-layout-viewから渡されたユーザ一覧collection(options.userListに入ってくる)を自身の中(this.userListに保持)
  • onRender関数内で、showUserList関数を実行してユーザ一覧の表示を行う
  • ユーザ一覧表示後、表示するTODOの担当者のユーザID(modelのassigneeから取得)を選択された状態に設定。
  • showUserList関数は、todo-composite-view.js内の同関数のコピペです!(今回はこれでご了承を!)
  • 更新ボタンクリック時の処理(onUpdateClick関数)では、ユーザ一覧で選択したユーザを担当者(assignee)をして設定しています。

実装

では、実際に修正して画面の動作確認をしてみましょう。

  • :white_check_mark: app/View/Layouts/default.ctpを上記の通り修正。
  • :white_check_mark: app/webroot/js/collections/user-collection.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/models/todo-model.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/views/todo-layout-view.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/views/todo-composite-view.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/views/todo-item-view.jsを上記の通り新規作成。
  • :white_check_mark: app/webroot/js/views/todo-detail-layout-view.jsを上記の通り新規作成。
  • :white_check_mark: app/webroot/js/views/todo-detail-item-view.jsを上記の通り新規作成。
  • :white_check_mark: 動作確認!
  • :white_check_mark: Gitにコミット

GitHubでのdiff表示へのリンク

第6回 Lesson1 クライアントサイド · suzukishouten-study/rest-study@f1623dc

以上です!

次回予告

次回テーマは、サーバー・クライアントの両面からかけるバリデーション - connpassです。
バリデーション機能を付けて、「ちゃんとした」アプリケーションに成長させていきます。
またぜひご参加ください!

コメント/フィードバックお待ちしております。

参加者の方も、そうでない方もお気づきの点があればお願い致します。