☁️

Lightning Web Components(LWC)で要素を動的に追加/削除するボタンの実装

19 min read

どんなもの?

カスタムコンポーネントを作成して、オブジェクトの項目詳細に要素を動的に追加(+)削除(-)するボタンを作成します。



デフォルトで用意されているコンポーネント
カスタム項目の新規作成には動的に要素を追加/削除できるものがないので実装してみます。

ネタバラシ

仕組みとしてはカスタム項目(データ型:ロングテキストエリア)にJSON文字列変換したデータを保存しているだけです。簡単!
ですのでついでにオブジェクトの全項目名の取得やlwcの特殊構文の解説も交えていきます。

(1)カスタム項目の追加

カスタムコンポーネントの使用対象オブジェクトに新規でカスタム項目を追加します。

  1. スクラッチ組織を開く sfdx force:org:open -u <username/alias>
  2. [設定]→[オブジェクトマネージャー]で対象オブジェクトを選択
  3. [項目とリレーション]で新規作成
  4. [カスタム項目の新規作成]でロングテキストエリアを選択し次へ
  5. [詳細を入力]で文字数を最大の131072にして次へ
  6. [項目レベルセキュリティの設定]はデフォルトで次へ
  7. [ページレイアウトへの追加]で項目の追加チェックボタンを外して保存
  8. スクラッチ組織の変更をpullしておく sfdx force:source:pull -u <username/alias>

7でレイアウトにJSON文字列データを表示しないようにチェックボタンを外します。
もしデータの中身を確認/表示したければチェックを外さなくてOKです。

(2)権限セットで(1)の許可

AppExchange開発で新規項目を作成した際によく忘れがちの工程です

  1. スクラッチ組織を開く sfdx force:org:open -u <username/alias>
  2. [設定]→[ホーム]→[権限セット]で対象権限セットを選択
  3. [アプリケーション]→[オブジェクト設定]を選択
  4. 対象オブジェクトを選択して(1)で作成した項目の編集アクセス権にチェックを入れて保存
  5. スクラッチ組織の変更をpullしておく sfdx force:source:pull -u <username/alias>

(3)サーバーサイド開発(classes)

カスタムコンポーネントの裏側を作っていきます。apexでクラスを作成します。
ディレクトリ構成は以下の通りです。

root
└── force-app
    └── main
        └── default
            └── classes
                ├── [クラス名].cls
		├── [クラス名].cls-meta.xml
                ├── [クラス名]_Test.cls
                └── [クラス名]_Test.cls-meta.xml

例として項目の上書きルールを動的に追加/削除するコンポーネントを作っていきます。
「リード」「商談」「取引先」「取引先責任者」の4つのオブジェクトの更新可能な項目を全取得してリスト化し、上書きルールを選択/作成できるようにします。

apex APIバージョン v51.0

[クラス名].cls

*** ... (1)で新規作成した項目を含むオブジェクトAPI参照名
$$$ ... (1)で新規作成した項目名
でそれぞれ置き換えてください。

[クラス名].cls
public with sharing class [クラス名] {
    // 「リード」「商談」「取引先」「取引先責任者」のオブジェクトリスト
    static final List<String> OBJECTS_LIST = new List<String>{
        'Lead', 'Opportunity', 'Account', 'Contact'
    };

    public static List<***> getRecord(id recId) {
        return [SELECT $$$ FROM *** WHERE id =: recId];
    }    

    @AuraEnabled(cacheable=true)
    public static String getRules(id recId) {
        *** record = getRecord(recId)[0];
        if (record.$$$ != null) { return record.$$$; }
        return '[]';
    }
    
    @AuraEnabled
    public static Integer updateRules(id recId, String newRules) {
        try {
	    if (newRules.length() > 13107) { return 400; }
            List<Object> overwriteInfo = (List<Object>)JSON.deserializeUntyped(newRules);
            for (Object info: overwriteInfo){
                Map<String, Object> infoMap = (Map<String, Object>)info;
                List<String> objValue1 = ((String)(infoMap.get('objValue1'))).split(':');
                List<String> objValue2 = ((String)(infoMap.get('objValue2'))).split(':');
                if (objValue1.size() != 2 || objValue2.size() != 2 || objValue1 == objValue2) { return 400; }
                Schema.DisplayType v1 = getTypeByObjField(objValue1[0], objValue1[1]);
                Schema.DisplayType v2 = getTypeByObjField(objValue2[0], objValue2[1]);
                if (v1 != v2) { return 400; } 
            }
            List<test_iijima__TEST__c> records = getRecord(recId);
            records[0].test_iijima__Rules__c = newRules;
            update records;
            return 200;
        } catch(DmlException e) {
            return 500;
        }
    }

    public static Schema.DisplayType getTypeByObjField(String objName, String fieldName) {
        return Schema.getGlobalDescribe().get(objName).getDescribe().fields.getMap().get(fieldName).getDescribe().getType();
    }

    @AuraEnabled(cacheable=true)
    public static Map<String, List<ApiLabelValue>> getObjFeildList(id recId) {
        Map<String, List<ApiLabelValue>> result = new Map<String, List<ApiLabelValue>>();
        for (String objName: OBJECTS_LIST){
            sObject sObj = getSObject(objName);
            result.put(objName, getApiLabelValue(sObj));
        }
        return result;
    }

    public static sObject getSObject(String objName) {
        return Schema.getGlobalDescribe().get(objName).newSObject();
    }

    public static DescribeSObjectResult getSObjectDescribe(sObject sObj) {
        return sObj.getSObjectType().getDescribe();
    }

    public static List<ApiLabelValue> getApiLabelValue(sObject sObj) {
        DescribeSObjectResult sObjectDescribe= getSObjectDescribe(sObj);
        Map<String, SObjectField> sObjectFields = sObjectDescribe.fields.getMap();
        String objName = sObjectDescribe.getName();
        String objLabel = sObjectDescribe.getLabel();
        List<ApiLabelValue> result = new List<ApiLabelValue>{};
        for(SObjectField f : sObjectFields.values()) {
            DescribeFieldResult field  = f.getDescribe();
            if (field.isUpdateable()) {
                String label = '【' + objLabel + '】' + field.getLabel();
                String value = objName + ':' + field.getName();
                ApiLabelValue fields = new ApiLabelValue(label, value);
                result.add(fields);
            }
        }
        return result;
    }

    public class ApiLabelValue{
        @AuraEnabled
        public String label; 
        @AuraEnabled
        public String value;
        public ApiLabelValue(String l, String v) {
            label = l; value = v;
        }
    }
}
[クラス名].cls-meta.xml
[クラス名].cls-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>51.0</apiVersion>
    <status>Active</status>
</ApexClass>

解説

@AuraEnabled

このアノテーションを指定することで、メソッドをlwc(フロント側)で使用できるようになります。

updateRules(id recId, String newRules)

フロント側からJSON文字列データ(newRules)が送られてくる想定で、バリデーションとアップデート処理を実装したメソッドです。
バリデーションは2つ実装しています。

  1. ロングテキストエリアの最大長チェック
    JSON文字列データはロングテキストエリア型((1)で作成済み)に保存するのでその最大長である13107より小さくなければいけません。newRules.length() > 13107
  2. 上書き項目の型が同じかチェック
    今回の例は項目同士の上書きルール追加/削除なので項目の型が同じでないと上書きできません。
    そこでgetTypeByObjField(String objName, String fieldName)で項目の型を取得して同じかどうかチェックするバリデーションをかけています。
getApiLabelValue(sObject sObj)

Salesforceが提供するオブジェクトクエリ言語「soql」ではSelect * From [オブジェクトAPI参照名]といった全データ取得が使えません。
そこで全データではなく全項目名を取得したいときに使えるメソッドを実装しました。
field.isUpdateable()によって更新可能な項目のみを取得しています。
返り値は(4)のlwcで使用する指定の型(ApiLabelValue)を勝手に命名して実装しました。

エラー返り値やバリデーションは必要最低限のことしか実装していないので必要であれば追加/修正してください。

忘れずに

スクラッチ組織に変更をpushしておく sfdx force:source:push -u <username/alias>

(4)フロント開発(lwc)

カスタムコンポーネントの見た目を作っていきます。
ディレクトリ構成は以下の通りです。

root
└── force-app
    └── main
        └── default
            └── lwc
	        └── [カスタムコンポーネント名]
                    ├── [カスタムコンポーネント名].css
		    ├── [カスタムコンポーネント名].html
		    ├── [カスタムコンポーネント名].js
                    └── [カスタムコンポーネント名].js-meta.xml

ボタンの数は1〜50までに制限してあります。(jsを参照してください)

lwc APIバージョン v51.0

[カスタムコンポーネント名].css
[カスタムコンポーネント名].css
.component {
    background-color:white;
    text-align: center;
    padding: 20px 0;
    border-radius: 5px;
}

.left {
    text-align: left;
    margin-left:10px;
}

.title {
    margin:10px 0;
    padding-left:10px;
    text-align: left;
    font-size: 15px;
    color: #222222;
}

.error {
    color: red;
}

.select {
    text-align: left;
    display: inline-block;
    width: 40%;
}

.arrow {
    display: inline-block;
    margin: 0 1%;
}

.button {
    margin-left: 1%;
}
[カスタムコンポーネント名].html
[カスタムコンポーネント名].html
<template>
    <div class="component">
        <p class="title">上書きルールの設定</p>
        <template if:true={hasError}>
            <p class="error">{errorMsg}</p>
        </template>
        <div class="left">
            <template iterator:it={rules}>
                <lightning-combobox
                    key={it.value.objValue1}
                    class="select"
                    label=""
                    value={it.value.objValue1}
                    placeholder="選択してください"
                    options={objFeildList}
                    onchange={changeObjFeildList1}
                    data-index={it.index} >
                </lightning-combobox>
                <lightning-icon
                    key={it.value.objValue1}
                    class="arrow"
                    icon-name="utility:forward" >
                </lightning-icon>
                <lightning-combobox
                    key={it.value.objValue1}
                    class="select"
                    label=""
                    value={it.value.objValue2}
                    placeholder="選択してください"
                    options={objFeildList}
                    onchange={changeObjFeildList2}
                    data-index={it.index} >
                </lightning-combobox>
                <template if:false={isDataSizeMin}>
                    <lightning-button-icon
                        key={it.value.objValue1}
                        variant="bare"
                        data-index={it.index}
                        icon-name="utility:ban"
                        onclick={clickDelete}
                        alternative-text="delete"
                        class="button" >
                    </lightning-button-icon>
                </template>
                <template if:false={isDataSizeMax}>
                    <template if:true={it.last}>
                        <lightning-button-icon
                            key={it.value.objValue1}
                            variant="bare"
                            data-index={it.index}
                            icon-name="utility:new"
                            onclick={clickAdd}
                            alternative-text="add"
                            class="button" >
                        </lightning-button-icon>
                    </template>
                </template>
                <br key={it.value.objValue1}>
            </template>
        </div>
        <br>
        <lightning-button
            variant="brand"
            label="保存"
            onclick={clickSave}
            disabled={noEdited} >
        </lightning-button>
    </div>
</template>
[カスタムコンポーネント名].js
[カスタムコンポーネント名].js
import {LightningElement, api, wire, track} from 'lwc';
//[クラス名]は(3)で作成したapexクラス名です
import getObjFeildList from '@salesforce/apex/[クラス名].getObjFeildList';
import getRules from '@salesforce/apex/[クラス名].getRules';
import updateRules from '@salesforce/apex/[クラス名].updateRules';

export default class CustomComponent extends LightningElement {
    @api recordId;
    @track rules;
    @track objFeildList;

    @wire(getObjFeildList)
    wiredObjFeildList({ error, data }) {
        if (data) {
            this.objFeildList = [
                {"objValue1":"", "objValue2":"","label":"選択してください"},
                ...data.Lead,
                ...data.Opportunity,
                ...data.Account,
                ...data.Contact,
            ];
        } else if (error) {
            console.log(JSON.stringify(error, null, '\t'));
        }
    }

    @wire(getRules, {recId: '$recordId'})
    wiredRules({ error, data }) {
        if (data) {
            var savedRules = JSON.parse(data);
            if (savedRules.length != 0) {
                this.rules = savedRules;
            } else {
                this.rules = [{"objValue1":"", "objValue2":""}];
            }
        } else if (error) {
            console.log(JSON.stringify(error, null, '\t'));
        }
    }

    @track noEdited = true;
    @track hasError = false;
    @track errorMsg = "";

    get isDataSizeMin() {
        return this.rules.length <= 1 ? true : false;
    }

    get isDataSizeMax() {
        return this.rules.length > 50 ? true : false;
    }

    changeObjFeildList1(e) {
        this.rules[+e.target.dataset.index].objValue1 = e.detail.value;
        this.noEdited = false;
    }

    changeObjFeildList2(e) {
        this.rules[+e.target.dataset.index].objValue2 = e.detail.value;
        this.noEdited = false;
    }

    clickAdd() {
        this.rules.push({"objValue1":"", "objValue2":""});
        this.noEdited = false;
    }

    clickDelete(e) {
        if (this.rules.length == 1) {
            this.clickAdd();
        } else {
            this.rules.splice(+e.target.dataset.index, 1);
            this.noEdited = false;
        }
    }

    clickSave() {
        this.hasError = false;
        let keys = [];
        let savedRules = JSON.parse(JSON.stringify(this.rules));
        if (!savedRules || savedRules.length == 1 && !savedRules[0].objValue1 && !savedRules[0].objValue2) {
            savedRules = [];
        }
        savedRules.forEach( v => {
            if (!v.objValue1 || !v.objValue2) {
                this.hasError = true;
                this.errorMsg = "空欄があります。";
            } else if (v.objValue1 == v.objValue2) {
                this.hasError = true;
                this.errorMsg = "同じ項目を選択しています。";
            } else {
                keys.push(v.objValue1 + v.objValue2);
            }
        });
        if (this.hasError) { return; }
        if ((new Set(keys)).size != savedRules.length) {
            this.hasError = true;
            this.errorMsg = "ルールが重複しています。";
            return;
        }
        savedRules = JSON.stringify(savedRules);
        if (savedRules.length > 131072) {
            this.hasError = true;
            this.errorMsg = "ルールをこれ以上追加できません。";
            return; 
        }
        updateRules({ recId: this.recordId, newRules: savedRules })
        .then(result => {
            if (result == 200) {
                location.reload();
            } else if (result == 400) {
                this.hasError = true;
                this.errorMsg = "ルールのデータ型が異なります。";
            } else {
                this.hasError = true;
                this.errorMsg = "ルールを追加できませんでした。";
            }
        });
    }
}
[カスタムコンポーネント名].js-meta.xml
[カスタムコンポーネント名].js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>51.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
    	<target>lightning__RecordPage</target>
    </targets>
</LightningComponentBundle>

解説

HTMLテンプレート(if)

lwcのHTMLテンプレートではif文を扱えます。
ですが演算子(==, !=, <, >, etc...)を使うことができません。
そのためjs側で真偽値を返すプロパティ値を準備してあげます。

<!-- if true -->
<template if:true={hasError}>
...
</template>
<!-- if false -->
<template if:false={isDataSizeMin}>
...
</template>
HTMLテンプレート(iterator)

lwcのHTMLテンプレートでは2種類の繰り返し処理を扱えます。

データ例
values = [
    { Id: 1, Name: 'a', Title: 'A' },
    { Id: 2, Name: 'b', Title: 'B' },
    { Id: 3, Name: 'c', Title: 'C' },
];
  1. for:each
    現在の項目のインデックスにアクセスするにはfor:index="index"を使用します。
例1
<template for:each={values} for:item="value">
    <li key={value.Id}>
        {value.Name}, {value.Title}
    </li>
</template>
  1. iterator
    forEachよりも使い勝手が良いのがiteratorです。valueindexの他に
  • first ... この項目がリスト内の最初の項目であるかどうかを示すBoolean値
  • last ... この項目がリスト内の最後の項目であるかどうかを示すBoolean値
    があります。
    処理の最初と最後で何か表示を変えたい場合など、演算子の使えないif文との併用ができます。
    今回の実装は、要素の追加/削除ボタンで最後の要素のみ「+」ボタンを設置したり、要素が1つだけの時に「-」ボタンの表示を消したりするのでfirstlastは重要な働きをします。
例2
<template iterator:it={values}>
    <li key={it.value.Id}>
        <div if:true={it.first} class="list-first"></div>
        {it.value.Name}, {it.value.Title}
        <div if:true={it.last} class="list-last"></div>
    </li>
</template>

リスト内のすべての項目にはkeyが必須になります。
keyは文字列または数字にする必要がありますが、indexをkeyの値として使用することはできません。

lwcライブラリ(@api、 @track、 @wire)

@api
パブリックプロパティです。画面の変更を検知して処理します。
クラス内に@api recordIdを宣言することで、自動的に現在のレコードIDを取得できます。

@track
プライベートプロパティです。画面の変更を検知して処理します。

@wire
javascriptの変数とApexのメソッドを紐づけます。引数はオブジェクトで渡します。

import {LightningElement, api, wire, track} from 'lwc';
import [メソッド名] from '@salesforce/apex/[クラス名].[メソッド名]';
export default class [クラス名] extends LightningElement {
    @api recordId;
    @track data;
    @wire([メソッド名], {recId: '$recordId'})
    wiredRules({ error, data }) {
        if (data) {
	    this.data = data;
        } else if (error) {
            console.log(JSON.stringify(error, null, '\t'));
        }
    }
    ...
}

バリデーションは必要最低限のことしか実装していないので必要であれば追加/修正してください。

忘れずに

スクラッチ組織に変更をpushしておく sfdx force:source:push -u <username/alias>

(5)カスタムコンポーネントをレイアウトに追加

(3)(4)をスクラッチ組織にpushするとページ編集からカスタムコンポーネントを追加できるようになります。

  1. スクラッチ組織を開く sfdx force:org:open -u <username/alias>
  2. 対象オブジェクトの詳細ページで設定歯車マークの[編集ページ]を開く
  3. コンポーネント一覧の[カスタム]に作成したコンポーネントがあるのでお好みの場所にドラッグ&ドロップ
  4. [保存]して[有効化]する
  5. スクラッチ組織の変更をpullしておく sfdx force:source:pull -u <username/alias>

完成!

デフォルトで用意されているコンポーネントは種類が豊富ですが無いものは無いので自分で作っちゃいましょう。
salesforce独自に提供されている特殊な書き方をマスターすれば作れないものはありません。
さらにAppExchange開発でできることの幅が増えますね!

Discussion

ログインするとコメントできます