商品の値段や個数を自由に変更できる「商品追加ボタン」を作ってみた (lwc)

# 背景
x.comで「商談商品」の追加画面で個数などが選べない、商談Familyでtree-gridのように選択できたらというお悩みがあったので作成してみました。実際のご利用環境では、商品数だったりFamilyごとのくくりや数が違うので、諸々の調整は必要かと思います。

# デモ
![custom-add-products.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/61fc5f1c-b669-0370-7b91-1f2973312463.gif)

# 構成
- Method * 2 (getPricebookEntry, createLineItems)
- lwc * 2 (productSearch, productItems)
- Action Button * 1 (Add Product)
以上です。

## 要点
### Class, method
とりあえずProductSearchControllerと名付けて以下の２つのMethodを作成します。

#### getPricebookEntries
`getPricebookEntries` は現在ある有効な商品を取得して、現在の商談のPricebook2Idと一致するPricebook内にある商品群を取得してきます。
<details><summary>getPricebookEntries</summary>

```
@AuraEnabled(cacheable=true)
public static List<PricebookEntry> getPricebookEntries(Id pricebook2Id) {
        return [
            SELECT Id, Product2Id, Product2.Name, Product2.Family, UnitPrice 
            FROM PricebookEntry 
            WHERE Pricebook2.IsStandard = false
            AND Pricebook2Id = :pricebook2Id
            AND Product2.IsActive = true
            ORDER BY UnitPrice DESC
        ];
    }
```
</summary>
</details>

:::note warn
WHERE句やORDER句は適宜調整していただければ組織にあったPricebookEntryが取得できるかと思います。
:::

#### createLineItems
`createLineItems` は取得してきた商品を商談商品として、個数や単価などを変更したのちのデータをデータベースに挿入します。

<details><summary>createLineItems</summary>

```
@AuraEnabled
public static List<OpportunityLineItem> createLineItems(List<OpportunityLineItem> lineItems) {
        insert lineItems;
        return lineItems;
    }
```
</summary>

</details>

:::note warn
SortOrderは自動なのでそれ以外の必須項目、またPricebook2Idが一致していないとエラーになるので、違うPricebook2Idが絡まないようにgetPricebookEntriesの方ですでに絞っています。
:::

### lwc
`productSearch`
productSeachは、取得してきた商品をtree-grid形式でFamilyをもとに羅列します。商品が多い場合検索項目を追加したりして対応できるかと思います。

<details><summary>productSearch (html)</summary>

```
<template>
    <template lwc:if={showTreeGrid}>
        <div class="tree-grid-container">
            <div class="myTreeGrid">
                <lightning-tree-grid columns={columns} data={gridData} key-field="Id"
                    onrowselection={handleRowSelection}>
                </lightning-tree-grid>
            </div>
            <div class="fixed-bottom-button slds-m-around_small">
                <button class="slds-button slds-button_brand slds-button_stretch" onclick={handleSelectAction}>Select
                </button>
            </div>
        </div>
    </template>
    <template lwc:if={showDatatable}>
        <c-product-items record-id={recordId} records={records} onclosedatatable={handleFinish}></c-product-items>
    </template>
    <template lwc:if={showSuccess}>
        <lightning-card class="custom-success-container">
            <div class="custom-success-container">
                <lightning-icon icon-name="standard:opportunity" size="large"></lightning-icon>
            </div>
            <div class="slds-m-around_small">
                <lightning-formatted-text value=" The lineItems are successfully created."
                    class="slds-text-heading_small"></lightning-formatted-text>
            </div>
        </lightning-card>
    </template>
</template>
```
</details>

<details><summary>productSearch (js)</summary>

```
import { LightningElement, wire, track, api } from 'lwc';
import { getRecord } from 'lightning/uiRecordApi';

import getLineItems from '@salesforce/apex/ProductSearchController.getLineItems';
import getPricebookEntries from '@salesforce/apex/ProductSearchController.getPricebookEntries';
import OPPORTUNITY_PRICEBOOK2ID_FIELD from '@salesforce/schema/Opportunity.Pricebook2Id';


export default class ProductTreeGrid extends LightningElement {
    @api recordId;

    @track columns = [
        { label: 'Name', fieldName: 'Name' },
        { label: 'Family', fieldName: 'Family' },
        { label: 'List Price', fieldName: 'ListPrice', type: 'currency' }
    ];

    @track gridData;
    @track showTreeGrid = true;
    @track showDatatable = false;
    @track records;

    opportunityPricebook2Id;
    wiredOpportunityResult;

    @wire(getRecord, { recordId: '$recordId', fields: [OPPORTUNITY_PRICEBOOK2ID_FIELD] })
    wiredOpportunity({ data, error }) {
        if (data) {
            this.wiredOpportunityResult = data;
            this.opportunityPricebook2Id = data.fields.Pricebook2Id.value;
            this.loadPricebookEntries();
        } else if (error) {
            console.log('wiredOpportunity error:', error);
        }
    }

    loadPricebookEntries() {
        getPricebookEntries({ pricebook2Id: this.opportunityPricebook2Id })
            .then(result => {
                this.gridData = this.transformData(result);
            })
            .catch(error => {
                console.log('getPricebookEntries error:', error);
            });
    }

    transformData(data) {
        let treeData = [];
        let families = new Map();

        data.forEach(entry => {
            let family = entry.Product2.Family;
            if (!families.has(family)) {
                families.set(family, {
                    _children: [],
                    Id          : family, // Use Family as a unique key
                    Name        : family, // Group by Family
                    Family      : '',
                    ListPrice   : null,
                    Quantity    : null,
                    Total       : null
                });
            }
            families.get(family)._children.push({
                Id                  : entry.Id,
                Name                : entry.Product2.Name,
                Family              : family,
                ListPrice           : entry.UnitPrice,
                Quantity            : 1,
                Total               : entry.UnitPrice * 1,
            });
        });

        families.forEach((value, key) => {
            treeData.push(value);
        });

        return treeData;
    }

    handleSelectAction() {
        const grid = this.template.querySelector('lightning-tree-grid');
        const selectedRows = grid.getSelectedRows();
        this.records = selectedRows; 
        this.showTreeGrid = false; 
        this.showDatatable = true;
    }

    handleFinish(event) {
        this.showDatatable = event.detail.showDatatable;
        this.showSuccess = event.detail.showSuccess;
    }
}
```
</details>

<details><summary>productSearch (xml)</summary>

```
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>58.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordAction</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordAction">
            <actionType>ScreenAction</actionType>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>
```

</details>

:::note warn
- QuickActionde利用するためにxmlが重要
- Pricebook2.Idを現在の商談から取得してくる必要
- loadPricebookEntriesはwireでデータを取得してから起動
:::

`productItems`
productItemsはtree-grid選んだ商品を、PricebookEntryにある単価*1としてテーブル表示し、さらにインラインでその単価や個数を編集（任意）して、その後作成アクションを行う、というものです。

<details><summary>productItems (html)</summary>

```
<template>
    <div class="tree-grid-container">
        <div class="myTreeGrid">
            <lightning-spinner if:true={isLoading} alternative-text="Loading" size="medium"></lightning-spinner>
            <lightning-datatable data={records} columns={columns} key-field="Id" hide-checkbox-column="false"
                oncellchange={handleCellChange} onsave={handleSave} draft-values={draftValues}>
            </lightning-datatable>
        </div>
        <div class="fixed-bottom-button slds-m-around_small">
            <button class="slds-button slds-button_brand slds-button_stretch" disabled={createButtonDisabled}
                onclick={handleCreate}>Create
            </button>
        </div>
    </div>
</template>
```
</details>

<details><summary>productItems (js)</summary>

```
import { LightningElement, api, track } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

import createLineItems from '@salesforce/apex/ProductSearchController.createLineItems';

export default class ProductItems extends LightningElement {
    @api recordId;
    @api records;
    @track draftValues = [];
    @track createButtonDisabled = false;
    @track isLoading = false;

    @track columns = [
        { label: 'Product Name', fieldName: 'Name', editable: false },
        { label: 'Sales Price', fieldName: 'ListPrice', type: 'currency', editable: true },
        { label: 'Quantity', fieldName: 'Quantity', type: 'number', editable: true },
        { label: 'Total', fieldName: 'Total', type: 'currency' }
    ];

    handleSave(event) {
        this.draftValues = event.detail.draftValues;
        const updatedRecords = this.records.map(record => {
        const draftUpdate = this.draftValues.find(draft => draft.Id === record.Id);
            if (draftUpdate) {
                let newTotal = record.Total;
                if (draftUpdate.Quantity !== undefined || draftUpdate.ListPrice !== undefined) {
                    const newQuantity = draftUpdate.Quantity !== undefined ? draftUpdate.Quantity : record.Quantity;
                    const newListPrice = draftUpdate.ListPrice !== undefined ? draftUpdate.ListPrice : record.ListPrice;
                    newTotal = newQuantity * newListPrice;
                }
                return {...record, ...draftUpdate, Total: newTotal};
            }
            return record;
        });
        this.records = updatedRecords;
        this.createButtonDisabled = false;
        this.draftValues = [];
    }

    handleCellChange(event) {
        this.createButtonDisabled = true;
        this.draftValues = event.detail.draftValues;
        const updatedRecords = this.records.map(record => {
        const draftUpdate = this.draftValues.find(draft => draft.Id === record.Id);
            if (draftUpdate) {
                let newTotal = record.Total;
                if (draftUpdate.Quantity !== undefined || draftUpdate.ListPrice !== undefined) {
                    const newQuantity = draftUpdate.Quantity !== undefined ? draftUpdate.Quantity : record.Quantity;
                    const newListPrice = draftUpdate.ListPrice !== undefined ? draftUpdate.ListPrice : record.ListPrice;
                    newTotal = newQuantity * newListPrice;
                }
                return {...record, ...draftUpdate, Total: newTotal};
            }
            return record;
        });
        this.records = updatedRecords;
    }

    handleCreate(){
        this.isLoading = true;
        const remappedRecords = this.transformDataForOpportunityLineItems();
        
        createLineItems({ lineItems: remappedRecords })
            .then(result => {
                this.showToast('Success', 'Pricebook Entries Created Successfully', 'success', 'pester');
                this.handleCloseDatatable();
                this.isLoading = false;
            })
            .catch(error => {
                console.error('Error in creating records:', error);
            });
    }

    transformDataForOpportunityLineItems() {
        return this.records.map(record => {
            const item = {
                OpportunityId       : this.recordId,
                PricebookEntryId    : record.Id,
                Name                : record.Name,
                Quantity            : record.Quantity,
                UnitPrice           : record.ListPrice,
            };
            return item;
        });
    }

    showToast(title, message, variant, mode) {
        const toastEvent = new ShowToastEvent({
            title: title,
            message: message,
            variant: variant,
            mode: mode,
        });
        this.dispatchEvent(toastEvent);
    }

    handleCloseDatatable(){
        const event = new CustomEvent('closedatatable', {
            detail: { showDatatable: false, showSuccess:true }
        });
        this.dispatchEvent(event);
    }
}
```

</details>


:::note warn
- dispatchEventで、Componentの表示を制御
- handleSaveとhandleCellChangeの挙動はほぼ同じだが、入力しているまま数値の変化を追えるようにしている。最終的なデータの確定はhandleSaveで行う。ここらへんはより抽象化できそう。 
:::


### Action Button
![add-button.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/f26c9009-65fd-3071-6cb8-87d57f65a4c5.png)

Add Productと名付けてはlwcを呼び出すことのできるActionButtonを作成してRecord Pageに配置します。lwcの`productSearch`のxmlファイルにて、targetConfigsで、lightning__RecordActionを追加していることがQuickActionで使用する場合必要になります。
```
    <targets>
        <target>lightning__RecordAction</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordAction">
            <actionType>ScreenAction</actionType>
        </targetConfig>
    </targetConfigs>
```

[参照: Lightning Web Components でクイックアクションを作成する](https://qiita.com/shunkosa/items/26293c899ccf7ef48218) 
by @shunkosa

# まとめ
- 唯一の問題はAuraでいうところのrefreshView()と同等のことがlwcではできなさそうので、Auraでwrapする必要がありそうなところです。CustomDispatchEvent等で商談自体はできるのですが、関連リストのRefreshがなかなかできません... 
- また、QuickActionのModal Containerも消せないようです。Auraでwrapしてclose_QuickActionをするのも悔しいので、「できました！」という画面で「x」を押してModalを消してもらう方式にしましたが、それほど違和感はなさそうです。

githubにコードを公開しておきますので、もしアイディアのある方がいたらぜひ。
https://github.com/liuxiachanghong/add-products

# 更新

```
import { CloseActionScreenEvent } from "lightning/actions";
...
this.dispatchEvent(new CloseActionScreenEvent());
```

にてproductSearchの加えることでmodalを意図どうり閉じることができるました。@shunkosa さんの[記事](https://qiita.com/shunkosa/items/26293c899ccf7ef48218)や[公式](https://developer.salesforce.com/docs/platform/lwc/guide/use-quick-actions-screen.html#open-and-close-the-modal-window)にも記載がありました。[github](https://github.com/liuxiachanghong/addProducts
)のコードにも変更を反映したので、ご指摘いただいた@skussun1に感謝です!