背景
x.comで「商談商品」の追加画面で個数などが選べない、商談Familyでtree-gridのように選択できたらというお悩みがあったので作成してみました。実際のご利用環境では、商品数だったりFamilyごとのくくりや数が違うので、諸々の調整は必要かと思います。
デモ
構成
- Method * 2 (getPricebookEntry, createLineItems)
- lwc * 2 (productSearch, productItems)
- Action Button * 1 (Add Product)
以上です。
要点
Class, method
とりあえずProductSearchControllerと名付けて以下の2つのMethodを作成します。
getPricebookEntries
getPricebookEntries
は現在ある有効な商品を取得して、現在の商談のPricebook2Idと一致するPricebook内にある商品群を取得してきます。
getPricebookEntries
@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
];
}
WHERE句やORDER句は適宜調整していただければ組織にあったPricebookEntryが取得できるかと思います。
createLineItems
createLineItems
は取得してきた商品を商談商品として、個数や単価などを変更したのちのデータをデータベースに挿入します。
createLineItems
@AuraEnabled
public static List<OpportunityLineItem> createLineItems(List<OpportunityLineItem> lineItems) {
insert lineItems;
return lineItems;
}
SortOrderは自動なのでそれ以外の必須項目、またPricebook2Idが一致していないとエラーになるので、違うPricebook2Idが絡まないようにgetPricebookEntriesの方ですでに絞っています。
lwc
productSearch
productSeachは、取得してきた商品をtree-grid形式でFamilyをもとに羅列します。商品が多い場合検索項目を追加したりして対応できるかと思います。
productSearch (html)
<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>
productSearch (js)
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;
}
}
productSearch (xml)
<?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>
- QuickActionde利用するためにxmlが重要
- Pricebook2.Idを現在の商談から取得してくる必要
- loadPricebookEntriesはwireでデータを取得してから起動
productItems
productItemsはtree-grid選んだ商品を、PricebookEntryにある単価*1としてテーブル表示し、さらにインラインでその単価や個数を編集(任意)して、その後作成アクションを行う、というものです。
productItems (html)
<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>
productItems (js)
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);
}
}
- dispatchEventで、Componentの表示を制御
- handleSaveとhandleCellChangeの挙動はほぼ同じだが、入力しているまま数値の変化を追えるようにしている。最終的なデータの確定はhandleSaveで行う。ここらへんはより抽象化できそう。
Action Button
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 でクイックアクションを作成する
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 さんの記事や公式にも記載がありました。githubのコードにも変更を反映したので、ご指摘いただいた@skussun1に感謝です!