Create a custom calendar in LWC — Part 3 (Event Management)

Rohit Tanwani
14 min readJan 7, 2024

--

We discussed using the Fullcalendar.io library in a Lightning Web Component in Part 1 of this series and styling the custom calendar in Part 2. Both of the links are provided above. If you haven’t read those parts please go through them and then come back here. So our calendar after part 2 looks like this.

Custom calendar after Part 2

This part is going to be the longest part of this series because we are going to discuss the following points.

  1. Display events (records) in the custom calendar.
  2. Adding records through the calendar and copying the start time from the calendar.
  3. Remove records from the custom calendar.
  4. Styling the events in the calendar so that they look identical to the salesforce event

Let’s give a little bit of information about the object I am going to use in this project. I have created a custom object known as Meeting (API Name as Meeting__c) which has three required custom fields added to their page layout.

  1. Start Date/Time: This field contains the information for meeting start time.
  2. End Date/Time: This field contains the information for meeting end time.
  3. Purpose: This field contains the purpose of the meeting or why this meeting is scheduled.

So after creating objects and fields, create some records in this object so that some data will be there to display in the calendar. After that let’s start with the first task.

Displaying events in the custom calendar

To display records we first have to query relevant data from the org. To query data we are creating an apex class with the name ‘CustomCalendarController’ (you can name anything) and in that class, we are creating a method with the name ‘getAllMeetingsData’. This method will query all the meeting data and return it to LWC. To use it in LWC we have to annotate this method with @AuraEnabled. Here is the complete class code.

public with sharing class CustomCalendarController {
@AuraEnabled(cacheable=true)
public static List<Meetings__c> getAllMeetingsData() {
List<Meetings__c> meetingsList = [SELECT ID, End_Date_Time__c, Start_Date_Time__c, Purpose__c
FROM Meetings__c];

return meetingsList;
}
}

Now we have to import this method into our LWC component. To import this method we have to write the following code in our LWC.

import getAllMeetingsData from '@salesforce/apex/CustomCalendarController.getAllMeetingsData';

Change the ‘objectApiName’ variable value from ‘Account’ to ‘Meeting__c’ in the LWC component. Now we go to Fullcalendar.io library to view how to pass event data to the calendar. Go to the Event Sources page in the documentation. We will use the events (as an array) approach in this project.

So, for the events (as an array) approach, we require an events array which we will pass to the global calendar variable and events will display in the calendar. So we make a wire method call to the ‘getAllMeetingsData’ method. First, import the wire adapter in LWC.

import { LightningElement, wire } from 'lwc';

We make the wire method call with function because we have to change the apex returned list to the JS array list of objects. After variable declaration write @wire and pass method name as argument. We are writing the function name as ‘wiredMeetings’ which takes one argument ‘result’. The ‘result’ variable is a JS object which has two properties ‘data’ and ‘error’. If the wired method is successful in retrieving data from the server ‘data’ variable contains the returned results. If any error occurs in making the call to the server the ‘error’ property contains the description of the error which we will print in the console. Confusing? Read the documentation for the wire method maybe that will help you to have a better understanding of the above explanation. The link is provided above.

Go to the link of Event object documentation to better understand what properties the Fullcalendar.io event object accepts. Then we will convert the Apex returned list of meetings to the array of events objects accepted by the calendar object. To convert we need to iterate over the list of meetings array and pass the Start Date Time to the start property, End Date Time to the end property and Purpose to the title property of the event. Set ID as salesforce record ID.Set allDay property to false and Editable property to true for every event record. Push all of these records into the JS array. We named the JS array as eventList. Create a class-level variable as ‘eventsList’. After processing all the records assign the local eventList list to class-level eventsList variable. Create one more variable ‘dataToRefresh’ and assign it with the value result (We will later use this in the apexRefresh function). Below is the code to help you better understand.

@wire(getAllMeetingsData)
wiredMeetings(result) {
if(result.data) {
const eventList = [];
for(let meeting of result.data) {
const event = {
id: meeting.Id,
editable: true,
allDay : false,
start: meeting.Start_Date_Time__c,
end: meeting.End_Date_Time__c,
title: meeting.Purpose__c
}
eventList.push(event);
}
this.eventsList = eventList;
this.dataToRefresh = result;
} else if(result.error){
console.log(error);
}
}

To show events in the calendar go to the initializeCalendar method inside the calendar option, write a property with the name ‘events’ and assign this value as class-level eventList. (Just one thing to remember, this.eventsList will not work because JS has function scope. Each function creates a new scope. So the calendar options function will have its separate scope and eventsList is a variable outside of its current scope so we have to create a copyOfOuter scope which we can use in the inner function. In short, before the calendar variable declaration, we create a new variable and pass it the value of this. More on the scope and this here). The initializeCalendar method will look like this now.

initializeCalendar() { 
const calendarEl = this.template.querySelector('div.fullcalendar');
const copyOfOuterThis = this;
const calendar = new FullCalendar.Calendar(calendarEl, {
headerToolbar: false,
initialDate: new Date(),
showNonCurrentDates: false,
fixedWeekCount: false,
allDaySlot: false,
navLinks: false,
events: copyOfOuterThis.eventsList
});
calendar.render();
calendar.setOption('contentHeight', 550);
this.calendarTitle = calendar.view.title;
this.calendar = calendar;
}

Deploy the above code, our calendar will look like this.

Custom calendar with events

Let’s make our refresh button work. To make the refresh button work, we need to import the refreshApex method.

import { refreshApex } from '@salesforce/apex';

Create a refreshHandler call the refreshApex and pass dataToRefresh to the refreshApex method. The refreshApex method returns a promise so we write a then statement. In that then statement we call the initializeCalendar method to render the changes made in the events.

refreshHandler() {
refreshApex(this.dataToRefresh)
.then(() => {
this.initializeCalendar();
});
}

And lastly, call this refreshHandler in calendarActionsHandler.

calendarActionsHandler(event) {
const actionName = event.target.value;
if(actionName === 'previous') {
this.calendar.prev();
} else if(actionName === 'next') {
this.calendar.next();
} else if(actionName === 'today') {
this.calendar.today();
} else if(actionName === 'new') {
this.navigateToNewRecordPage(this.objectApiName);
} else if(actionName === 'refresh') {
this.refreshHandler();
}
this.calendarTitle = this.calendar.view.title;
}

This refresh button is important to implement because now you can create a record by clicking on the new button and clicking refresh to show the new data here. Similarly, you can delete a record in the backend and click refresh here to get the updated data.

Add a record through the calendar and copy the start time from the calendar.

We can add the records by clicking on the new button in the actions panel of the calendar. Now, we want to open the new record page of the meeting object with the start date and time data pre-populated with the date we clicked on.

We will go to the documentation of fullcalendar.io to see if any method is present that runs when we click on a certain date block. The ‘dateClick’ method is triggered when the user clicks on a date or a time. We have to override this method in our calendar.

To pre-populate fields in the standard new record form we use encodeDefaultFieldValues

To use ‘encodeDefaultFieldValues’ first import it with the following syntax.

import { encodeDefaultFieldValues } from "lightning/pageReferenceUtils";

Here is what the initializeCalendar method will look like after doing those changes.

initializeCalendar() { 
const calendarEl = this.template.querySelector('div.fullcalendar');
const copyOfOuterThis = this;
const calendar = new FullCalendar.Calendar(calendarEl, {
headerToolbar: false,
initialDate: new Date(),
showNonCurrentDates: false,
fixedWeekCount: false,
allDaySlot: false,
navLinks: false,
events: copyOfOuterThis.eventsList,
dateClick: function(info) {
const defaultValues = encodeDefaultFieldValues({
Start_Date_Time__c: info.dateStr
});
copyOfOuterThis.navigateToNewRecordPage(copyOfOuterThis.objectApiName, defaultValues);
}
});
calendar.render();
calendar.setOption('contentHeight', 550);
this.calendarTitle = calendar.view.title;
this.calendar = calendar;
}

Pass this ‘defaultValues’ variable to the state property in the NavigationMixin method. Here is what the navigateToNewRecordPage method will look like after doing the changes.

navigateToNewRecordPage(objectName, defaultValues) {
if(!defaultValues) {
defaultValues = {};
}
this[NavigationMixin.Navigate]({
type: "standard__objectPage",
attributes: {
objectApiName: objectName,
actionName: "new",
},
state: {
defaultFieldValues: defaultValues
}
});
}

Remove records from the calendar

By clicking on the event record, we will be prompted to ask whether you want to delete the record or not. For this confirm window, we will use lightningConfirm where on the click of the OK button, the window closes and the record gets deleted and on the click of the Cancel button the window closes.

To use lightningConfirm we first have to import LightningConfirm from the lightning/confirm module.

import LightningConfirm from 'lightning/confirm';

Then we have to look for the event click function name in the FullCalendar.io library. The ‘eventClick’ function is fired when we click on an event. We have to provide a handler to the ‘eventClick’ function. In that function, we call our showConfirmWindow function and pass the event property of the info variable. The eventClick code in the calendar will look like this.

eventClick: function(info) {
copyOfOuterThis.showConfirmWindow(info.event);
}

In the showConfirmWindow function first, we will ask the user if the user wants to delete the record. If you go to the documentation page of LightningConfirm you will see that LightningConfirm returns true if the OK button is clicked else it returns false. So we will write an if statement and inside that if statement we will write the code to delete the record.

To delete the record, first, we need to remove the record from the calendar. To remove the record from the calendar first, we need to find the record on the calendar. Luckily Fullcalendar.io provides a method to find the event by ID with the name findEventById which takes the argument as event id. To remove this event from the calendar just call a delete method.

To remove the record from salesforce, we will use the LWC standard delete record from uiRecordAPI. First, import the deleteRecord from uiRecordApi with the following syntax.

import { deleteRecord } from "lightning/uiRecordApi";

Then, continue in the ‘showConfirmWindow’ method after removing the event from the calendar. Call the deleteRecord method and pass the event ID as deleteRecord returns a JS Promise handle it with then and catch. The complete code of the method is shown below.

async showConfirmWindow(event) {
const result = await LightningConfirm.open({
message: 'Are you sure you want to delete this Meeting?',
variant: 'header',
label: 'Delete Meeting',
theme: 'brand'
});

if(result) {
const eventToDelete = this.calendar.getEventById(event.id);
eventToDelete.remove();

deleteRecord(event.id)
.then(() => {
const event = new ShowToastEvent({
title: 'Deleted!',
message: 'Record deleted successfully',
variant: 'success'
});
this.dispatchEvent(event);

})
.catch(error => {
const event = new ShowToastEvent({
title: 'Error occured!',
message: error.message,
variant: 'error'
});
this.dispatchEvent(event);
})
}
}

Now you can create some records and play with the data. If everything is fine up until this point I seriously advise you to create some records for the upcoming section where we will style the events of records.

Styling of Events

If you have created some records now you will see everything is working as expected but the events records in the calendar are not looking good.

To style the event, visit this page. This page contains basic event styling properties. Currently, we will use the ‘eventDisplay’, ‘eventColor’, ‘eventTimeFormat’, ‘eventTextColor’ and ‘dayMaxEventRows’ (not on the above page) properties to style the events. We are setting the following values in the properties. The following properties you have to put in the calendarOptions object. If you don’t remember calendarOptions object read part 1 of this series.

  1. eventDisplay as ‘block’
  2. eventColor as #f36e83
  3. eventTimeFormat as a JS Object with the following values
eventTimeFormat: {
hour: 'numeric',
minute: '2-digit',
omitZeroMinute: true,
meridiem: 'short'
},

4. eventTextColor as RGB(3, 45, 96)

5. dayMaxEventRows as true.

Now after all of this do you guys remember the CustomCalendar.css file which we used to style the calendar in part 2 of this series? We will again use the same file to style the events in the calendar. So we will apply the following styles to the selector and re-upload the same file to the static resource we created in part 2.

.fc-daygrid-event {
display: inline-block;
padding: 0.2em;
width: 90%;
font-size: 13px;
}

.fc-daygrid-event-harness {
display: flex;
justify-content: center;
}

.fc-event-time {
font-weight: normal;
}

.fc-event-title {
font-weight: bold;
}

.fc-daygrid-block-event .fc-event-time {
font-weight: normal;
}

After refreshing a few times the calendar will now look like this.

Custom calendar with styled events

TimeZone issue in the calendar

For some users the time shown in the calendar and the time in the records might be different to resolve this issue please change the class with the following code.

public with sharing class CustomCalendarController {
@AuraEnabled(cacheable=true)
public static List<Meetings__c> getAllMeetingsData() {
List<Meetings__c> meetingsList = [SELECT ID, End_Date_Time__c, Start_Date_Time__c, Purpose__c
FROM Meetings__c];
for(Meetings__c meet: meetingsList) {
meet.End_Date_Time__c = meet.End_Date_Time__c.dateGMT();
meet.Start_Date_Time__c = meet.Start_Date_Time__c.dateGMT();
}
return meetingsList;
}
}

Why we are doing this because Salesforce org automatically converts the DateTime value into the user timezone. So to show the actual value in the calendar we are changing the field values (Which is not the right practice. Instead of doing this, we need to create a wrapper class. Iterate over the meeting records. Create records of that wrapper object and then send that wrapper record list) to GMT value.

Add a timeZone property in the calendarOptions object and set the value as UTC.

So the final snapshot of the code will look like this.

The JS of the custom calendar.

import { LightningElement, wire } from 'lwc';

import FullCalendarJS from '@salesforce/resourceUrl/FullCalendar';
import FullCalendarCustom from '@salesforce/resourceUrl/FullCalendarCustom';
import { loadStyle, loadScript } from 'lightning/platformResourceLoader';
import { NavigationMixin } from "lightning/navigation";
import { refreshApex } from '@salesforce/apex';
import { encodeDefaultFieldValues } from "lightning/pageReferenceUtils";
import LightningConfirm from 'lightning/confirm';
import { deleteRecord } from "lightning/uiRecordApi";
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

import getAllMeetingsData from '@salesforce/apex/CustomCalendarController.getAllMeetingsData';

export default class CustomCalendar extends NavigationMixin(LightningElement) {

calendar;
calendarTitle;
objectApiName = 'Meetings__c';
objectLabel = '';
eventsList = [];
viewOptions = [
{
label: 'Day',
viewName: 'timeGridDay',
checked: false
},
{
label: 'Week',
viewName: 'timeGridWeek',
checked: false
},
{
label: 'Month',
viewName: 'dayGridMonth',
checked: true
},
{
label: 'Table',
viewName: 'listView',
checked: false
}
];

@wire(getAllMeetingsData)
wiredMeetings(result) {
if(result.data) {
const eventList = [];
for(let meeting of result.data) {
const event = {
id: meeting.Id,
editable: true,
allDay : false,
start: meeting.Start_Date_Time__c,
end: meeting.End_Date_Time__c,
title: meeting.Purpose__c
}
eventList.push(event);
}
this.eventsList = eventList;
this.dataToRefresh = result;
} else if(result.error){
console.log(error);
}
}

get buttonLabel() {
return 'New ' + this.objectLabel;
}

changeViewHandler(event) {
const viewName = event.detail.value;
if(viewName != 'listView') {
this.calendar.changeView(viewName);
const viewOptions = [...this.viewOptions];
for(let viewOption of viewOptions) {
viewOption.checked = false;
if(viewOption.viewName === viewName) {
viewOption.checked = true;
}
}
this.viewOptions = viewOptions;
this.calendarTitle = this.calendar.view.title;
} else {
this.handleListViewNavigation(this.objectApiName);
}
}

handleListViewNavigation(objectName) {
this[NavigationMixin.Navigate]({
type: 'standard__objectPage',
attributes: {
objectApiName: objectName,
actionName: 'list'
},
state: {
filterName: 'Recent'
}
});
}

calendarActionsHandler(event) {
const actionName = event.target.value;
if(actionName === 'previous') {
this.calendar.prev();
} else if(actionName === 'next') {
this.calendar.next();
} else if(actionName === 'today') {
this.calendar.today();
} else if(actionName === 'new') {
this.navigateToNewRecordPage(this.objectApiName);
} else if(actionName === 'refresh') {
this.refreshHandler();
}
this.calendarTitle = this.calendar.view.title;
}

navigateToNewRecordPage(objectName, defaultValues) {
if(!defaultValues) {
defaultValues = {};
}
this[NavigationMixin.Navigate]({
type: "standard__objectPage",
attributes: {
objectApiName: objectName,
actionName: "new",
},
state: {
defaultFieldValues: defaultValues
}
});
}

connectedCallback() {
Promise.all([
loadStyle(this, FullCalendarJS + '/lib/main.css'),
loadScript(this, FullCalendarJS + '/lib/main.js'),
loadStyle(this, FullCalendarCustom)
])
.then(() => {
this.initializeCalendar();
})
.catch(error => console.log(error))
}

refreshHandler() {
refreshApex(this.dataToRefresh)
.then(() => {
this.initializeCalendar();
});
}

initializeCalendar() {
const calendarEl = this.template.querySelector('div.fullcalendar');
const copyOfOuterThis = this;
const calendar = new FullCalendar.Calendar(calendarEl, {
headerToolbar: false,
initialDate: new Date(),
timeZone: 'UTC',
showNonCurrentDates: false,
fixedWeekCount: false,
allDaySlot: false,
navLinks: false,
events: copyOfOuterThis.eventsList,
eventDisplay: 'block',
eventColor: '#f36e83',
eventTimeFormat: {
hour: 'numeric',
minute: '2-digit',
omitZeroMinute: true,
meridiem: 'short'
},
dayMaxEventRows: true,
eventTextColor: 'rgb(3, 45, 96)',
dateClick: function(info) {
const defaultValues = encodeDefaultFieldValues({
Start_Date_Time__c: info.dateStr
});
copyOfOuterThis.navigateToNewRecordPage(copyOfOuterThis.objectApiName, defaultValues);
},
eventClick: function(info) {
copyOfOuterThis.showConfirmWindow(info.event);
}
});
calendar.render();
calendar.setOption('contentHeight', 550);
this.calendarTitle = calendar.view.title;
this.calendar = calendar;
}

async showConfirmWindow(event) {
const result = await LightningConfirm.open({
message: 'Are you sure you want to delete this Meeting?',
variant: 'header',
label: 'Delete Meeting',
theme: 'brand'
});

if(result) {
const eventToDelete = this.calendar.getEventById(event.id);
eventToDelete.remove();

deleteRecord(event.id)
.then(() => {
const event = new ShowToastEvent({
title: 'Deleted!',
message: 'Record deleted successfully',
variant: 'success'
});
this.dispatchEvent(event);

})
.catch(error => {
const event = new ShowToastEvent({
title: 'Error occured!',
message: error.message,
variant: 'error'
});
this.dispatchEvent(event);
})
}
}
}

HTML of the custom calendar

<template>
<lightning-card>
<div class="calendar-card">
<div class="slds-theme_shade slds-box">
<div class="slds-grid slds-gutters">
<div class="slds-col slds-size_1-of-3">
<div class="slds-media slds-no-space slds-grow">
<div class="slds-media__figure">
<lightning-icon icon-name="standard:event" size="medium"></lightning-icon>
</div>
<div class="slds-media__body">
<p class="slds-line-height_reset" data-aura-rendered-by="168:1393;a">Calendar</p>
<h1 class="slds-page-header__title slds-m-right_small slds-align-middle slds-truncate" data-aura-rendered-by="170:1393;a">{calendarTitle}</h1>
</div>
</div>
</div>

<div class="slds-col slds-size_2-of-3 slds-clearfix">
<div class="slds-float_right slds-p-top_xx-small">
<lightning-button-icon
variant="border-filled"
icon-name="utility:chevronleft"
value="previous"
onclick={calendarActionsHandler}>
</lightning-button-icon>

<lightning-button-icon
variant="border-filled"
icon-name="utility:chevronright"
value="next"
onclick={calendarActionsHandler}>
</lightning-button-icon>

<lightning-button
label="Today"
class="slds-m-horizontal_small"
value="today"
onclick={calendarActionsHandler}>
</lightning-button>

<lightning-button-icon
variant="border-filled"
icon-name="utility:refresh"
class="slds-m-left_medium"
value="refresh"
onclick={calendarActionsHandler}>
</lightning-button-icon>

<lightning-button-menu alternative-text="Show menu" variant="border-filled"
icon-name="utility:event" class="slds-m-horizontal_small"
menu-alignment="auto" onselect={changeViewHandler}>
<template for:each={viewOptions} for:item="menuItem">
<lightning-menu-item
key={menuItem.viewName}
value={menuItem.viewName}
label={menuItem.label}
checked={menuItem.checked}>
</lightning-menu-item>
</template>
</lightning-button-menu>

<lightning-button
label={buttonLabel}
class="slds-m-horizontal_small"
value="new"
onclick={calendarActionsHandler}>
</lightning-button>
</div>
</div>
</div>
</div>
</div>
<div class="fullcalendar"></div>
</lightning-card>
</template>

CustomCalendar.css (file used to style the calendar)

.fc-col-header-cell .fc-scrollgrid-sync-inner {
padding: 8px;
}

.fc-col-header-cell-cushion, .fc-daygrid-day-number {
text-decoration: none;
color: rgb(24, 24, 24);
font-weight: normal;
text-transform: uppercase;
}

.fc-col-header-cell-cushion:hover, .fc-daygrid-day-number:hover {
text-decoration: none;
cursor: none;
color: rgb(24, 24, 24);
}

.fc-daygrid-event {
display: inline-block;
padding: 0.2em;
width: 90%;
font-size: 13px;
}

.fc-daygrid-event-harness {
display: flex;
justify-content: center;
}

.fc-event-time {
font-weight: normal;
}

.fc-event-title {
font-weight: bold;
}

.fc-daygrid-block-event .fc-event-time {
font-weight: normal;
}

Apex class for retrieving the events

public with sharing class CustomCalendarController {
@AuraEnabled(cacheable=true)
public static List<Meetings__c> getAllMeetingsData() {
List<Meetings__c> meetingsList = [SELECT ID, End_Date_Time__c, Start_Date_Time__c, Purpose__c
FROM Meetings__c];
for(Meetings__c meet: meetingsList) {
meet.End_Date_Time__c = meet.End_Date_Time__c.dateGMT();
meet.Start_Date_Time__c = meet.Start_Date_Time__c.dateGMT();
}
return meetingsList;
}
}

And with this, we are finishing this project but you guys can enhance it however you like.

  1. Can add a small calendar as the sidebar similar to the salesforce calendar to navigate to any date.
  2. Add a tooltip in events to show the event details.
  3. Create a flow to publish a platform event and our LWC will listen to it. So whenever a record gets added our components automatically re-renders the events.
  4. Make it more customizable so that users can select object names and the calendar is ready for them.

The scope is endless. Just keep building and please ask your queries in the comment section here. I am sharing the entire code on my GitHub. The link to GitHub will be provided in the comments. If you want part 4 of this project with the above features please tell that also in the comments. If you like this content please share it with your friends. Thank you for your support in the last two parts. If you have project ideas you can share them in the comments or email me at rohittanwani61@gmail.com.

--

--

Rohit Tanwani

Consultant at Deloitte USI | Salesforce Developer | Cinephile