• 8
    Like
  • 0
    Comment
More than 1 year has passed since last update.

Salesforce App Cloud Advent Calendar 2015の16日目の記事ということでVisualforce × AngularJSについて投稿したいと思います。

はじめに

VisualforceとAngularJSをつかった機能のデモ動画とサンプルコードをつくりました。

デモ動画

Visualforce × AngularJSで次のような動きをするアプリを簡単に作成できます。
Force.com Demo #98 - Apex AngularJs App

Force.com Demo #98 - Apex AngularJs App
https://www.youtube.com/watch?v=Q5CoU9ov3z0

サンプルコード

今回紹介するサンプルアプリのコードです。
スクリーンショット 2015-12-14 0.00.20.png

tyoshikawa1106/apex-vf-angular
https://github.com/tyoshikawa1106/apex-vf-angular

AngularJSの読み込み

Visualforce開発にはJavaScriptを読み込むためのapex:includeScriptタグが用意されています。

<apex:includeScript value="https://code.jquery.com/jquery-2.1.4.min.js" />
<apex:includeScript value="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.8/angular.min.js" />
<apex:includeScript value="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.8/angular-animate.min.js" />
<apex:includeScript value="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js" />

Zipにまとめて静的リソースにアップロードしたものを読み込む場合はURLFORを使います。こんな感じです。

<apex:includeScript value="{!URLFOR($Resource.MyLibrary, 'dist/jquery-2.1.4.min.js')}" />
<apex:includeScript value="{!URLFOR($Resource.MyLibrary, 'dist/angular.min.js')}" />
<apex:includeScript value="{!URLFOR($Resource.MyLibrary, 'dist/angular-animate.min.js')}" />
<apex:includeScript value="{!URLFOR($Resource.MyLibrary, 'dist/underscore-min.js')}" />

ng-appとng-controllerの宣言

ng-appとng-controllerはdivタグに宣言できます。

<apex:page standardController="Account" extensions="AngularAccountController" showHeader="true" sidebar="false" id="page">
  <head>
    <!-- 略 -->
  </head>
  <body>
    <div id="vf-page">
      <apex:sectionHeader title="{!$ObjectType.Account.Label}" subTitle="{!IF(account.Id != null, account.Name, '')}" />
      <apex:form id="form">
        <div ng-app="myApp">
          <div ng-controller="mainCtrl">
            <!-- 略 -->
          </div>
        </div>
      </apex:form>
    </div>
  </body>
</apex:page>

apex:formタグをng-app="myApp"の中に入れるとsyntaxエラーが発生しました。通常のformタグなら大丈夫かもしれませんが、ほとんどのapexタグを利用するにはapex:formタグの宣言が必須となっています。

apex:inputTextタグなどがまったく利用できないと開発がしずらくなると思いますので、ng-appタグの外に宣言しておくのが良さそうです。
スクリーンショット 2015-12-14 22.03.27.png

VisualforceとAngularJS

AngularJSの書き方はいろいろあると思いますが、こんな感じに書くことができます。

AngularJSの宣言

<apex:page >
    <script type="text/javascript">
        (function($j){
            // AngularJS
            var myApp = angular.module('myApp', ['ngAnimate']);
            myApp.controller('mainCtrl', ['$scope', '$sce', function($scope, $sce) {

                // ・・・略・・・

                $scope.doSave = function(event) {
                    event.preventDefault();

                    // ・・・略・・・

                };

                // ・・・略・・・
            }]).filter('unsafe', function($sce) { return $sce.trustAsHtml; });

            // ・・・略・・・

        })(jQuery);
    </script>
</apex:page>

apexタグとng属性

AngularJSにはng-clickやng-changeなど便利なng属性が用意されています。

<button ng-click="count = count + 1" ng-init="count=0">
  Sample Button
</button>

このng属性ですが、そのままではapexタグで利用できません。
スクリーンショット 2015-12-14 22.53.15.png

apexタグでng属性を利用するには先頭に『html-』を追加します。

<apex:commandButton value=" Save " html-ng-click="doSave($event)" />

これでapex:commandButtonなどのapexタグでもng属性を宣言できます。この『html-』という考え方ですが、今回のng属性だけではなく、HTML5のplaceholderなどの属性も同じルールが適用されます。

apex:commandButtonなどでng-clickを利用する場合、1つ注意点があります。apex:commandButtonでng-clickを実行するとJSの処理が終わった後に画面の再描画が実行されます。
スクリーンショット 2015-12-14 22.59.50.png

この問題は『event.preventDefault();』で回避可能です。

$scope.doSave = function(event) {
    event.preventDefault();

    // ・・・略・・・

};

AngularJSで『event』を利用するにはng-cickの引数に『$event』を指定すれば利用できます。

<apex:commandButton ・・・ html-ng-click="doSave($event)" />

これでJS処理が終わった後に再描画の処理が実行されるのを防ぐことができます。

RemoteActionをつかったJSとApexの値渡し

AngularJSなどでJavaScriptメインの画面を開発する場合、reRenderによる値渡しや画面のリフレッシュは相性がよくありません。基本reRenderは使用せずにRemoteActionをつかったやり取りで対応する必要があります。

RemoteActionで値を渡すときですが、実はsObject型の変数として渡すことができます。次のようにJS側でオブジェクト変数を用意します。このとき変数名は対象のsObjectの項目API名と合わせておきます。

getAccountObject: function(prmAccountId) {
    var accountId = null;
    if (prmAccountId) {
        accountId = prmAccountId;
    }

    var accountName = document.getElementById('{!$Component.form.block.section.accName}').value;
    var accountNumber = document.getElementById('{!$Component.form.block.section.accNumber}').value;
    return {
        Id: accountId,
        Name: accountName,
        AccountNumber: accountNumber
    };
}

これでAccount型変数として渡すことができました。カスタムオブジェクト、カスタム項目でも問題なく渡すことが可能です。

@RemoteAction
public static AngularAccountResult doSave(Account account, ・・・・) {
    // ・・略・・
}

API名をあわせなかった場合、『No such column...』というエラーになります。
スクリーンショット 2015-12-14 23.49.18.png

sObject型の項目として存在していないものを渡したい場合は1つクラスを用意してそちらにセットして渡すのがいいと思います。

JavaScript側でオブジェクト変数をJSON文字列に変換して渡す方法もあります。ですがsObjectやクラスによる値渡しの時には不要だった型変換の処理や判定が必要になってしまったり、テストクラスをつくる際に必要なテストデータ作成が少し複雑になってしまいます。

基本的にはJSON文字列よりsObject型またはクラスで渡すほうが処理もスッキリすると思います。JSON文字列で渡す方法について興味がある方は次のリンク先にまとめてあります。

SFDC:Object型変数をApexで扱う方法について
http://tyoshikawa1106.hatenablog.com/entry/2015/12/12/154118

RemoteActionの複数レコードの値渡し

RemoteActionでオブジェクト型変数をsObject型として渡すことができることを紹介しました。ここで1つ注意点があります。

一件のレコードを渡す場合、sObject型でそのまま受け取ることができました。なので複数件の場合は、『List』をつかえば良いように思えます。

...ですがList型変数で渡そうとすると次のようにエラーとなってしまいます。
スクリーンショット 2015-12-15 0.20.47.png

複数件数を渡す場合は、List型ではなくMap型として渡せばいいみたいです。
http://salesforce.stackexchange.com/a/9947/11530

Mapで渡した後はApexクラス側でKeySet()の件数分ループしながらList型の変数にセットしていけます。

public List<Contact> getContactsByApexMap(Map<Integer, AngularAccountApexContact> apexContactMap, Id accountId) {
    List<Contact> contacts = new List<Contact>();
    for (Integer key : apexContactMap.keySet()) {
        if (apexContactMap.containsKey(key)) {
            // 取引先責任者情報取得
            Contact c = apexContactMap.get(key).contact;
            c.AccountId = accountId;
            // 変更判定フラグ取得
            Boolean isChanged = false;
            if (apexContactMap.get(key).isChanged != null) {
                isChanged = apexContactMap.get(key).isChanged;
            }
            // IDなし(新規) or 値変更(更新)の場合、登録対象としてリストに追加
            if (String.isEmpty(c.Id) || isChanged) {
                contacts.add(c);
            }
        }
    }
    return contacts;
}

RemoteActionで値渡しをするときの基本的な考え方はこんな感じです。

RemoteActionと日付項目

RemoteActionと日付型項目についてもよくハマるところだと思います。文字列として渡そうとしたり、new Date()で日付型に変換して渡そうとしてもエラーとなります。

日付の渡し方については悩んでいたところ、Twitterで教えてもらいました。Date型の値はUTCのepochミリ秒にしないといけなかったみたいです。

日付のミリ秒変換はgetTime()で対応できます。

SLAExpirationDate__c: new Date(accountDate).getTime()

これで日付に値をセットしてRemoteActionで値を渡すことができました。・・・ただし、もう一つ対応が必要です。これだけだとUTC形式なので登録時に9時間ずれてしまいます。

入力時
スクリーンショット 2015-12-15 1.23.37.png

保存されたレコード
スクリーンショット 2015-12-15 1.24.53.png

この問題の対応方法はいくつかあると思うのですが、区切り文字が"/"ではなく"-"の場合はGMT形式として扱われるみたいです。

SLAExpirationDate__c: new Date(accountDate.replace(/\//g, "-")).getTime()

ひとまずこれで入力した日付が登録されました。

RemoteActionと数式項目

数式項目をVisualforceで表示する場合、apex:outputFieldタグが必要になります。ですが、JavaScriptメインで実装するときはapex:outputFieldタグが利用できないケースがあります。そんな数式項目の表示でもAngularJSが活躍します。

次のようにHYPERLINKの数式項目があるとします。
スクリーンショット 2015-12-15 1.46.37.png

これを画面に表示には、『ng-bind』を利用します。

<div ng-bind="item.HomeLink__c" />

...値は表示されましたが、HTMLタグとして表示されてしまいました。
スクリーンショット 2015-12-15 1.51.22.png

正しくは『ng-bind-html』を利用します。

<div ng-bind-html="item.HomeLink__c | unsafe" />

これで数式項目をapex:outputFieldのときと同じように表示できます。
スクリーンショット 2015-12-15 1.56.02.png

『ng-bind-html』ですがAngularJS 1.2の辺りからそのままでは利用できなくなったみたいです。利用するには『$sce』(Strict Contextual Escaping)の宣言が必要になります。
https://docs.angularjs.org/api/ng/service/$sce

次のように宣言します。

myApp.controller('mainCtrl', ['$scope', '$sce', function($scope, $sce) {
  // ・・略・・
});

RemoteActionと数式項目についてはこんな感じです。

RemoteActionとエラーハンドリング

通常のApex開発ではapex:pageMessagesタグとApexPages.addMessageをつかってエラーメッセージを表示できますが、JavaScriptメインで実装する場合はreRenderによる画面リフレッシュは使えないので別の方法を検討する必要があります。

次のやり方を試してみました。処理結果を格納するクラスを用意します。

public with sharing class AngularAccountResult {

    public Id accountId {get; set;}
    public List<String> errorMessages {get; set;}

    /**
     * コンストラクタ
     */
    public AngularAccountResult() {
        this.accountId = null;
        this.errorMessages = new List<String>();
    }
}

値の未入力エラーがあったり、Exceptionエラーが発生した場合、エラーメッセージを格納する変数にエラー情報を追加して戻り値として返します。

AngularAccountResult result = new AngularAccountResult();
try {
    // ・・略・・

    // 取引先IDを返す
    result.accountId = account.Id;

} catch(DmlException e) {
    Database.rollback(sp);
    result.errorMessages.add(e.getDmlMessage(0));
    return result;
} catch(Exception e) {
    Database.rollback(sp);
    result.errorMessages.add(e.getMessage());
    return result;
}

return result;

こうして取得したエラーメッセージの情報をng-showやng-repeatと組み合わせてメッセージ表示するのがいいと思います。

<apex:component >
    <div class="message errorM3 ng-hide" role="alert" ng-show="errorMessages.length > 0">
        <table border="0" cellpadding="0" cellspacing="0" class="messageTable" style="padding:0px;margin:0px;">
            <tbody>
                <tr valign="top">
                    <td>
                        <img alt="ERROR" class="msgIcon" src="/s.gif" title="ERROR" />
                    </td>
                    <td class="messageCell">
                        <div class="messageText">
                            <span style="color:#cc0000">
                                <h4>ERROR</h4>
                            </span>
                            <br />
                        </div>
                    </td>
                </tr>
                <tr>
                    <td></td>
                    <td>
                        <span>
                            <ul ng-repeat="errorMsg in errorMessages track by $index" style="padding-left:10px;padding-top:0px;margin:0px">
                                <li ng-bind="errorMsg" style="padding-top:5px"></li>
                            </ul>
                        </span>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</apex:component>

AngularJSとVisualforce Component

ひとつ上にでてきたサンプルコードですが、Visualforce Componentとなっています。AngularJSのバインド変数はVisualforce Component内でもそのまま利用できました。

エラーメッセージ用のテーブルなど処理が長くなるものはVisualforce Componentとして外出ししておくと、コードがスッキリしてメンテナンスがしやすくなると思います。

スクリーンショット 2015-12-15 2.30.56.png

JS処理を実装する際のポイント

JavaScriptを実装する場合、Visualforceページに直接記述する方法と静的リソースにアップする方法の2種類が考えられます。Gruntやgulpなどのビルドツールの使い方を理解していれば、そちらを利用して静的リソースにアップするのがベストみたいです。

- 参考 -
http://qiita.com/stomita/items/0d978be1f3696e9ef7e8

自分はまだビルドツールは勉強中のため(早く覚えなきゃ...)、今回は他の方法で対応する場合について紹介します。ビルドツールを利用せずに開発する場合ですが、JSの処理を静的リソースにアップする形で開発を行うとJSの実装→静的リソースにアップ→JSの修正→静的リソースにアップ...という形で進めることになります。

実際に試したことがありますが、かなりツライです。また静的リソースにアップした場合、カスタム表示ラベルなどの宣言ができなくなることについても注意が必要です。

個人的には静的リソースにアップして修正するよりはVisualforceに直接記述するのがいいと思っています。

Visualforceページに直接記載する場合、よくあるのが次の書き方だと思います。

<apex:page standardController="Account" extensions="AngularAccountController" showHeader="true" sidebar="false" id="page">
  <head>
    <!-- 略 -->
  </head>
  <body>
    <div id="vf-page">
      <apex:sectionHeader title="{!$ObjectType.Account.Label}" subTitle="{!IF(account.Id != null, account.Name, '')}" />
      <apex:form id="form">
        <!-- 略 -->
      </apex:form>
    </div>
    <script type="text/javascript">
       
       
     JavaScript
       
       
       
       
       
       
       
       
    </script>
  </body>
</apex:page>

ちょっとした処理ならこれでも問題ありませんが、これが1000行2000行のJS処理になるとHTMLとJS部分を上下にスクロールすることになってツライ感じになります。

なのでHTML側とJS側は別ページに分けてapex:includeタグで差し込む形がいいと思います。

AngularAccount.page
<apex:page standardController="Account" extensions="AngularAccountController" showHeader="true" sidebar="false" id="page">
  <head>
    <!-- 略 -->
  </head>
  <body>
    <div id="vf-page">
      <apex:sectionHeader title="{!$ObjectType.Account.Label}" subTitle="{!IF(account.Id != null, account.Name, '')}" />
      <apex:form id="form">
        <!-- 略 -->
      </apex:form>
    </div>
    <apex:include pageName="AngularAccountScript" />
  </body>
</apex:page>
AngularAccountScript.page
<apex:page>
  <script type="text/javascript">
       
     JavaScript
       
       
    </script>
</apex:page>

これでJS処理が大きくなったときでもHTML側はスッキリした状態で開発を進められると思います。apex:includeタグで読み込む方法ですが、JavaScriptだけでなくCSSも同じように対応できます。

さいごに

AngularJSには変数のバインドなど便利な機能がたくさん用意されています。そのままのJavaScriptだけでDOM操作しながら処理を書いていくよりも、コード量が少なくなり処理がスッキリしました。

JSメインで実装するのはapexタグだけでやるより、意識することや対応することが増えて少し大変ですが、行追加などの操作がサクサク動作して快適になります。

RemoteActionはJS側の値をsObject型やClass型として渡せるということ覚えておけばJSメインの開発がやりやすくなるかなと思いました。(大量の引数を渡したり、JSON文字列で渡してApex側で変換作業をやらなくても大丈夫です。)

本題からは少し逸れますがApex開発するときはテストクラスの開発も忘れずに行いましょう。

カバー率を上げるだけでなくきちんとSystem.assertEqualで値を検証するようにするとエラーが発生したときの原因を調査しやすくなります。(リファクタリングもやりやすくなります。)

きちんとテストを書くことで品質が向上するだけでなく、実装時の判定処理の間違いに気づけたり、保守で引き継いだ人が作業しやすくなったりもするんじゃないかと思います。

以上、Visualforce×AngularJSの開発についてでした。