Visualforce+Apexで作るSalesforce一覧検索画面

Visualforceで一覧検索画面を作りました。作ったときに理解したことを整理する意味でも記事を書いて公開しておきます。
標準画面ベースで開発を行っていたとしてもここだけは作りこむことは多いのではないかと思うので、サンプルとして使っていただければうれしいです。

まだまだSalesforceの基本が理解できてきたレベルで、VisualforceやApexは触り始めたばかりです。もしもっとよい機能や書き方があるのでしたらコメントをお願いします。

## 何を作ったのか

カスタムオブジェクトの一覧検索画面

* 検索条件がいくつか設定されている
* 検索ボタンが押されると、その条件をもとにカスタムオブジェクトをSOQLで検索し、一覧表示する
* 見出しをクリックするとソート順が切り替わる

こんな画面です
![1.JPG](https://qiita-image-store.s3.amazonaws.com/0/47589/d20a5b02-f6bf-5f88-bcf0-f89e412334c1.jpeg)

＃機能的にはレポートでも実現できる機能ではあるのですが、そこはツッコまないでくださいm(_ _)m

バリデーションエラーも作りこんでいます
![2.JPG](https://qiita-image-store.s3.amazonaws.com/0/47589/57b7ab65-eb8c-e65e-e64c-033694858bc0.jpeg)


## 事前準備

### カスタムオブジェクト

先日のSalesforceWorldTourのminihack課題１の出張申請オブジェクトを使います。
https://developer.salesforce.com/ja/worldtour2016/minihacks

（たまたま実施した環境があったので流用しただけです。カスタム項目がいくつか入っていれば何でもよいです）

### カスタム項目

minihackの中で作るものは省略します。

| ラベル | API参照名 | 型 | 説明 |
|:------|:---|:------|:---------------------|
| ステータス  | Status__c  | 選択リスト  | リストの一例として用意  |
| 検索用ステータス  | SearchConditionStatus__c  | 選択リスト  | ステータスと選択肢が違う可能性を考慮し、別にした  |
| 検索用最終更新日From  | SearchConditionLastModifiedFrom__c  | 日付  | 検索対象は日時型だが、検索条件は日付とする |
| 検索用最終更新日To  | SearchConditionLastModifiedTo__c  | 日付  |   |


## 実装

### Apexコントローラ

```TravelRequestListController.apxc
public with sharing class TravelRequestListController {
    
    // 抽出対象となるフィールドリスト
    static List<String> TARGET_FIELDS = new List<String>{
         'Id'
        ,'Name'
        ,'TravelRequestName__c'
        ,'Status__c'
        ,'TravelStartDate__c'
        ,'TravelEndDate__c'
        ,'Purpose_of_Travel__c'
        ,'Total__c'
        ,'LastModifiedDate'
    };
    
    public SearchCondition condition{ get;set; } 		// 検索条件
    public List<Travel_Request__c> results { get;set; }	// 検索結果リスト
    public String sortingField { get;set; } 			// 見出しのソート用リンクを押された際のフィールド名

    /**
     * 初期化処理
     */
    public void init(){
        this.condition = new SearchCondition();
        this.results = new List<Travel_Request__c>();
    }
    
    /**
     * クリアボタン処理
     */
    public PageReference clear(){
        init();
        return null;
    }
    
    /**
     * 検索ボタン処理
     */
    public PageReference search() {

        // バリデーションチェック
        if( condition.validate() ){
            return null;
        }
        
        // 検索条件からSOQLを作成
        String soqlQuery = condition.getSoqlQuery();
        System.debug('[soql] ' + soqlQuery);

        try{
            this.results = database.query(soqlQuery);
        }catch(DmlException e){
            ApexPages.addMessages(e);
        }catch(Exception e){
            ApexPages.addMessages(e);
        }
        return null;
    }

    /**
     * ソートリンク処理
     */
    public PageReference sort(){
        
        // ソートを行う対象フィールドが不明な場合は何もしない
        if(this.sortingField == null ){
            return null;
        }
        
        // ソートを行う対象が、現在ソートしているフィールドと同じ場合はソート順を反対にする
        if(this.sortingField == this.condition.sortkey){
            this.condition.setOrderReverse();
        }
        // ソートを行う対象が、現在ソートしているフィールドと違う場合は新しい項目でソートするようにする
        else {
            this.condition.sortkey = this.sortingField;
        }
        
        // 検索実行
        search();
        return null;
    }
    
    /**
     * 現在のソートキーを取得するためのメソッド
     * 
     * ・検索結果の見出しにソート順を出すために使う
     * ・本来はgetSortOrderも含めて、ロジックもApex側で持ちたいが、
     * 　VisualForceから引数ありでメソッドが呼べないため、このようにしている。
     * 
     */
    public String getSortKey(){
        return this.condition.sortkey;
    }
    
    /**
     * 現在のソート順を取得するためのメソッド（▲ or ▼を返す）
     */ 
    public String getSortOrder(){
        return this.condition.sortOrderToString();
    }
    

    /**
     * 検索条件を管理するクラス
     */ 
    public Class SearchCondition {

	private Time JST_AM0 = Time.newInstance(9, 0, 0, 0);  
        
        /*
         * 検索条件の入力フィールド用
         * 
         * ・カスタムフィールドとして検索条件の入力項目を作成している。
         * ・こうすることで、プルダウンリストの場合はオブジェクトの設定画面から
         * 　選択リスト値を追加することで一覧画面の選択肢も追加される。
         */
	    public Travel_Request__c obj {get;set;}		
        
        public String sortkey { get;set; }			// ソートキー
        public String order { get;set; }        	// ソート順
        
        public SearchCondition() {
        	this.obj = new Travel_Request__c();
            
            // デフォルトは最終更新日時の降順とする
            sortkey = 'LastModifiedDate';
            order = 'DESC';
        }

        
        // 検索条件のバリデーションチェック
        public boolean validate(){
            boolean isError = false;
            if( this.obj.SearchConditionLastModifiedFrom__c != null &&
               	this.obj.SearchConditionLastModifiedTo__c != null &&
               	this.obj.SearchConditionLastModifiedFrom__c > this.obj.SearchConditionLastModifiedTo__c ){
            		ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, '最終更新日の範囲指定が逆転しています', ''));
                	isError = true;
            }
            return isError;
        }


        // 最終的なSOQLを生成（最大500件というのは固定）
        public String getSoqlQuery(){
            List<String> param = new String[]{ getFieldList(), getWhere(), getOrder() };
            return String.format('SELECT {0} FROM Travel_Request__c {1} {2} LIMIT 500', param);
        }
        
        // SELECT対象フィールド
        private String getFieldList(){
            return String.join(TARGET_FIELDS, ',');
        }
        
        // WHERE句作成
        private String getWhere(){
            List<String> param = new String[]{ };
            
            // ステータス
            if( !String.isBlank(this.obj.SearchConditionStatus__c) ){
                param.add('Status__c = \'' + obj.SearchConditionStatus__c + '\'');
            }
            // 最終更新日From
            if( this.obj.SearchConditionLastModifiedFrom__c != null ){
                Datetime fromDate = getStartOfDay(obj.SearchConditionLastModifiedFrom__c );
                param.add('LastModifiedDate >= ' + fromDate.format('yyyy-MM-dd\'T\'HH:mm:ss.000\'Z\''));
            }            
            // 最終更新日To
            if( this.obj.SearchConditionLastModifiedTo__c != null ){
                Datetime toDateAddOneDay = getStartOfDay( obj.SearchConditionLastModifiedTo__c.addDays(1));
                param.add('LastModifiedDate < ' + toDateAddOneDay.format('yyyy-MM-dd\'T\'HH:mm:ss.000\'Z\''));
            }
            
            if(param.isEmpty()){
                return '';
            }
            return 'WHERE ' + String.join(param, ' AND ');
        }
        
        // ORDERBY句作成
        private String getOrder(){
            List<String> param = new String[]{ sortkey, order };
            return String.format('ORDER BY {0} {1}', param);
        }
        
        private DateTime getStartOfDay(Date d){
            // GMT+9を考慮して、GMTで日本の0時を作成する（＝GMTでは1日前の15時）
            JST_AM0 = Time.newInstance(15, 0, 0, 0);
            return Datetime.newInstance(d.addDays(-1), JST_AM0);
        }
        
        // ソート順を見出しに表示する記号に変換する
        public String sortOrderToString(){
            if(this.order == 'DESC'){
                return '▼';
            }
            return '▲';
        }
        
        // ソート順を逆にする
        public void setOrderReverse(){
            if(this.order == 'DESC'){
                this.order = 'ASC';
            }
            else {
	            this.order = 'DESC';
            }
        } 
    }
}
```


### Visualforceページ


```TravelRequestList.vfp
<apex:page controller="TravelRequestListController" action="{!init}" sidebar="false" Id="TravelRequestList" >
<style type="text/css">
	#conditionTable { width: 50%; border: 0; margin-left: 5%; }
    #conditionTable .label { text-align: right; }
    #conditionTable .dateFormat { display: none; }
</style>
<script type="text/javascript">
	// ページを開いたときに初期フォーカスをしない
	beenFocused = true;
</script>
<apex:form id="form1">
    <apex:pageBlock title="出張一覧">
		<apex:pageMessages id="messagearea" showDetail="false"/>
        <apex:pageblockSection id="conditionSection" title="検索" columns="1">
		    <apex:outputpanel id="searchcondition">
                <table id="conditionTable">
                    <tr>
		               	<td class="label"><apex:outputLabel value="ステータス" for="scStatus" styleClass="labelCol" /></td>
                        <td><apex:inputField id="scStatus" value="{!condition.obj.SearchConditionStatus__c}" /></td>
                    </tr>
                    <tr>
                        <td class="label"><apex:outputLabel value="最終更新日"  styleClass="labelCol" /></td>
                        <td>
                            <apex:inputField id="scLastModifiedFrom" value="{!condition.obj.SearchConditionLastModifiedFrom__c}" />
                            <apex:outputLabel value=" ～　"  styleClass="labelCol" />
                            <apex:inputField id="scLastModifiedTo" value="{!condition.obj.SearchConditionLastModifiedTo__c}" />
                        </td>
            		</tr>
                </table>
    		</apex:outputpanel>
        </apex:pageblockSection>
        <apex:pageBlockButtons id="buttonSection" location="bottom" >
            <apex:commandButton value="検索" action="{!search}" style="font-size:12pt;width:100px;height:30px;" reRender="searchresult,messagearea"/>
            <apex:commandButton value="クリア" action="{!clear}" style="font-size:12pt;width:100px;height:30px;" reRender="searchcondition,searchresult,messagearea"/>
        </apex:pageBlockButtons>
    </apex:pageBlock>
</apex:form>
<apex:outputpanel id="searchresult">
    <apex:pageBlock title="検索結果：" rendered="{!(results.size == 0)}">
         検索条件に該当するデータがありません
    </apex:pageBlock>
    
    <apex:form id="resultForm">
        <apex:pageBlock id="resultBlock" title="出張一覧" rendered="{!(results.size > 0)}">
            <apex:outputtext style="width:110px" value="結果 : {!results.size}件"/>
            <apex:pageblockTable id="resultTable" value="{!results}" var="o" frame="box">
                <apex:column style="width:80px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="出張申請番号{!IF(sortKey == 'Name', sortOrder, '　')}">
                            <apex:param value="Name" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet>
                    <apex:outputlink value="/{!o.Id}"><apex:outputField style="width:80px" value="{!o.Name}"/></apex:outputlink>
                </apex:column>
                <apex:column style="width:160px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="出張申請名{!IF(sortKey == 'TravelRequestName__c', sortOrder, '　')}">
                            <apex:param value="TravelRequestName__c" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet> 
                    <apex:outputField style="width:150px" value="{!o.TravelRequestName__c}"/>
                </apex:column>
                <apex:column style="width:80px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="ステータス{!IF(sortKey == 'Status__c', sortOrder, '　')}">
                            <apex:param value="Status__c" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet>
                    <apex:outputField style="width:150px" value="{!o.Status__c}"/>
                </apex:column>
                <apex:column style="width:80px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="出張開始日{!IF(sortKey == 'TravelStartDate__c', sortOrder, '　')}">
                            <apex:param value="TravelStartDate__c" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet> 
                    <apex:outputField style="width:80px" value="{!o.TravelStartDate__c}"/>
                </apex:column>
                <apex:column style="width:80px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="出張終了日{!IF(sortKey == 'TravelEndDate__c', sortOrder, '　')}">
                            <apex:param value="TravelEndDate__c" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet> 
                    <apex:outputField style="width:80px" value="{!o.TravelEndDate__c}"/>
                </apex:column>
                <apex:column style="width:120px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="出張目的{!IF(sortKey == 'Purpose_of_Travel__c', sortOrder, '　')}">
                            <apex:param value="Purpose_of_Travel__c" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet> 
                    <apex:outputField style="width:150px" value="{!o.Purpose_of_Travel__c}"/>
                </apex:column>
                <apex:column style="width:120px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="総費用{!IF(sortKey == 'Total__c', sortOrder, '　')}">
                            <apex:param value="Total__c" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet> 
                    <apex:outputField style="width:150px" value="{!o.Total__c}"/>
                </apex:column>
                <apex:column style="width:80px">
                    <apex:facet name="header">
                        <apex:commandLink action="{!sort}" value="最終更新日時{!IF(sortKey == 'LastModifiedDate', sortOrder, '　')}">
                            <apex:param value="LastModifiedDate" name="String" assignTo="{!sortingField}" />
                    	</apex:commandLink>
                    </apex:facet> 
                    <apex:outputField style="width:80px" value="{!o.LastModifiedDate}"/>
                </apex:column>
            </apex:pageblockTable>
        </apex:pageBlock>
    </apex:form>
</apex:outputpanel>
</apex:page>
```

### Visualforceタブ

上記のVisualforceページを開くタブを作成する。

### Apexコントローラのテスト

簡単ではありますがテストも作っています。カバレッジは88%です。

余談ですが、テスト時にテストデータを登録したことに連動してステータスが更新されるという項目自動更新が仕込まれていたため、ステータスによる絞り込みのテストが失敗していました。テストデータでは正しく定義しているはずなのに・・と少しハマりましたが、1つ1つログを追っていくとテスト時にワークフローも実行されるんだということがよく理解できました。

```TravelRequestListControllerTest.apex
@isTest
public class TravelRequestListControllerTest {

    /**
     * 検索条件なしで検索
     */ 
    @isTest
    static void noKeywordSearch(){
        createRecords();
        TravelRequestListController sut = new TravelRequestListController();
        
        Test.startTest();
        sut.init();
        sut.search();
        Test.stopTest();
        
        System.assert(sut.results.size() == 3);
    }
    
    /**
     * ステータスを指定した検索の実施
     */ 
    @isTest
    static void statusSearch(){
        createRecords();
        TravelRequestListController sut = new TravelRequestListController();
        
        Test.startTest();
        sut.init();
        sut.condition.obj.SearchConditionStatus__c = '承認完了';
        sut.search();
        Test.stopTest();
        
        System.debug(sut.results.size());
        System.debug(sut.results);
        System.assert(sut.results.size() == 1);
    }
    
    /**
     * ソートキー変更、ソート順変更など
     */ 
    @isTest
    static void lastModifiedDateSearchWithSorting(){
        createRecords();
        TravelRequestListController sut = new TravelRequestListController();
        
        Test.startTest();
        sut.init();
        sut.condition.obj.SearchConditionLastModifiedFrom__c = Date.today();
        sut.condition.obj.SearchConditionLastModifiedTo__c = Date.today();
        sut.search();
        
        // 出張申請名の降順ソート
        sut.sortingField = 'TravelRequestName__c'; 
        sut.sort();                    
        Travel_Request__c actual1 = sut.results.get(0);
        System.assert(actual1.TravelRequestName__c == '出張3');

        // 昇順ソートに変更
        sut.sort();
        Travel_Request__c actual2 = sut.results.get(0);
        System.assert(actual2.TravelRequestName__c == '出張1');
        System.assert(sut.getSortKey() == 'TravelRequestName__c');
        System.assert(sut.getSortOrder() == '▲');

        Test.stopTest();
    }
    
    /**
     * クリアボタン処理
     */ 
    @isTest
    static void clear(){
        createRecords();
        TravelRequestListController sut = new TravelRequestListController();
        
        Test.startTest();
        sut.init();
        sut.search();
        System.assert(sut.results.size() > 0);
        sut.clear();
        Test.stopTest();
        
        System.assert(sut.results.size() == 0);
    }
    
    
    /**
     * テスト用に3レコード作成する
     */ 
    private static void createRecords(){
        List<Travel_Request__c> records = new Travel_Request__c[]{
              createRecord(1, '申請中')
            , createRecord(2, '承認完了')
            , createRecord(3, '却下')
        };
        insert records;
    }
    
    private static Travel_Request__c createRecord(Integer index, String status){
        Travel_Request__c tr = new Travel_Request__c();
	tr.TravelRequestName__c = '出張' + String.valueOf(index);
        tr.Status__c = status;
        tr.TravelStartDate__c = Date.newInstance(2017, 1, 1).addDays(index);
        tr.TravelEndDate__c = Date.newInstance(2017, 1, 4).addDays(index);
        tr.Airfare__c = 1000;
        return tr;
    }
    
}
```

## 感想

* 思った以上にシンプルに一覧画面が実現できた。これならテンプレとして活用できそう。
* 開発者コンソールはつらかった。補完やコメントアウトなど本当にやりにくい。
* Visualforceから引数ありでメソッド呼べないのは残念。paramのassignToなど概念の理解が難しい
* テストコードのstartTest/stopTestとassertをどういう順に書くべきなのかがわからない
* テストコードで共通のsetupメソッドがほしい。ある・・？