Wspominałem w newsach (w grudniu 2018r), że przyjrzę się co to właściwie są te całe Web Componenty i jak trudne dla programisty Apexa to jest. Ponieważ zawsze brakowało mi czasu na naukę JSa więc temat leżał i był odkładany na później. Ostatnio udało mi się zagospodarować kilka chwil i zacząłem studiować dokumentację i rozwiązywać proste przykłady. Muszę jeszcze zrobić zaległe Trialheady :)
Wracając jednak do sedna, na warsztat wziąłem prostą implementację kalendarza i wypełnienie go danymi. Do tego zadania użyłem :
LWC
FullCalendar
Moment + JQuery
Początkowo chciałem użyć biblioteki FullCalendar w wersji 4.x jednak po kilku próbach okazało się, że LWC nie wspierają (jeszcze) prototypów JSowych. Dla człowieka, który na co dzień programuje w Apexie brzmiało to jak magia. Na StackExchange sugerowali przerobienie metod prototypowych, jednak nie do końca mi to wyszło, tak, kalendarz się inicjalizował i renderował ale występowały problemy z callbackami. Szukając pomocy natrafiłem na sugestię aby użyć wersji 3.10. Tak też zrobiłem.
W LWC na zewnętrzne biblioteki narzucany jest dodatkowy czynnik jakim jest Locker Service. Czym on jest? W skrócie powoduje on opakowanie JSowych obiektów w tzw. obiekty Proxy które są niemutowalne. Zapobiega to modyfikacji tych obiektów oraz zabezpiecza dane. Niestety każda biblioteka zewnętrzna jest rozpatrywana poprzez pryzmat tego rozwiązania. Stanowi to czasami nie lada problemy.
Na szczęście wersja 3.10 działa bez zarzutu i z uruchomieniem biblioteki nie miałem problemów. Pojawił się jednak inny czynnik, który stanowił wyzwanie. W jaki sposób przesłać dane do kalendarza? W filozofii LWC jest jasno powiedziane, że właściwości śledzone (@track) są one reaktywne i przy każdej zmianie powodują przerenderowanie formularza, który je zawiera. Jak jednak odnieść to do zewnętrznej biblioteki, która nie korzysta z reaktywnych właściwości LWC? Długo borykałem się z tym problemem a rozwiązanie okazało się dosyć trywialne aczkolwiek według mnie mało eleganckie.
Otóż dane pobierane z serwera są wywoływane asynchronicznie, tak samo jak inicjalizacja biblioteki. Może zdarzyć się tak, że kalendarz pojawi się wcześniej niż dane (lub odwrotnie). Ciężko jest więc zsynchronizować te dwa eventy. Drugi problem to to, że kalendarz ładowany jest ze static resources w momencie wykonania przez LWC renderedCallback. To narzuca problem, że wszelkie ustawienia co do formatu daty itp. są wykonywane w momencie renderowania kalendarza.
Postanowiłem w mało elegancki sposób wybrnąć z sytuacji. Założyłem, że będę sprawdzał przy inicjalizacji kalendarza czy dane już są. Jeżeli tak, to wypełnię nim kalendarz. Jeżeli nie, to ustawiam dodatkową flagę, że brakuje danych. Z drugiej strony w momencie pobierania danych sprawdzam, czy kalendarz jest już zainicjalizowany i wypełniony danymi. Jeżeli nie wykonuję metodę aby wypełniła kalendarz. Dzięki temu mechanizmowi dane są zawsze obecne w kalendarzu.
Przejdźmy więc do kodu.
Pierwszym elementem jest template HTMLowy:
<!-- Calendar -->
<template>
<lightning-card title="">
<template if:false={hideLoading}>
<div class="loading">
<lightning-spinner if:false={hideLoading}></lightning-spinner>
</div>
</template>
<div class="slds-m-around_medium">
<div class="calendar" lwc:dom="manual"></div>
</div>
</lightning-card>
</template>
Jak widać nie ma w nim nic szczególnego. Cała magia dzieje się w divie z klasą calendar. To tutaj biblioteka umieszcza swoje znaczniki kalendarza. Przejdźmy może do dużo ciekawszej części, czyli do kodu JSowego.
import { LightningElement, wire, track } from 'lwc';
import { loadScript, loadStyle } from 'lightning/platformResourceLoader';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import CalendarJS from '@salesforce/resourceUrl/FullCalendar';
import getAllBillsApex from '@salesforce/apex/Bill_CalendarController.getAllBills';
export default class Calendar extends LightningElement {
@track calendarInitialized = false;
@track dataLoaded = false;
@track bills;
@track error;
get hideLoading() {
return this.calendarInitialized && this.dataLoaded;
}
@wire(getAllBillsApex) wiredBills({ error, data }) {
if (data) {
this.bills = data;
this.error = undefined;
if (!this.dataLoaded && this.calendarInitialized) {
this.fillData();
}
} else if (error) {
this.error = error;
this.bills = undefined;
}
}
parseBackendData() {
let arr = JSON.parse(JSON.stringify(this.bills));
let source = [];
arr.forEach(i => {
source.push({
id: i.id,
title: i.title,
start: moment(i.startDate, 'YYYY-MM-DD'),
end: moment(i.endDate, 'YYYY-MM-DD')
});
});
return source;
}
fillData() {
const cal = this.template.querySelector('div.calendar');
if (this.bills) {
console.log('Add to calendar');
$(cal).fullCalendar('addEventSource', this.parseBackendData());
$(cal).fullCalendar('rerenderEvents');
this.dataLoaded = true;
} else {
console.log('No data yet.');
}
}
renderedCallback() {
if (this.calendarInitialized) {
return;
}
Promise.all([
loadScript(this, CalendarJS + '/lib/jquery.min.js'),
loadScript(this, CalendarJS + '/lib/moment.min.js'),
loadScript(this, CalendarJS + '/fullcalendar.js'),
loadStyle(this, CalendarJS + '/fullcalendar.css'),
])
.then(() => {
this.initializeCalendar();
this.calendarInitialized = true;
this.fillData();
})
.catch(error => {
this.dispatchEvent(
new ShowToastEvent({
title: 'Error loading Calendar',
message: error.message,
variant: 'error',
}),
);
});
}
initializeCalendar() {
const cal = this.template.querySelector('div.calendar');
$(cal).fullCalendar({
defaultDate: moment().format("YYYY-MM-DD"),
selectable: true,
editable: true,
firstDay: 1,
header: {
left: 'prev,next today',
center: 'title',
right: ''
},
select: ((i,j) => this.handleSelect(i,j)),
dateClick: (i => this.handleDateClick(i)),
eventClick: ((i,j,k) => this.handleEventClick(i,j,k)),
});
}
handleSelect(startDate, endDate) {
let message = `Selected ${startDate.format()} to ${endDate.format()}`;
this.showToastMessage('Selected', message, 'info');
}
handleDateClick(date) {
let message = `Clicked date ${date.format()}`;
this.showToastMessage('Clicked', message, 'info');
}
handleEventClick(event, jsEvent, view) {
let message = `Clicked event ${event.title} on view ${view.type} Coordinates: ${jsEvent.pageX} ${jsEvent.pageY}`;
this.showToastMessage('Event click', message, 'info');
}
showToastMessage(title, message, variant) {
const evt = new ShowToastEvent({
title: title,
message: message,
variant: variant,
mode: 'dismissable'
});
this.dispatchEvent(evt);
}
}
Ciekawe jest kilka elementów. Po pierwsze tak jak pisałem wcześniej, mamy dwie flagi do sterowania przepływem danych: calendarInitialized oraz dataLoaded. Obie domyślnie są ustawione na False. Ponadto zastosowałem getter który powoduje schowanie spinnera kiedy obie flagi są True.
W linii 22 widzimy sprawdzenie owych warunków i jeżeli danych nie ma a kalendarz jest zainicjowany, to wypełniamy go danymi. Podobny mechanizm mamy w linii 73. Tutaj jednak nie sprawdzamy tego jawnie, ponieważ warunek znajduje się w metodzie fillData. Przy inicjowaniu kalendarza ten warunek sprawdza czy kolekcja bills jest wypełniona, jeżeli nie, to wyświetla po prostu komunikat w konsoli. Oznacza to też, że dane przyjdą później i w metodzie pobierania danych zostaną one wczytane do kalendarza.
Następny ciekawy element to samo ładowanie bibliotek. W LWC wykonuje się to w sposób pokazany w linii 60-74. Należy upewnić się, że biblioteka nie jest jeszcze zainicializowana, ponieważ renderedCallback może być wywołany kilkakrotnie. Nie chcemy za każdym razem na nowo inicjować kalendarza.
Metoda initializeCalendar (linie 86) powoduje ustawienie naszego docelowego diva i wyrenderowanie w nim kalendarza. Z dokumentacji biblioteki można dowiedzieć się o poszczególnych opcjach. Tutaj istotne jest pobranie querySelectora i wykonanie na nim (za pomocą JQuery) inicjalizacji. Każda metoda callbackowa z kalendarza musi być opakowana jako pokazano w 98-100 ponieważ z wnętrza funkcji LWC nie widzi naszych lokalnych metod. Opakowanie tego w funkcje anonimowe rozwiązuje ten problem.
Ostatnim elementem to parsowanie danych z backendu. Jest to pokazane w metodzie parseBackendData. Ponieważ Locker Service opakowuje dane w Proxy więc nie można w łatwy sposób dobrać się do danych. Rozwiązaniem tego jest przekonwertowanie danych na Stringa (JSON.stringify) a następnie z powrotem przeparsowanie tego stringa do JSONa do lokalnej kolekcji. W ten sposób możemy operować na danych w JS obiektach.
Z backendu wystawiamy dane opakowane we wrapper. Dlaczego? Nie potrzebowałem tutaj wszystkich danych i wszystkich pól. Zebrałem więc tylko to co na daną chwilę było mi potrzebne.
Klasa backendowa wygląda następująco:
public with sharing class Bill_CalendarController {
@AuraEnabled(Cacheable = true)
public static List<Bill_CalendarController.BillWrapper> getAllBills() {
List<Bill__c> bills = Bill_DAO.getAllBills();
List<Bill_CalendarController.BillWrapper> wrappers = new List<Bill_CalendarController.BillWrapper>();
for (Bill__c bill : bills) {
wrappers.add(new Bill_CalendarController.BillWrapper(
bill.Id,
bill.Name,
String.valueOf(bill.Pay_date__c),
String.valueOf(bill.Pay_date__c)
));
}
return wrappers;
}
public class BillWrapper {
@AuraEnabled
public String id;
@AuraEnabled
public String title;
@AuraEnabled
public String startDate;
@AuraEnabled
public String endDate;
public BillWrapper(String id, String title, String startDate, String endDate) {
this.id = id;
this.title = title;
this.startDate = startDate;
this.endDate = endDate;
}
}
}
Jak widzimy w klasie kontrolera definiujemy również wewnętrzną klasę ze strukturą danych. Proste i często stosowane rozwiązanie. Oczywiście można dane wyrzucić w postaci listy obiektów i podobnie jak powyżej, parsować dane na froncie.
Zabawa z LWC jest czymś innym niż programowanie w Apexie. Wszystko opiera się o JavaScript i ES6 co dla backendowych developerów może być czymś nowym. Filozofia stojąca za tym frameworkiem jest zupełnie czymś innym niż Aura. Na tą chwilę jest to ciekawa technologia, która mam nadzieję rozwinie się jeszcze bardziej.