1. 程式人生 > 實用技巧 >Salesforce LWC學習(三十) lwc superbadge專案實現

Salesforce LWC學習(三十) lwc superbadge專案實現

本篇參考:https://trailhead.salesforce.com/content/learn/superbadges/superbadge_lwc_specialist

我們做lwc的學習時,因為很多人可能還沒接觸過lwc的專案,所以通過學習知道很多的知識點,但是可能沒有機會做到一個小專案,salesforce lwc superbadge正好可以在將知識點串起來基礎上,深化學習,當一個小專案練手。首先先按照上方的superbadge要求,安裝一個unlocked package,然後匯入到基礎資料。匯入以後資料以及表和基本的 component的殼子就都有了。一步一步進行分析。觀看以下視訊檢視效果:

https://www.iqiyi.com/v_1rz2ad7afb8.html

(注: 如果沒有lwc的基礎,請務必先了解基礎以後檢視,否則看此篇純屬浪費時間)

一. 表結構

demo中主要用到了三個表。

  • Boat:儲存了船的一些基礎資訊;
  • BoatReview:船的評價資訊;
  • Boat Type:儲存船的型別。

二. component層級結構

demo中包含了以下的 component資訊,其中層級結構如下所示。我們可以看到 boatSearch / boatDetailTabs以及boatMap是同層,他們傳遞 recordId的方式便需要通過 Lightning Message Service方式。

三. 涉及到技術以及程式碼詳情

涉及到的主要技術

1. lightning message service:用於沒有關聯的元件間的資訊傳播,類似於aura中的 application event,實現跨元件傳遞 引數,可以參考此篇文章:

Salesforce LWC學習(二十三) Lightning Message Service 淺談

2. 父子component傳值,子如何建立事件,父如何去排程事件,可以參考此篇文章:

Salesforce LWC學習(四) 父子component互動 / component宣告週期管理 / 事件處理

3. 通過 wire service或者Lightning Data Service實現和資料的互動,可以參考此篇文章:

Salesforce LWC學習(五) LDS & Wire Service 實現和後臺資料互動 & meta xml配置

4. Navigation 以及Toast實現展示Toast資訊以及頁面跳轉功能,可以參考此篇文章:

Salesforce LWC學習(七) Navigation & Toast

5. lwc提供的各種預置的元件,可以參考官方連結:https://developer.salesforce.com/docs/component-library/overview/components

6. wire adapter等知識點使用。可以參考此篇文章:

Salesforce LWC學習(六) @salesforce & lightning/ui*Api Reference

預備工作,按照1操作中的步驟建立Message Channel:在messageChannels目錄下建立一個 BoatMessageChannel.messageChannel-meta.xml的檔案,裡面包含主要欄位為 fieldName,可以在上方 superbadge去進行適當的copy。

程式碼如下:BoatDataService.cls:包含了專案中用到的後臺需要使用的所有的方法

  1 public with sharing class BoatDataService {
  2 
  3     public static final String LENGTH_TYPE = 'Length';
  4     public static final String PRICE_TYPE = 'Price';
  5     public static final String TYPE_TYPE = 'Type';
  6     @AuraEnabled(cacheable=true)
  7     public static List<Boat__c> getBoats(String boatTypeId) {
  8         // Without an explicit boatTypeId, the full list is desired
  9         String query = 'SELECT '
 10                      + 'Name, Description__c, Geolocation__Latitude__s, '
 11                      + 'Geolocation__Longitude__s, Picture__c, Contact__r.Name, '
 12                      + 'BoatType__c, BoatType__r.Name, Length__c, Price__c '
 13                      + 'FROM Boat__c';
 14         if (String.isNotBlank(boatTypeId)) {
 15             query += ' WHERE BoatType__c = :boatTypeId';
 16         }
 17         query += ' WITH SECURITY_ENFORCED ';
 18         return Database.query(query);
 19     }
 20 
 21     @AuraEnabled(cacheable=true)
 22     public static List<Boat__c> getSimilarBoats(Id boatId, String similarBy) {
 23         List<Boat__c> similarBoats = new List<Boat__c>();
 24         List<Boat__c> parentBoat = [SELECT Id, Length__c, Price__c, BoatType__c, BoatType__r.Name
 25                                     FROM Boat__c
 26                                     WHERE Id = :boatId
 27                                     WITH SECURITY_ENFORCED];
 28         if (parentBoat.isEmpty()) {
 29             return similarBoats;
 30         }
 31         if (similarBy == LENGTH_TYPE) {
 32             similarBoats = [
 33                 SELECT Id, Contact__r.Name, Name, BoatType__c, BoatType__r.Name, Length__c, Picture__c, Price__c, Year_Built__c
 34                 FROM Boat__c
 35                 WHERE Id != :parentBoat.get(0).Id
 36                 AND (Length__c >= :parentBoat.get(0).Length__c / 1.2)
 37                 AND (Length__c <= :parentBoat.get(0).Length__c * 1.2)
 38                 WITH SECURITY_ENFORCED
 39                 ORDER BY Length__c, Price__c, Year_Built__c
 40             ];
 41         } else if (similarBy == PRICE_TYPE) {
 42             similarBoats = [
 43                 SELECT Id, Contact__r.Name, Name, BoatType__c, BoatType__r.Name, Length__c, Picture__c, Price__c, Year_Built__c
 44                 FROM Boat__c
 45                 WHERE Id != :parentBoat.get(0).Id
 46                 AND (Price__c >= :parentBoat.get(0).Price__c / 1.2)
 47                 AND (Price__c <= :parentBoat.get(0).Price__c * 1.2)
 48                 WITH SECURITY_ENFORCED
 49                 ORDER BY Price__c, Length__c, Year_Built__c
 50             ];
 51         } else if (similarBy == TYPE_TYPE) {
 52             similarBoats = [
 53                 SELECT Id, Contact__r.Name, Name, BoatType__c, BoatType__r.Name, Length__c, Picture__c, Price__c, Year_Built__c
 54                 FROM Boat__c
 55                 WHERE Id != :parentBoat.get(0).Id
 56                 AND (BoatType__c = :parentBoat.get(0).BoatType__c)
 57                 WITH SECURITY_ENFORCED
 58                 ORDER BY Price__c, Length__c, Year_Built__c
 59             ];
 60         }
 61         return similarBoats;
 62     }
 63 
 64     @AuraEnabled(cacheable=true)
 65     public static List<BoatType__c> getBoatTypes() {
 66         return [SELECT Name, Id FROM BoatType__c WITH SECURITY_ENFORCED ORDER BY Name];
 67     }
 68 
 69     @AuraEnabled(cacheable=false)
 70     public static List<BoatReview__c> getAllReviews(Id boatId) {
 71         return [
 72             SELECT
 73                 Id,
 74                 Name,
 75                 Comment__c,
 76                 Rating__c,
 77                 LastModifiedDate,
 78                 CreatedDate,
 79                 CreatedBy.Name,
 80                 CreatedBy.SmallPhotoUrl,
 81                 CreatedBy.CompanyName
 82             FROM
 83                 BoatReview__c
 84             WHERE
 85                 Boat__c =:boatId
 86             WITH SECURITY_ENFORCED
 87             ORDER BY
 88                 CreatedDate DESC
 89         ];
 90     }
 91 
 92     @AuraEnabled(cacheable=true)
 93     public static String getBoatsByLocation(Decimal latitude, Decimal longitude, String boatTypeId) {
 94         // Without an explicit boatTypeId, the full list is desired
 95         String query = 'SELECT Name, Geolocation__Latitude__s, Geolocation__Longitude__s FROM Boat__c ';
 96         if (String.isNotBlank(boatTypeId)) {
 97             query += 'WHERE BoatType__c = :boatTypeId ';
 98         }
 99         query += ' WITH SECURITY_ENFORCED ORDER BY DISTANCE(Geolocation__c, GEOLOCATION(:latitude, :longitude), \'mi\') LIMIT 10';
100         return JSON.serialize(Database.query(query));
101     }
102 }

fiveStartRating.js:使用了第三方的js以及css;

 1 //import fivestar static resource, call it fivestar
 2 import { LightningElement, api } from 'lwc';
 3 import fivestar from '@salesforce/resourceUrl/fivestar';
 4 import { ShowToastEvent } from 'lightning/platformShowToastEvent';
 5 import { loadStyle, loadScript } from 'lightning/platformResourceLoader';
 6 
 7 const ERROR_TITLE = 'Error loading five-star';
 8 const ERROR_VARIANT = 'error';
 9 const EDITABLE_CLASS = 'c-rating';
10 const READ_ONLY_CLASS = 'readonly c-rating';
11 
12 export default class FiveStarRating extends LightningElement {
13   //initialize public readOnly and value properties
14   @api readOnly;
15   @api value;
16 
17   editedValue;
18   isRendered;
19 
20   //getter function that returns the correct class depending on if it is readonly
21   get starClass() {
22     return this.readOnly ? READ_ONLY_CLASS : EDITABLE_CLASS;
23   }
24 
25   // Render callback to load the script once the component renders.
26   renderedCallback() {
27     if (this.isRendered) {
28       return;
29     }
30     this.loadScript();
31     this.isRendered = true;
32   }
33 
34   //Method to load the 3rd party script and initialize the rating.
35   //call the initializeRating function after scripts are loaded
36   //display a toast with error message if there is an error loading script
37   loadScript() {
38       Promise.all([
39         loadStyle(this, fivestar + '/rating.css'),
40         loadScript(this, fivestar + '/rating.js')
41       ]).then(() => {
42         this.initializeRating();
43       }).catch(()=>{
44         const event = new ShowToastEvent({title:ERROR_TITLE, variant:ERROR_VARIANT});
45         this.dispatchEvent(event);
46       });
47   }
48 
49   initializeRating() {
50     let domEl = this.template.querySelector('ul');
51     let maxRating = 5;
52     let self = this;
53     let callback = function (rating) {
54       self.editedValue = rating;
55       self.ratingChanged(rating);
56     };
57     this.ratingObj = window.rating(
58       domEl,
59       this.value,
60       maxRating,
61       callback,
62       this.readOnly
63     );
64   }
65 
66   // Method to fire event called ratingchange with the following parameter:
67   // {detail: { rating: CURRENT_RATING }}); when the user selects a rating
68   ratingChanged(rating) {
69     this.dispatchEvent(new CustomEvent('ratingchange', {detail: { rating: rating }}));
70   }
71 }

fiveStartRating.html: 展示UI

<template>
    <ul class={starClass}></ul>
</template>

boatSearchForm.js: wire adapter和後臺互動以及註冊自定義事件

 1 import { LightningElement, wire, track } from 'lwc';
 2 import getBoatTypes from '@salesforce/apex/BoatDataService.getBoatTypes';
 3 export default class BoatSearchForm extends LightningElement {
 4     selectedBoatTypeId = '';
 5     
 6     // Private
 7     error = undefined;
 8     
 9     // Needs explicit track due to nested data
10     @track searchOptions = [];
11     
12     // Wire a custom Apex method
13     @wire(getBoatTypes)
14       boatTypes({ error, data }) {
15       if (data) {
16         this.searchOptions = data.map(type => ({
17             // TODO: complete the logic
18             label: type.Name,value: type.Id
19         }));
20         this.searchOptions.unshift({ label: 'All Types', value: '' });
21       } else if (error) {
22         this.searchOptions = undefined;
23         this.error = error;
24       }
25     }
26     
27     // Fires event that the search option has changed.
28     // passes boatTypeId (value of this.selectedBoatTypeId) in the detail
29     handleSearchOptionChange(event) {
30         this.selectedBoatTypeId = event.detail.value.trim();
31         const searchEvent = new CustomEvent('search',  {detail:{boatTypeId: this.selectedBoatTypeId} });
32         this.dispatchEvent(searchEvent);
33     }
34 }

boatSearchForm.html:搜尋UI展示

<template>
    <lightning-layout>
      <lightning-layout-item class="slds-align-middle">
        <lightning-combobox class="slds-align-middle" options={searchOptions} onchange={handleSearchOptionChange} value={selectedBoatTypeId}></lightning-combobox>
      </lightning-layout-item>
    </lightning-layout>
</template>

boatTile.js: 獲取父元件傳遞過來的內容,註冊自定義事件

 1 import { LightningElement, api} from "lwc";
 2 const TILE_WRAPPER_SELECTED_CLASS = "tile-wrapper selected";
 3 const TILE_WRAPPER_UNSELECTED_CLASS = "tile-wrapper";
 4 export default class BoatTile extends LightningElement {
 5     @api boat;
 6     @api selectedBoatId;
 7     get backgroundStyle() {
 8         return `background-image:url(${this.boat.Picture__c})`;
 9     }
10     get tileClass() {
11         return this.selectedBoatId == this.boat.Id ? TILE_WRAPPER_SELECTED_CLASS : TILE_WRAPPER_UNSELECTED_CLASS;
12     }
13     selectBoat() {
14         this.selectedBoatId = !this.selectedBoatId;
15         const boatselect = new CustomEvent("boatselect", {
16             detail: {
17                 boatId: this.boat.Id
18             }
19         });
20         this.dispatchEvent(boatselect);
21     }
22 }

boatTile.html:詳情頁UI展示

<template>
    <div onclick={selectBoat} class={tileClass}>
        <div style={backgroundStyle} class="tile"></div>
        <div class="lower-third">
            <h1 class="slds-truncate slds-text-heading_medium">{boat.Name}</h1>
            <h2 class="slds-truncate slds-text-heading_small">{boat.Contact__r.Name}</h2>
            <div class="slds-text-body_small">
                Price: <lightning-formatted-number maximum-fraction-digits="2" format-style="currency" currency-code="USD" value={boat.Price__c}> </lightning-formatted-number>
            </div>
            <div class="slds-text-body_small"> Length: {boat.Length__c} </div>
            <div class="slds-text-body_small"> Type: {boat.BoatType__r.Name} </div>
        </div>
    </div>
</template>

boatTile.css

.tile {
    width: 100%;
    height: 220px;
    padding: 1px !important;
    background-position: center;
    background-size: cover;
    border-radius: 5px;
  }
  .selected {
    border: 3px solid rgb(0, 95, 178);
    border-radius: 5px;
  }
  .tile-wrapper {
    cursor: pointer;
    padding: 5px;
  }

boatsNearMe.js: wire adapter / toast / 生命週期函式/ lightning-map 預置函式

 1 import { LightningElement, track, wire, api } from 'lwc';
 2 import getBoatsByLocation from '@salesforce/apex/BoatDataService.getBoatsByLocation';
 3 import { ShowToastEvent } from 'lightning/platformShowToastEvent';
 4 
 5 const LABEL_YOU_ARE_HERE = 'You are here!';
 6 const ICON_STANDARD_USER = 'standard:user';
 7 const ERROR_TITLE = 'Error loading Boats Near Me';
 8 const ERROR_VARIANT = 'error';
 9 export default class BoatsNearMe extends LightningElement {
10     @api boatTypeId;
11     @track mapMarkers = [];
12     @track isLoading = true;
13     @track isRendered = false;
14     latitude;
15     longitude;
16     @wire(getBoatsByLocation, { latitude: '$latitude', longitude: '$longitude', boatTypeId: '$boatTypeId' })
17     wiredBoatsJSON({ error, data }) {
18         if (data) {
19             this.createMapMarkers(data);
20         } else if (error) {
21             this.dispatchEvent(
22                 new ShowToastEvent({
23                     title: ERROR_TITLE,
24                     message: error.body.message,
25                     variant: ERROR_VARIANT
26                 })
27             );
28             this.isLoading = false;
29         }
30     }
31     renderedCallback() {
32         if (this.isRendered == false) {
33             this.getLocationFromBrowser();
34         }
35         this.isRendered = true;
36     }
37     getLocationFromBrowser() {
38         navigator.geolocation.getCurrentPosition(
39             (position) => {
40                 this.latitude = position.coords.latitude;
41                 this.longitude = position.coords.longitude;
42             },
43             (e) => {
44 
45             }, {
46             enableHighAccuracy: true
47         }
48         );
49     }
50     createMapMarkers(boatData) {
51         this.mapMarkers = boatData.map(rowBoat => {
52             return {
53                 location: {
54                     Latitude: rowBoat.Geolocation__Latitude__s,
55                     Longitude: rowBoat.Geolocation__Longitude__s
56                 },
57                 title: rowBoat.Name,
58             };
59         });
60         this.mapMarkers.unshift({
61             location: {
62                 Latitude: this.latitude,
63                 Longitude: this.longitude
64             },
65             title: LABEL_YOU_ARE_HERE,
66             icon: ICON_STANDARD_USER
67         });
68         this.isLoading = false;
69     }
70 }

boatsNearMe.html

<template>
    <lightning-card class="slds-is-relative">
       <!-- The template and lightning-spinner goes here -->
       <template if:true={isLoading}>
        <lightning-spinner
        alternative-text="Loading" variant="brand">
        </lightning-spinner>
       </template>
       <!-- The lightning-map goes here -->
       <lightning-map map-markers={mapMarkers}></lightning-map>
       <div slot="footer">Top 10 Only!</div>
    </lightning-card>
</template>

boatSearchResults.js: wire adapter / lightning message service / toast / 父元件處理子元件事件

  1 import { LightningElement, wire, api, track } from 'lwc';
  2 import getBoats from '@salesforce/apex/BoatDataService.getBoats';
  3 import { updateRecord } from 'lightning/uiRecordApi';
  4 import { ShowToastEvent } from 'lightning/platformShowToastEvent';
  5 import { refreshApex } from '@salesforce/apex';
  6 import { publish, MessageContext } from 'lightning/messageService';
  7 import BoatMC from '@salesforce/messageChannel/BoatMessageChannel__c';
  8 
  9 export default class BoatSearchResults extends LightningElement {
 10     boatTypeId = '';
 11     @track boats;
 12     @track draftValues = [];
 13     selectedBoatId = '';
 14     isLoading = false;
 15     error = undefined;
 16     wiredBoatsResult;
 17 
 18     @wire(MessageContext) messageContext;
 19 
 20     columns = [
 21         { label: 'Name', fieldName: 'Name', type: 'text', editable: 'true'  },
 22         { label: 'Length', fieldName: 'Length__c', type: 'number', editable: 'true' },
 23         { label: 'Price', fieldName: 'Price__c', type: 'currency', editable: 'true' },
 24         { label: 'Description', fieldName: 'Description__c', type: 'text', editable: 'true' }
 25     ];
 26 
 27     @api
 28     searchBoats(boatTypeId) {
 29         this.isLoading = true;
 30         this.notifyLoading(this.isLoading);
 31         this.boatTypeId = boatTypeId;
 32     }
 33 
 34     @wire(getBoats, { boatTypeId: '$boatTypeId' })
 35     wiredBoats(result) {
 36         this.boats = result;
 37         if (result.error) {
 38             this.error = result.error;
 39             this.boats = undefined;
 40         }
 41         this.isLoading = false;
 42         this.notifyLoading(this.isLoading);
 43     }
 44 
 45     updateSelectedTile(event) {
 46         this.selectedBoatId = event.detail.boatId;
 47         this.sendMessageService(this.selectedBoatId);
 48     }
 49 
 50     handleSave(event) {
 51         this.notifyLoading(true);
 52        const recordInputs = event.detail.draftValues.slice().map(draft=>{
 53            const fields = Object.assign({}, draft);
 54            return {fields};
 55        });
 56 
 57        console.log(recordInputs);
 58        const promises = recordInputs.map(recordInput => updateRecord(recordInput));
 59        Promise.all(promises).then(res => {
 60            this.dispatchEvent(
 61                new ShowToastEvent({
 62                    title: SUCCESS_TITLE,
 63                    message: MESSAGE_SHIP_IT,
 64                    variant: SUCCESS_VARIANT
 65                })
 66            );
 67            this.draftValues = [];
 68            return this.refresh();
 69        }).catch(error => {
 70            this.error = error;
 71            this.dispatchEvent(
 72                 new ShowToastEvent({
 73                     title: ERROR_TITLE,
 74                     message: CONST_ERROR,
 75                     variant: ERROR_VARIANT
 76                 })
 77             );
 78             this.notifyLoading(false);
 79        }).finally(() => {
 80             this.draftValues = [];
 81         });
 82     }
 83     @api
 84     async refresh() {
 85         this.isLoading = true;
 86         this.notifyLoading(this.isLoading);      
 87         await refreshApex(this.boats);
 88         this.isLoading = false;
 89         this.notifyLoading(this.isLoading);        
 90     }
 91 
 92 
 93     notifyLoading(isLoading) {
 94         if (isLoading) {
 95             this.dispatchEvent(new CustomEvent('loading'));
 96         } else {
 97             this.dispatchEvent(CustomEvent('doneloading'));
 98         }
 99     }
100 
101      sendMessageService(boatId) { 
102         publish(this.messageContext, BoatMC, { recordId : boatId });
103     }
104 }

boatSearchResults.html:巢狀子元件,展示UI

<template>
    <lightning-tabset variant="scoped">
        <lightning-tab label="Gallery">
            <template if:true={boats.data}>
                <div class="slds-scrollable_y">
                    <lightning-layout horizontal-align="center" multiple-rows>
                        <template for:each={boats.data} for:item="boat">
                            <lightning-layout-item key={boat.Id} padding="around-small" size="12" small-device-size="6"
                                medium-device-size="4" large-device-size="3">
                                <c-boat-tile boat={boat} selected-boat-id={selectedBoatId}
                                    onboatselect={updateSelectedTile}></c-boat-tile>
                            </lightning-layout-item>
                        </template>
                    </lightning-layout>
                </div>
            </template>
        </lightning-tab>
        <lightning-tab label="Boat Editor">
            <!-- Scrollable div and lightning datatable go here -->
            <template if:true={boats.data}>
                <div class="slds-scrollable_y">
                    <lightning-datatable key-field="Id" data={boats.data} columns={columns} onsave={handleSave}
                        draft-values={draftValues} hide-checkbox-column show-row-number-column>
                    </lightning-datatable>
                </div>
            </template>
        </lightning-tab>
        <lightning-tab label="Boats Near Me">
            <!-- boatsNearMe component goes here -->
            <c-boats-near-me boat-type-id={boatTypeId}></c-boats-near-me>
        </lightning-tab>
    </lightning-tabset>
</template>

boatSearch.js:處理子元件事件 / navigation

import { LightningElement , track } from 'lwc';
import { NavigationMixin } from 'lightning/navigation'

// imports
export default class BoatSearch extends NavigationMixin(LightningElement) {
    @track isLoading = false;

    // Handles loading event
    handleLoading(event) {
        this.isLoading = true;
    }

    // Handles done loading event
    handleDoneLoading(event) {
        this.isLoading = false;
    }

    // Handles search boat event
    // This custom event comes from the form
    searchBoats(event) {
        const boatTypeId = event.detail.boatTypeId;
        this.template.querySelector("c-boat-search-results").searchBoats(boatTypeId);
    }

    createNewBoat() {
        this[NavigationMixin.Navigate]({
            type: 'standard__objectPage',
            attributes: {
                objectApiName: 'Boat__c',
                actionName: 'new'
            }
        });
    }
}

boatSearch.html: UI展示

<template>
    <lightning-layout multiple-rows>
        <!-- Top -->
        <lightning-layout-item size="12">
            <lightning-card title="Find a Boat">
                <!-- New Boat button goes here -->
                <lightning-button label="New Boat" onclick={createNewBoat}></lightning-button>
                <p class="slds-var-p-horizontal_small">
                    <!-- boatSearchForm component goes here -->
                    <c-boat-search-form onsearch={searchBoats}></c-boat-search-form>
                </p>
            </lightning-card>
        </lightning-layout-item>
        <!-- Bottom -->
        <lightning-layout-item size="12" class="slds-p-top_small slds-is-relative">
            <!-- Spinner goes here -->
            <template if:true={isLoading}>
                <lightning-spinner alternative-text="Loading" variant="brand"></lightning-spinner>
            </template>
            <!-- boatSearchResults goes here -->
            <c-boat-search-results onloading={handleLoading} ondoneloading={handleDoneLoading}></c-boat-search-results>
            <!-- onloading={handleLoading} ondoneloading={handleDoneLoading} -->
        </lightning-layout-item>
    </lightning-layout>
</template>

boatReviews.js:wire adapter / navigation / 父子巢狀

 1 import { LightningElement, api } from 'lwc';
 2 import getAllReviews  from '@salesforce/apex/BoatDataService.getAllReviews';
 3 import { NavigationMixin } from 'lightning/navigation';
 4 import { refreshApex } from '@salesforce/apex';
 5 
 6 export default class BoatReviews extends NavigationMixin(LightningElement) {
 7     // Private
 8     boatId;
 9     error;
10     boatReviews = [];
11     isLoading = false;
12     
13     // Getter and Setter to allow for logic to run on recordId change
14     @api
15     get recordId() { 
16         return this.boatId;
17     }
18 
19     set recordId(value) {
20       //sets boatId attribute
21       this.setAttribute('boatId', value);
22       //sets boatId assignment
23       this.boatId = value;
24       //get reviews associated with boatId
25       this.getReviews();
26     }
27     
28     // Getter to determine if there are reviews to display
29     get reviewsToShow() { 
30         return this.boatReviews && this.boatReviews.length > 0 ? true : false;
31     }
32     
33     // Public method to force a refresh of the reviews invoking getReviews
34     @api
35     refresh() { 
36         refreshApex(this.getReviews());
37     }
38     
39     // Imperative Apex call to get reviews for given boat
40     // returns immediately if boatId is empty or null
41     // sets isLoading to true during the process and false when it’s completed
42     // Gets all the boatReviews from the result, checking for errors.
43     getReviews() { 
44         if(this.boatId == null  || this.boatId == '') {
45             return;
46         }
47         this.isLoading = true;
48         this.error = undefined;
49         getAllReviews({boatId:this.recordId})
50         .then(result=>{
51             this.boatReviews = result;
52             this.isLoading = false;
53         }).catch(error=>{
54             this.error = error.body.message;
55         }).finally(() => {
56             this.isLoading = false;
57          });
58     }
59     
60     // Helper method to use NavigationMixin to navigate to a given record on click
61      navigateToRecord(event) { 
62         this[NavigationMixin.Navigate]({
63           type: "standard__recordPage",
64           attributes: {
65               recordId: event.target.dataset.recordId,
66               actionName: "view"
67           }
68         });
69       }
70   }

boatReviews.html

<template>
    <!-- div for when there are no reviews available -->
    <template if:false={reviewsToShow}>
      <div class="slds-feed, reviews-style, slds-is-relative, slds-scrollable_y "><div class="slds-align_absolute-center">No reviews available</div></div>
    </template>
    
    <!-- div for when there are reviews available -->
    <div>
    <!-- insert spinner -->
    <template if:true={isLoading}>
      <lightning-spinner variant="brand" alternative-text="Loading" size="small"></lightning-spinner>
    </template>
    <template if:true={reviewsToShow}>
      <ul class="slds-feed__list">
        <!-- start iteration -->
        <template for:each={boatReviews} for:item="boatReview">
          <li class="slds-feed__item" key={boatReview.Id}>
            <article class="slds-post">
              <header class="slds-post__header slds-media">
                <div class="slds-media__figure">
                  <!-- display the creator’s picture -->
                  <lightning-avatar 
                      variant="circle" 
                      src={boatReview.CreatedBy.SmallPhotoUrl} 
                      initials="AW" 
                      fallback-icon-name="standard:user" 
                      alternative-text={boatReview.CreatedBy.Name}  
                      class="slds-m-right_small">
              </lightning-avatar>
                </div>
                <div class="slds-media__body">
                  <div class="slds-grid slds-grid_align-spread slds-has-flexi-truncate">
                    <p>
                        <!-- display creator’s name -->
                        <a data-record-id={boatReview.CreatedBy.Id} title={boatReview.CreatedBy.Name} onclick={navigateToRecord}>{boatReview.CreatedBy.Name}
                        </a>
                      <span><!-- display creator’s company name -->
                        {boatReview.CreatedBy.CompanyName}
                      </span>
                    </p>
                  </div>
                  <p class="slds-text-body_small">
                    <!-- display created  date -->
                    <lightning-formatted-date-time value={boatReview.CreatedDate}></lightning-formatted-date-time>
                  </p>
                </div>
              </header>
              <div class="slds-text-longform">
                <p class="slds-text-title_caps"><!-- display Name -->{boatReview.Name}</p>
                <!-- display Comment__c -->
                <lightning-formatted-rich-text value={boatReview.Comment__c}></lightning-formatted-rich-text>
              </div>
              <!-- display five star rating on readonly mode -->
              <c-five-star-rating read-only value={boatReview.Rating__c}></c-five-star-rating>
            </article>
          </li>
        
        </template> 
        <!-- end iteration -->
      </ul>
    </template>
    </div>
  </template>

boatReviews.css

.reviews-style {
    max-height: 250px;
}

boatAddReviewForm.js:lightning data service / wire adapter / toast

 1 import { LightningElement, api, track } from 'lwc';
 2 import { createRecord } from 'lightning/uiRecordApi';
 3 import { ShowToastEvent } from 'lightning/platformShowToastEvent';
 4 import NAME_FIELD from '@salesforce/schema/BoatReview__c.Name';
 5 import COMMENT_FIELD from '@salesforce/schema/BoatReview__c.Comment__c';
 6 import RATING_FIELD from '@salesforce/schema/BoatReview__c.Rating__c';
 7 import BOAT_REVIEW_OBJECT from '@salesforce/schema/BoatReview__c';
 8 import BOAT_FIELD from '@salesforce/schema/BoatReview__c.Boat__c';
 9 
10 const SUCCESS_TITLE = 'Review Created!';
11 const SUCCESS_VARIANT = 'success';
12 
13 export default class BoatAddReviewForm extends LightningElement {
14     @api boat;
15     boatId;
16     nameField = NAME_FIELD;
17     commentField = COMMENT_FIELD;
18     boatReviewObject = BOAT_REVIEW_OBJECT;
19     rating = 0;
20     review = '';
21     title = '';
22     comment = '';
23 
24 
25     @api
26     get recordId() {
27         return this.boatId;
28     }
29 
30     set recordId(value) {
31       this.boatId = value;
32   }
33 
34 // Gets user rating input from stars component
35 handleRatingChanged(event) {
36     this.rating = event.detail.rating;
37  }
38 
39 // Custom submission handler to properly set Rating
40 // This function must prevent the anchor element from navigating to a URL.
41 // form to be submitted: lightning-record-edit-form
42 handleSubmit(event) {
43   event.preventDefault();
44   const fields = event.detail.fields;
45   fields.Boat__c = this.boatId;
46   fields.Rating__c = this.rating;
47   this.template.querySelector('lightning-record-edit-form').submit(fields);
48  }
49 
50 // Shows a toast message once form is submitted successfully
51 // Dispatches event when a review is created
52 handleSuccess() {
53   // TODO: dispatch the custom event and show the success message
54   const evt = new ShowToastEvent({
55       title: SUCCESS_TITLE,
56       variant: SUCCESS_VARIANT
57   });
58   this.dispatchEvent(evt);
59   this.dispatchEvent(new CustomEvent('createreview'));
60   this.handleReset();
61 }
62 
63 // Clears form data upon submission
64 // TODO: it must reset each lightning-input-field
65 handleReset() { 
66   const inputFields = this.template.querySelectorAll(
67       'lightning-input-field'
68   );
69   if (inputFields) {
70       inputFields.forEach(field => {
71           field.reset();
72       });
73   }
74 }
75 }

boatAddReviewForm.html

<template>
  <lightning-record-edit-form object-api-name="BoatReview__c" onsuccess={handleSuccess} onsubmit={handleSubmit}>
      <lightning-layout vertical-align="stretch" multiple-rows="true">
          <lightning-messages>
          </lightning-messages>
          <lightning-layout-item size="12">
              <lightning-input-field field-name="Name">
              </lightning-input-field>
          </lightning-layout-item>
          <lightning-layout-item size="12">
              <p>Rating:</p>
              <c-five-star-rating rating-value={rating} onratingchange={handleRatingChanged}>
              </c-five-star-rating>
          </lightning-layout-item>
          <lightning-layout-item>
              <lightning-input-field field-name="Comment__c">
              </lightning-input-field>
          </lightning-layout-item>
          <div class="slds-align--absolute-center">
              <lightning-button label="Submit" type="submit" icon-name="utility:save"></lightning-button>
          </div>
      </lightning-layout>
  </lightning-record-edit-form>
</template>

boatDetailTabs: 訂閱 lightning message service / wire adapter / navigation

// Custom Labels Imports
// import labelDetails for Details
// import labelReviews for Reviews
// import labelAddReview for Add_Review
// import labelFullDetails for Full_Details
// import labelPleaseSelectABoat for Please_select_a_boat
// Boat__c Schema Imports
// import BOAT_ID_FIELD for the Boat Id
// import BOAT_NAME_FIELD for the boat Name
import { LightningElement, api,wire } from 'lwc';
import labelDetails from '@salesforce/label/c.Details';
import labelReviews from '@salesforce/label/c.Reviews';
import labelAddReview from '@salesforce/label/c.Add_Review';
import labelFullDetails from '@salesforce/label/c.Full_Details';
import labelPleaseSelectABoat from '@salesforce/label/c.Please_select_a_boat';
import BOAT_ID_FIELD from '@salesforce/schema/Boat__c.Id';
import BOAT_NAME_FIELD from '@salesforce/schema/Boat__c.Name';
import { getRecord,getFieldValue } from 'lightning/uiRecordApi';
import BOATMC from '@salesforce/messageChannel/BoatMessageChannel__c';
import { APPLICATION_SCOPE,MessageContext, subscribe } from 'lightning/messageService';
import BoatReviews from 'c/boatReviews';
const BOAT_FIELDS = [BOAT_ID_FIELD, BOAT_NAME_FIELD];
import {NavigationMixin} from 'lightning/navigation';
export default class BoatDetailTabs extends NavigationMixin(LightningElement) {
  @api boatId;

  label = {
    labelDetails,
    labelReviews,
    labelAddReview,
    labelFullDetails,
    labelPleaseSelectABoat,
  };
  
  // Decide when to show or hide the icon
  // returns 'utility:anchor' or null
  get detailsTabIconName() {
    return this.wiredRecord && this.wiredRecord.data ? 'utility:anchor' : null;
   }
  
  // Utilize getFieldValue to extract the boat name from the record wire
  @wire(getRecord,{recordId: '$boatId', fields: BOAT_FIELDS})
  wiredRecord;

  get boatName() {
    return getFieldValue(this.wiredRecord.data, BOAT_NAME_FIELD);
   }
  
  // Private
  subscription = null;
  // Initialize messageContext for Message Service
  @wire(MessageContext)
  messageContext;
  
  // Subscribe to the message channel
  subscribeMC() {
    if(this.subscription) { return; }
    // local boatId must receive the recordId from the message
    this.subscription = subscribe(
        this.messageContext, 
        BOATMC, 
        (message) => {
            this.boatId = message.recordId;
        }, 
        { scope: APPLICATION_SCOPE }
    );
  }
  
  // Calls subscribeMC()
  connectedCallback() { 
    this.subscribeMC();
  }
  
  // Navigates to record page
  navigateToRecordViewPage() {
    this[NavigationMixin.Navigate]({
      type: "standard__recordPage",
      attributes: {
          recordId: this.boatId,
          actionName: "view"
      }
  });
   }
  
  // Navigates back to the review list, and refreshes reviews component
  handleReviewCreated() {
    this.template.querySelector('lightning-tabset').activeTabValue = 'reviews';
    this.template.querySelector('c-boat-reviews').refresh();
    // BoatReviews.refresh();
   }
}

boatDetailTabs.html

<template>
  <template if:false={wiredRecord.data}>
    <!-- lightning card for the label when wiredRecord has no data goes here  -->
      <lightning-card class= "slds-align_absolute-center no-boat-height">
          <span>{label.labelPleaseSelectABoat}</span>
      </lightning-card>
  </template>
  <template if:true={wiredRecord.data}>
     <!-- lightning card for the content when wiredRecord has data goes here  -->
     <lightning-card>
         <lightning-tabset variant="scoped">
             <lightning-tab label={label.labelDetails}>
                 <lightning-card icon-name={detailsTabIconName} title={boatName}>
                     <lightning-button slot="actions" title={boatName} label={label.labelFullDetails} onclick={navigateToRecordViewPage}></lightning-button>
                     <lightning-record-view-form density="compact"
                          record-id={boatId}
                          object-api-name="Boat__c">
                          <lightning-output-field field-name="BoatType__c" class="slds-form-element_1-col"></lightning-output-field>
                          <lightning-output-field field-name="Length__c" class="slds-form-element_1-col"></lightning-output-field>
                          <lightning-output-field field-name="Price__c" class="slds-form-element_1-col"></lightning-output-field>
                          <lightning-output-field field-name="Description__c" class="slds-form-element_1-col"></lightning-output-field>
                     </lightning-record-view-form>
                 </lightning-card>
             </lightning-tab>
             <lightning-tab label={label.labelReviews}  value="reviews"><c-boat-reviews record-id={boatId}></c-boat-reviews></lightning-tab>
             <lightning-tab label={label.labelAddReview}> <c-boat-add-review-form record-id={boatId} oncreatereview={handleReviewCreated}></c-boat-add-review-form></lightning-tab>
         </lightning-tabset>
     </lightning-card>
  </template>
</template>

boatDetailTabs.css

.no-boat-height {
    height: 3rem;
}

boatMap.js: lightning message service / wire adapter

// import BOATMC from the message channel
import { LightningElement,wire,api,track } from 'lwc';
import { getRecord } from 'lightning/uiRecordApi';
import { APPLICATION_SCOPE,subscribe,MessageContext,unsubscribe } from 'lightning/messageService';
import BOATMC from '@salesforce/messageChannel/BoatMessageChannel__c';
// Declare the const LONGITUDE_FIELD for the boat's Longitude__s
// Declare the const LATITUDE_FIELD for the boat's Latitude
// Declare the const BOAT_FIELDS as a list of [LONGITUDE_FIELD, LATITUDE_FIELD];
const LONGITUDE_FIELD = 'Boat__c.Geolocation__Longitude__s';
const LATITUDE_FIELD = 'Boat__c.Geolocation__Latitude__s';
const BOAT_FIELDS = [LONGITUDE_FIELD, LATITUDE_FIELD];
export default class BoatMap extends LightningElement {
    // private
    subscription = null;
    boatId;
    // Getter and Setter to allow for logic to run on recordId change
    // this getter must be public
    @api get recordId() {
        return this.boatId;
    }
    set recordId(value) {
        this.setAttribute('boatId', value);
        this.boatId = value;
    }
    //public
    @track error = undefined;
    @track mapMarkers = [];
    // Initialize messageContext for Message Service
    @wire(MessageContext)
    messageContext;
    // Getting record's location to construct map markers using recordId
    // Wire the getRecord method using ('$boatId')
    @wire(getRecord,{recordId:'$boatId',fields:BOAT_FIELDS})
    wiredRecord({ error, data }) {
    // Error handling
        if (data) {
            this.error = undefined;
            const longitude = data.fields.Geolocation__Longitude__s.value;
            const latitude = data.fields.Geolocation__Latitude__s.value;
            console.log('*** longitude : ' + longitude);
            console.log('*** latitude : ' + latitude);
            this.updateMap(longitude, latitude);
        } else if (error) {
            this.error = error;
            this.boatId = undefined;
            this.mapMarkers = [];
        }
    }
    // Encapsulate logic for Lightning message service subscribe and unsubsubscribe
    subscribeMC(){
        if(!this.subscription){
            this.subscription = subscribe(
                this.messageContext,
                BOATMC,
                (message) => {this.boatId = message.recordId},
                { scope: APPLICATION_SCOPE }
            );
        }
    }

    unsubscribeToMessageChannel() {
        unsubscribe(this.subscription);
        this.subscription = null;
    }
    // Runs when component is connected, subscribes to BoatMC
    connectedCallback() {
        // recordId is populated on Record Pages, and this component
        // should not update when this component is on a record page.
        if (this.subscription || this.recordId) {
            return;
        }
        this.subscribeMC();
        // Subscribe to the message channel to retrieve the recordID and assign it to boatId.
    }
    disconnectedCallback() {
        this.unsubscribeToMessageChannel();
    }
    // Creates the map markers array with the current boat's location for the map.
    updateMap(Longitude, Latitude) {
        this.mapMarkers = [{
            location : {
                Latitude : Latitude,
                Longitude : Longitude
            }
        }];
    }
    // Getter method for displaying the map component, or a helper method.
    get showMap() {
        return this.mapMarkers.length > 0;
    }
}

boatMap.html

<template>
  <lightning-card title="Current Boat Location" icon-name="action:map">
    <template if:true={showMap}>
      <lightning-map map-markers={mapMarkers} zoom-level="10"></lightning-map>
    </template>
    <template if:false={showMap}>
      <span class="slds-align_absolute-center">
        Please select a boat to see its location!
      </span>
    </template>
  </lightning-card>
</template>

一覽圖展示各元件位置及關係,我們需要建立一個 single app,將 boatSearch / boatDetailTabs 以及 boatMap拖動到指定位置即可。

通過以上程式碼即可實現一個lwc的簡單的app。

總結:篇中根據lwc superbadge進行了程式碼的整理,程式碼並非最優版,感興趣小夥伴自行優化,篇中有錯誤歡迎指出,有不懂歡迎留言。