DatatableのInfinite Loadingを実装してみた (lwc)

# 背景
Salesforceにおいて、DatatableとのRowに対するアクションは使用頻度と重要度が高いのは、Spreadsheetライクに利用したい需要があるからだと考えています。ただデータの量が多くなると、データの処理能力と表示スピードを考慮した設計にする必要があります。

Datatableの形としては２種類あります。

1. `Datatable`でrow actionができるテーブルを作成する
1. `Iteration`で、擬似的テーブルを作成する

大量データの表示方法としても２種類あります。
1. `Pagination`でoffsetをPage数を利用してインクリメント取得する
1. `Infinite loading`で、延々終わるまで取得する

また、データをインクリメント的な取得方法も二つあります。
1. SOQLの`Offset`をすでに取得したデータ量で設定しクエリする
1. `Id`や`CreatedDate`をインクリメントごとで再設定しそれ以降を取得する

今回は、Infinite Loadingを試してみました。

:::note info
Offsetの最大は2000です。それ以上をSOQLの回避策はQueryMoreで、カーディナリティが高く、一意の値が多い項目を使用することを勧めています。
[Workaround for offset 2000 limit on SOQL query (Salesforce)](https://help.salesforce.com/s/articleView?id=000387840&type=1)
[SOAP API - queryMore (Salesforce)](https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_calls_querymore.htm)
[OFFSET (Salesforce)](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_offset.htm)
::: 

# デモ
50件ずつインクリメント取得しています。
![infinite-loading-demo-640.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/9d6c2078-ffdd-02a0-1baf-0e3901199b29.gif)


# 構成
- Lead
- Lwc * 1
- Apex * 1
# 下準備
前回の投稿のMockarooでダミーデータを作成
[Mockarooでダミーデータを作成してみた](https://qiita.com/masahiro_sf/items/86551599b08ea4094d03)
# 内容
まずデータの取得です。私はmapで包んで投げるのが好きです。parametersはひとつずつで問題ないのでそこはお好みで。
## SOQL
<details><summary> SOQL (getLeads) </summary>
返すデータはLeadのリスト、変数はstringSetかdateSetでまとめて管理しています。データ数はLIMIT 50です。

:::note 
DateTimeはJavaScriptでは`yyyy-mm-ddT00:00:00.000Z`なので渡すMapの型がDateTimeであればそのままで問題ないですが、String型で渡す場合は、`yyyy-mm-dd 00:00:00`に成形し渡した先でDateTime.valueof(string)としてDateTime型に戻す手間があります。今回は、dateSetがDateTimeのMapなのでそのままです。
:::

```
@AuraEnabled
    public static List<Lead> getLeads(String searchTerm, Map<String, String> stringSet, Map<String, DateTime> dateSet) {
        DateTime lastCreatedDate = dateSet.get('lastCreatedDate');
        String lastId = stringSet.get('lastId');
        String ownerAlias = stringSet.get('ownerAlias');
        String status = stringSet.get('status');     
        String leadSource = stringSet.get('leadSource');     
        String query = 'SELECT Id, Company, LastName, FirstName, Email, Phone, Status, LeadSource, Website, Industry, OwnerId, Owner.Alias, CreatedDate, CreatedBy.Alias, (SELECT Id, Type, OwnerDivision__c, Owner.Alias FROM Cases__r), (SELECT Id, CreatedDate, Type, CreatedById FROM Tasks ORDER BY CreatedDate DESC LIMIT 10) FROM Lead';    
        List<String> conditions = new List<String>();
        conditions.add('Status != \'Unqualified\'');
        conditions.add('IsConverted = false');
        if (lastCreatedDate != null && String.isNotBlank(lastId)) {
            conditions.add('(CreatedDate < :lastCreatedDate OR (CreatedDate = :lastCreatedDate AND Id < :lastId))');
        }
        if (String.isNotBlank(ownerAlias)) {
            conditions.add('Owner.Alias = :ownerAlias');
        }
        if (String.isNotBlank(leadSource)) {
            conditions.add('LeadSource = :leadSource');
        } 
        if (String.isNotBlank(searchTerm)) {
            String keyword = '%' + searchTerm + '%';
            conditions.add('(Company LIKE :keyword OR Email LIKE :keyword OR LastName LIKE :keyword OR FirstName LIKE :keyword)');
        }    
        if (!conditions.isEmpty()) {
            query += ' WHERE ' + String.join(conditions, ' AND ');
        }
        
        query += ' ORDER BY CreatedDate DESC, Id DESC LIMIT 50';
        system.debug(query);
        return Database.query(query);
    }
```
</details>

`lastId`と`lastCreatedDate`が、スクロールによりSpinnerが表示されるたびにDatatableの`onloadMore`アクションで呼び出されたmethod内で最後のRecordから取得します。それを、loadDataへ投げて次の50件を取得してくるparameterになります。

## LWC
### Datatable
<details><summary>Datatable (html)</summary>

```
<lightning-datatable
  key-field="id" 
  data={rows} 
  columns={columns} 
  show-row-number-column="true"
  enable-infinite-loading 
  load-more-offset="20" 
  onloadmore={loadmoreData}>
</lightning-datatable>
```
</details>

Datatableでは、`onloadMore`でテーブルの下部に近づいた時に起動するアクションを設定します。`load-more-offset`は下部からどのくらい上かpixel単位で数値を設定できます。早めにアクションして欲しい場合は数値を大きくしますが、デフォルトは20pxです。`enable-infinite-loading`は必須です。
参照 
[lightning-datatable (salesforce) ](https://developer.salesforce.com/docs/component-library/bundle/lightning-datatable/documentation)

### loadData
<details><summary>loadData (JS)</summary>

```
    loadData() {
        return new Promise((resolve, reject) => {
            getLeads({searchTerm: this.searchTerm, stringSet: this.stringSet, dateSet: this.dateSet })
            .then(data => {
    
                if (data.length) {
                    const mappedData = this.mapData(data);
                    this.rows = (this.rows || []).concat(mappedData); // concatenate mapped data
                }
                resolve();
            })
            .catch(error => {
                console.error(error);
            })
            .finally(() => {
                if (this.rows && this.rows.length > 1) {
                    this.tempLastCreatedDate = this.rows[this.rows.length - 1].CreatedDate;
                    this.tempLastId = this.rows[this.rows.length - 1].Id;
                } else {
                    this.tempLastCreatedDate = new Date();
                    this.tempLastId = null;
                }
            });
        });
    }
```

ここで取得したデータの一番最後のlastIdとlastCreatedDateを取得しています。
```
this.tempLastCreatedDate = this.rows[this.rows.length - 1].CreatedDate;
this.tempLastId = this.rows[this.rows.length - 1].Id;
``` 
</details>

`loadData`で取得します。@wireでもconnectedCallbackでも実装は可能ですが、@wireがデータ取得が速すぎてspinnerが出ないほどでした。今回はデータ量が50件ごとなのとspinnerが出た方が**取得している感**が出るので、connectedCallbackを利用しました。

また、データをdatatableに合うようにmapをしますが、mapについてはcellをデータによっては着色したり、iconをTypeAttributeでclass付けしたりダイナミックな表現にするときに噛ませます。詳細は本投稿の最後で。

### loadmoreData
<details><summary>loadMoreData (JS)</summary>

```
    async loadmoreData(event) {
        if (this.rows.length < 50) {
            return; // the datatable frame is too short, so it will keep rendering loadmore unless to stop by return here
        }
        const { target } = event;
        target.isLoading = true;
        this.dateSet.lastCreatedDate = this.tempLastCreatedDate;
        this.stringSet.lastId = this.tempLastId;
        try {
            await this.loadData(); 
            target.isLoading = false;
        } catch (error) {
            console.error(error);
        }
    }
```
</details>

:::note warn 
`loadmoreData`は、スクロールでDatatableの下部に来た時に呼ばれるメソッドで、現在のテーブル上にあるデータが50を下回りテーブルの下部が表示されてしまう高さの場合、DatatableのloadしたらすぐDatatableの下部に触ると見なされ永遠とloadData()をcallしてしまいます。そこで再取得の制御のために、このreturnが必要です。
:::

# まとめ
TaskやEvent, Chatterなど明らかにCreatedDateが降順で表示されるべきもの、数値として2000で区切るものでもないものにはInfinite Loadingは選択肢としてありかなと感じました。

# 参照
[lightning-datatable (salesforce) ](https://developer.salesforce.com/docs/component-library/bundle/lightning-datatable/documentation)
[Workaround for offset 2000 limit on SOQL query (Salesforce)](https://help.salesforce.com/s/articleView?id=000387840&type=1)
[SOAP API - queryMore (Salesforce)](https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_calls_querymore.htm)
[OFFSET (Salesforce)](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_offset.htm)

## 関連投稿
[Mockarooでダミーデータを作成してみた](https://qiita.com/masahiro_sf/items/86551599b08ea4094d03)
[Searchable Combobox 検索型選択リストを作ってみた (lwc)](https://qiita.com/masahiro_sf/items/a1f6d0e6a38da7e0332f)

## github
https://github.com/liuxiachanghong/datatable-lwc/tree/main