こんにちは、開発ユニットbcです。

去る8月2日、弊社の子会社である株式会社トラスト&グロースが、新サービス「URIHO」(https://uriho.jp)を開始しました。
(「URIHO」は年商5億円以下の企業様を対象とした、売掛債権の保証サービスです。ご興味ある方はサイトをご覧ください。)

当ユニットがこの新システムの開発を担当したのですが、その中からJSONをベースにした画面処理をどう実装したかについて簡単にご紹介します。

構成としてはサーバサイドにRailsとJBuilder、フロントにAngularJSを採用しています。
JBuilderにはJSON編集のための強力な機能が備わっており、また、AngularJSとの相性が非常に良いと感じました。

処理の流れ

sqd

処理のおおまかな流れとしては上記のような感じです。
では、画面イメージとソースコードで説明していきます。 

一覧表示

テーブルの全レコードを取得して表示するだけのサンプル画面です。
list


■RailsのController
app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
  def index
  end

  def search
    @companies = Company.all
    respond_to do |format|
      format.json
    end
  end
・・・
indexメソッドはデータなしのHTMLを返却するだけなので、特に何もしていません。
HTMLロード後に非同期で呼び出されるsearchメソッドでデータを全件取得し
インスタンス変数@companiesに保持しています。

■JBuilder
app/views/companies/search.json.jbuilder
json.array!(@companies) do |company|
  json.(company, :id, :company_name, :boss_name)
  json.edit_url companies_edit_path(company.id)
  json.emails company.emails do |email|
    json.(email, :id, :email)
  end
end
出力されるJSONデータ
[
    {
        "id": 1, 
        "company_name": "株式会社ラクーン", 
        "boss_name": "楽太郎", 
        "edit_url": "/companies/1/edit", 
        "emails": [
            {
                "id": 1, 
                "email": "rakutaro1@raccoon.ne.jp"
            }, 
            {
                "id": 2,  
                "email": "rakutaro2@raccoon.ne.jp"
            }
        ]
    }, 
    {
        "id": 2, 
        "company_name": "株式会社トラスト&グロース", 
・・・

コントローラで取得した@companiesからJSONデータを生成しています。

json.(company, :id, :company_name, :boss_name) 
は、第1引数の要素のうち、列挙された項目について出力しています。 
尚、下記のコードと同義です。 
json.extract! company, :id, :company_name, :boss_name

json.emails company.emails do |email| 
 json.(email, :id, :email)
end
で配列項目を出力しています。
また下記のような、より簡潔な書き方も可能です。
json.emails company.emails, :id, :email

json.edit_url companies_edit_path(company.id) 
companies_edit_path()でroutes.rbの定義からcompanies_editのパスを生成します。

■AngularJSのコントローラ & ERB
app/views/companies/index.html.erb
・・・
<div class="container" ng-controller="CompanyCtrl as companyCtrl"> <h1>会社一覧</h1> <table class="table table-bordered"> <thead> <tr> <th>会社名</th> <th>代表者名</th> <th colspan="3"></th> </tr> </thead> <tbody> <tr ng-repeat="company in companyCtrl.companies"> <td>{{company.company_name}}</td> <td>{{company.boss_name}}</td> <td><a ng-href="{{company.edit_url}}">編集</a></td> </tr> </tbody> </table> </div> ・・・

app/assets/javascripts/companies/companies_controller.js
(function() {
  function CompanyCtrl($http) {
    var self = this;
    self.companies = [];
    $http.get("companies/search.json").then(function(result){
      self.companies = result.data;
    });
  }
  angular.module("SampleApp").controller("CompanyCtrl",CompanyCtrl);
})();

非同期でGETリクエストを送信し、取得結果のJSONデータをAngularJSのコントローラに保持して、
$http.get("companies/search.json").then(function(result){
  self.companies = result.data;
}); 

画面に表示しています。
<tr ng-repeat="company in companyCtrl.companies"> 
  <td>{{company.company_name}}</td>

編集画面

1レコードの表示と更新のサンプルです。
先ほどの一覧画面の"編集"リンクを押すとこの画面が開きます。
edit
 

まずは初期表示から

■RailsのController
app/controllers/companies_controller.rb
・・・
class CompaniesController < ApplicationController ・・・
def edit end def load @company = Company.find(params[:id]) respond_to do |format| format.json end end ・・・
一覧画面と同じく、editメソッドはデータなしのHTMLを返却するだけなので、特に何もしていません。
HTMLロード後に非同期で呼び出されるloadメソッドで受け取ったパラメータ"id"に紐づくレコードを1件取得してインスタンス変数@companyに保持しています。

■JBuilder
app/views/companies/load.json.jbuilder
json.(@company, :id, :company_name, :boss_name)
json.emails @company.emails do |email|
  json.(email,:id,:email)
  json._destroy false
end
json.update_path companies_update_path(format: :json)
出力されるJSONデータ
{
    "id": 1, 
    "company_name": "株式会社ラクーン", 
    "boss_name": "楽太郎", 
    "update_path": "/companies/1/update.json", 
    "emails": [
        {
            "_destroy": false, 
            "email": "rakutaro1@raccoon.ne.jp", 
            "id": 1
        }, 
        {
            "_destroy": false, 
            "email": "rakutaro2@raccoon.ne.jp", 
            "id": 2
        }
    ]
}

format: :jsonと指定することで、パスが"update.json"と出力されます。
json.update_path companies_update_path(format: :json)

"_destroy"については、この後の更新処理で説明します。

■AngularJSのコントローラ & ERB
app/views/companies/edit.html.erb
・・・
<div class="container" ng-controller="CompanyEditCtrl as companyCtrl"> <h3>編集</h3> <form> <div class="form-group" ng-class="{'has-error': companyCtrl.isError('company_name')}"> <label>会社名</label> <input type="text" class="form-control" ng-model="companyCtrl.company.company_name"> <span class="text-danger" ng-repeat="message in companyCtrl.getMessages('company_name')"> {{message}} </span> </div> <div class="form-group" ng-class="{'has-error': companyCtrl.isError('boss_name')}"> <label>代表者名</label> <input type="text" class="form-control" ng-model="companyCtrl.company.boss_name"> <span class="text-danger" ng-repeat="message in companyCtrl.getMessages('boss_name')"> {{message}} </span> </div> <div class="form-group row " ng-repeat="email in companyCtrl.company.emails"> <label class="control-label col-md-12" ng-show="$first">メールアドレス</label> <div class="col-md-12" ng-class="{'has-error': companyCtrl.isError('emails[{{$index}}].email')}"> <input type="text" class="form-control" ng-model="email.email"> <span class="text-danger" ng-repeat="message in companyCtrl.getMessages('emails['+$index+'].email')"> {{message}} </span> </div> </div> <input type="button" class="btn btn-primary" value="更新" ng-click="companyCtrl.update()"> </form> </div> ・・・

app/assets/javascripts/companies/companies_edit_controller.js
(function() {
  function CompanyEditCtrl($http,$window,$location) {
    var self = this;
    self.company = {};
    self.errors = [];
    $http.get($location.path() + "/load.json").then(function(result){
      self.company = result.data;
    });
・・・

angular.module("SampleApp").controller("CompanyEditCtrl", CompanyEditCtrl); })();

一覧画面と同じく、この部分でJSONデータを非同期で取得しています。
$http.get($location.path() + "/load.json").then(function(result){
  self.company = result.data;
});

続いて更新処理

■RailsのController
app/controllers/companies_controller.rb
・・・
class CompaniesController < ApplicationController ・・・
def update @company = Company.find(params[:id]) if @company.update(update_params) render json: { path: companies_path } else render :update_error, status: :unprocessable_entity end end private def update_params params.require(:company).permit( :company_name, :boss_name, emails_attributes: [ :id, :email, :_destroy ] ) end ・・・

@company.update(update_params) 
で、画面から送信されたパラメータでデータを更新しています。

更新が成功した場合は
render json: { path: companies_path }
で一覧画面のパスを返し、AngularJSのコントローラ側で画面遷移を実行しています。

更新が失敗した場合については後述します。

■AngularJSのコントローラ
app/assets/javascripts/companies/companies_edit_controller.js
(function() {
  function CompanyEditCtrl($http,$window) {
    var self = this;
    self.company = {};
    self.errors = [];

・・・

self.update = function() { angular.forEach(self.company.emails,function(v,i) { v._destroy = v.email === ""; }); $http.put(self.company.update_path, { company: { company_name: self.company.company_name, boss_name: self.company.boss_name, emails_attributes: self.company.emails } }).then(function(result){ $window.location.href = result.data.path; } ,function(result) { self.errors = result.data.errors.messages; }); } self.isError = function(key) { for(var i = 0; i < self.errors.length; i++) { if(self.errors[i].key === key) { return true; } } return false; } self.getMessages = function(key) { for(var i = 0; i < self.errors.length; i++) { if(self.errors[i].key === key) { return self.errors[i].messages; } } return null; } } angular.module("SampleApp").controller("CompanyEditCtrl", CompanyEditCtrl); })();

■JBuilder(バリデーションエラー用)
app/views/companies/update_error.json.jbuilder
json.errors do
  json.messages do
    json.array!(@company.errors.to_hash(true)) do |key, messages|
      next if key.to_s.include?("emails.")
      json.key "#{key}"
      json.messages messages
    end
    @company.emails.each_with_index do |v, i|
      json.array!(v.errors.to_hash(true)) do |key, messages|
        json.key "emails[#{i}].#{key}"
        json.messages messages
      end
    end
  end
end

出力されるJSONデータ
{
    "errors": {
        "messages": [
            {
                "key": "company_name", 
                "messages": [
                    "Company name can't be blank"
                ]
            }
        ]
    }
}
 
更新ボタンをクリックされたら
ng-click="companyCtrl.update()"

PUTリクエストを非同期で送信
$http.put(self.company.update_path, {
  company: {
  company_name: self.company.company_name,
    boss_name: self.company.boss_name,
    emails_attributes: self.company.emails
  }
})

更新結果が正常の場合、一覧画面に遷移
.then(function(result){
  $window.location.href = result.data.path;
}

更新結果がエラーだった場合、エラーデータを保持して
,function(result) {
  self.errors = result.data.errors.messages;
});

エラー時にはエラーメッセージを表示したり
<span class="text-danger" ng-repeat="message in companyCtrl.getMessages('company_name')">
  {{message}}
</span>

テキストボックスの背景が赤くなるクラスを付与
ng-class="{'has-error': companyCtrl.isError('company_name')}"
edit_err1


配列項目のエラー処理について

配列項目のエラーをRailsのmodelとAngularJSで連携させるには若干の工夫が必要でしたので、それについて説明します。

今回のサンプルではメールアドレスがそれに該当します。

■JBuilder
app/views/companies/edit.json.jbuilder
json.errors do
  json.messages do
    json.array!(@company.errors.to_hash(true)) do |key, messages|
      next if key.to_s.include?("emails.")
      json.key "#{key}"
      json.messages messages
    end
    @company.emails.each_with_index do |v, i|
      json.array!(v.errors.to_hash(true)) do |key, messages|
        json.key "emails[#{i}].#{key}"
        json.messages messages
      end
    end
  end
end
出力されるJSONデータ
{
    "errors": {
        "messages": [
            {
                "key": "emails[0].email", 
                "messages": [
                    "Email is invalid"
                ]
            }, 
            {
                "key": "emails[1].email", 
                "messages": [
                    "Email is invalid"
                ]
            }
        ]
    }
}


@company.emails.each_with_index do |v, i|
のループ処理内で
json.key "emails[#{i}].#{key}"
要素番号を付与した形でエラー項目のキーを作成しています。

要素番号付きの項目名にマッピングしてエラーメッセージを表示します。
<span class="text-danger" ng-repeat="message in companyCtrl.getMessages('emails['+$index+'].email')">{{message}}</span>
※$indexはng-repeat内でのインデックス番号です

同様に、テキストボックスの背景が赤くなるクラスを付与しています。
ng-class="{'has-error': companyCtrl.isError('emails[{{$index}}].email')}"

ちなみに
next if key.to_s.include?("emails.")
この部分は、"emails"をキーにしたエラー項目が作成されるのを抑制しています。

edit_err2


削除処理について

_destroyパラメータを使用した削除処理について説明します。

モデルに_destroy属性をtrueで設定して更新処理を行うと、ActiveRecordはそのレコードを削除してくれるのでそれを利用します。

※今回の画面仕様では、メールアドレスのテキストボックスを空にしたら削除することとしています。
■AngularJSのコントローラ
app/assets/javascripts/companies/companies_edit_controller.js
(function() {

・・・

self.update = function() { angular.forEach(self.company.emails,function(v,i) { v._destroy = v.email === ""; }); $http.put(self.company.update_path, { company: { company_name: self.company.company_name, boss_name: self.company.boss_name, emails_attributes: self.company.emails } ・・・

■RailsのController
app/controllers/companies_controller.rb
・・・
class CompaniesController < ApplicationController ・・・
def update @company = Company.find(params[:id]) if @company.update(update_params) render json: { path: companies_path } else render :update_error, status: :unprocessable_entity end end private def update_params params.require(:company).permit( :company_name, :boss_name, emails_attributes: [ :id, :email, :_destroy ] ) end ・・・

メールアドレスが空の場合、_destroyをtrueに設定しサーバへ送信
angular.forEach(self.company.emails,function(v, i) {
  v._destroy = v.email === "";
});

受け取ったパラメータでモデルをそのまま更新
@company.update(update_params)
パラメータに"_destroy=true"が設定されているとActive Recordが自動で削除処理を行ってくれます。


以上、かなり簡潔化した内容ではありましたが、JSONベースの画面処理についてのご紹介でした。