Overview
The open-source Angular Calendar component from DayPilot Lite package supports drag and drop user actions for scheduling events.
In this tutorial, you will learn how to extend the Calendar component to support Undo/Redo operations.
The undo/redo services maintains a complete multi-level undo/redo history.
No server-side implementation is needed.
Available under a business-friendly open-source license (Apache License 2.0).
Download a complete TypeScript source code of the Angular Calendar application.
To let you see how undo/redo works, the app shows a complete history that is maintained by the Calendar and the current position.
License
Apache License 2.0
Angular Calendar Component with Drag and Drop Support
Let’s create a simple Angular Calendar component configuration using the open-source DayPilot Lite library.
In this example, we have a weekly view configured by setting viewType
to "Week"
.
The Calendar component will support the following UI actions:
Drag and drop event moving and resizing actions are enabled by default.
By setting
timeRangeSelectedHandling
to"Enabled"
, we allow users to create new calendar events when they selects a time range using drag and drop.To enable event deleting, we have set
eventDeleteHandling
to"Update"
. This tells the calendar to show a “delete” icon in the upper-right corner of calendar events.
We have assigned an async function to onTimeRangeSelected
which will be called when the user selects a time range. This function will show a modal dialog box using DayPilot.Modal.prompt
and ask the user to create a new event. Once the user has entered a name for the event and clicks "Ok", we create a new event object with the start and end time of the selected time range and the user-entered name. We add this new event to the calendar using calendar.events.add(data)
.
Additionally, we have event handlers assigned to onEventMoved
, onEventResized
and onEventDeleted
which will be called when an event is moved, resized or deleted.
You can also create a custom configuration (and generate a new Angular project) using the visual UI Builder tool.
calendar.component.html
<daypilot-calendar [config]="config" #calendar></daypilot-calendar>
calendar.component.ts
@Component({
selector: 'calendar-component',
templateUrl: './calendar.component.html',
styleUrls: ['./calendar.component.css'],
})
export class CalendarComponent implements AfterViewInit {
@ViewChild("calendar") calendar!: DayPilotCalendarComponent;
config: DayPilot.CalendarConfig = {
viewType: "Week",
timeRangeSelectedHandling: "Enabled",
onTimeRangeSelected: async args => {
const modal = await DayPilot.Modal.prompt("Create a new event:", "Event 1");
const calendar = args.control;
calendar.clearSelection();
if (modal.canceled) {
return;
}
const data = {
start: args.start,
end: args.end,
id: DayPilot.guid(),
resource: args.resource,
text: modal.result
};
calendar.events.add(data);
},
onEventMoved: args => { },
onEventResized: args => { },
eventDeleteHandling: "Update",
onEventDeleted: args => { }
};
// ...
}
Steps to Implement Undo/Redo in the Calendar
To implement undo/redo, the following steps are necessary:
Synchronize the initial calendar event with the
UndoService
set when loading event data.Record all user actions (adding a new event, moving an event, deleting an event).
Add “Undo” and “Redo” buttons that will undo/redo the changes and move the current position in the action history.
Enable Undo/Redo for the Calendar (Initialization)
When loading a new set of events to be displayed by the Calendar component, it is necessary to initialize the UndoService
. The UndoService
maintains a complete history of all changes performed by the user. To handle the changes properly, it needs to know the initial state of the calendar data. In this step, we will learn how to initialize the UndoService
when loading calendar events from the server-side API endpoint.
First, we inject the UndoService
as this.undoService
in the CalendarComponent
constructor:
constructor(public undoService: UndoService) {}
In the Angular ngAfterViewInit
event handler, we fetch the calendar event data using a ds
service. The fetched events are then passed to the calendar component using the update() method.
This call replaces the events currently loaded in the Calendar. In order to synchronize the state of the UndoService
with events loaded in the calendar component, we need to initialize the undoService
using the initial data set.
ngAfterViewInit(): void {
const from = this.calendar.control.visibleStart();
const to = this.calendar.control.visibleEnd();
this.ds.getEvents(from, to).subscribe(events => {
this.calendar.control.update({events});
this.undoService.initialize(events);
});
}
Record All User Actions using the UndoService
Inside the onTimeRangeSelected
event handler, the user has a chance to enter new calendar events details. Then, we add the new event to the calendar using the events.update() method. In a real application with a server-side backend, you would also notify the server about the new event to store it in the database. To record the change with the UndoServer, we need to call this.undoService.add()
.
The onEventMoved
and onEventResized
event handlers call this.undoService.update()
with the event data.
Finally, we record information about the deleted event using this.underService.remove()
in the the onEventDeleted
event handler.
All UndoService
methods that record calendar event changes also accept an optional description as the second parameter. The description will be part of the undo/redo history.
config: DayPilot.CalendarConfig = {
viewType: "Week",
timeRangeSelectedHandling: "Enabled",
onTimeRangeSelected: async args => {
const modal = await DayPilot.Modal.prompt("Create a new event:", "Event 1");
const calendar = args.control;
calendar.clearSelection();
if (modal.canceled) {
return;
}
const data = {
start: args.start,
end: args.end,
id: DayPilot.guid(),
resource: args.resource,
text: modal.result
};
calendar.events.add(data);
this.undoService.add(data, "Event created.");
},
onEventMoved: args => {
this.undoService.update(args.e.data, "Event moved.");
},
onEventResized: args => {
this.undoService.update(args.e.data, "Event resized.");
},
eventDeleteHandling: "Update",
onEventDeleted: args => {
this.undoService.remove(args.e.data, "Event deleted.");
}
}
The “Undo” Button
Now we can add the “Undo” button using a <button>
element.
calendar.component.html
<button (click)="undoButtonClick()" [disabled]="!undoService.canUndo">Undo</button>
The undoButtonClick()
method calls the undo()
method of the UndoService
.
The undo()
method retrieves the last action record, returns it and changes the current position in the history.
Depending on the record type ("add"
, "remove"
, "update"
), we make changes to the Calendar UI:
When the record type is
"add"
, we remove the event from the Calendar.When the record type is
"remove"
, we re-add the event that was removed (it is stored in therecord.previous
property).When the record type is
"update"
, we restore the the calendar event state from the previous version (record.previous
).
undoButtonClick(): void {
const record: HistoryRecord = this.undoService.undo();
switch (record.type) {
case "add":
this.calendar.control.events.remove(record.id);
break;
case "remove":
this.calendar.control.events.add(<EventData>record.previous);
break;
case "update":
this.calendar.control.events.update(<EventData>record.previous);
break;
}
}
The HistoryRecord
interface describes the record returned by the undo()
method:
export interface HistoryRecord {
id: string | number;
time: DayPilot.Date;
previous: Item | null;
current: Item | null;
text: string;
type: string;
}
The HistoryRecord
stores the event id, type and time of the change, previous and current versions of the event data object, and the optional description of the user action.
The “Redo” Button
The “Redo” button works in a similar way.
calendar.component.html
<button (click)="redoButtonClick()" [disabled]="!undoService.canRedo">Redo</button>
The redoButtonClick()
method calls redo()
method of the UndoService which returns the details of the next record in the action history.
The record.type
property holds the action type which will helps us decide what needs to be done to repeat the action:
redoButtonClick(): void {
const record: HistoryRecord = this.undoService.redo();
switch (record.type) {
case "add":
// added, need to re-add
this.calendar.control.events.add(<EventData>record.current);
break;
case "remove":
// removed, need to remove again
this.calendar.control.events.remove(record.id);
break;
case "update":
// updated, use the new version
this.calendar.control.events.update(<EventData>record.current);
break;
}
}
Disabling the Undo/Redo Buttons Depending on Availability
Note that we use the canUndo
and canRedo
getters of the UndoService
to determine the availability of of undo/redo actions.
If the actions is not supported (it depends on the state of the undo/redo history and the current position) we mark the button as disabled.
A disabled button will be displayed in gray color.
CSS
button {
display: inline-block;
text-align: center;
background-color: #3c78d8;
border: 1px solid #1155cc;
color: #fff;
padding: 6px 20px;
border-radius: 2px;
cursor: pointer;
margin-right: 5px;
text-decoration: none;
}
button:disabled {
background-color: #666;
border-color: #333;
color: #ccc;
}
Angular Undo/Redo Service Implementation
This is the TypeScript source code of the UndoService
class that provides functionality to undo and redo changes made to a list of calendar events. It has methods to initialize the list, add a new item, update an existing item, and remove an item. Each of these methods returns a HistoryRecord
, which contains information about the change made.
The class also includes properties to keep track of the list of calendar events, the history of changes, and the current position in the history. There are getter methods to check if undo or redo is possible, based on the current position in the history.
The methods that update or remove items check if the item exists in the list and if it has been modified since it was last saved. If an error is encountered, an error message is thrown.
import {Injectable} from '@angular/core';
import {DayPilot} from "@daypilot/daypilot-lite-angular";
@Injectable()
export class UndoService {
private _items: any;
private _history: HistoryRecord[] = [];
get history(): HistoryRecord[] {
return this._history;
}
private _position: number = 0;
get position(): number {
return this._position;
}
get canUndo(): boolean {
return this._position > 0;
}
get canRedo(): boolean {
return this._position < this._history.length;
}
initialize(items: Item[]): void {
// deep copy using JSON serialization/deserialization
this._items = [];
items.forEach(i => {
let str = JSON.stringify(i);
let key = this.keyForItem(i);
if (this._items[key]) {
throw "Duplicate IDs are not allowed.";
}
this._items[key] = str;
});
this._history = [];
}
update(item: Item, text?: string): HistoryRecord {
let key = this.keyForItem(item);
let stringified = JSON.stringify(item);
if (!this._items[key]) {
throw "The item to be updated was not found in the list.";
}
if (this._items[key] === stringified) {
throw "The item to be updated has not been modified.";
}
let record: HistoryRecord = {
id: item.id,
time: new DayPilot.Date(),
previous: JSON.parse(this._items[key]),
current: JSON.parse(stringified),
text: text || "",
type: "update"
};
this._items[key] = stringified;
this.addToHistory(record);
return record;
}
add(item: Item, text?: string): HistoryRecord {
let key = this.keyForItem(item);
if (this._items[key]) {
throw "Item is already in the list";
}
let record: HistoryRecord = {
id: item.id,
time: new DayPilot.Date(),
previous: null,
current: item,
text: text || "",
type: "add"
};
this._items[key] = JSON.stringify(item);
this.addToHistory(record);
return record;
}
remove(item: Item, text?: string): HistoryRecord {
let key = this.keyForItem(item);
if (!this._items[key]) {
throw "The item to be removed was not found in the list.";
}
if (this._items[key] !== JSON.stringify(item)) {
throw "The item to be removed has been modified.";
}
let record: HistoryRecord = {
id: item.id,
time: new DayPilot.Date(),
previous: item,
current: null,
text: text || "",
type: "remove"
};
this._items[key] = null;
this.addToHistory(record);
return record;
}
undo(): HistoryRecord {
if (!this.canUndo) {
throw "Can't undo";
}
this._position -= 1;
let record = this._history[this._position];
let key = this.keyForId(record.id);
switch (record.type) {
case "add":
this._items[key] = null;
break;
case "remove":
this._items[key] = JSON.stringify(record.previous);
break;
case "update":
this._items[key] = JSON.stringify(record.previous);
break;
}
return record;
}
redo(): HistoryRecord {
if (!this.canRedo) {
throw "Can't redo";
}
let record = this._history[this._position];
this._position += 1;
let key = this.keyForId(record.id);
switch (record.type) {
case "add":
this._items[key] = JSON.stringify(record.current);
break;
case "remove":
this._items[key] = null;
break;
case "update":
this._items[key] = JSON.stringify(record.current);
break;
}
return record;
}
private keyForItem(item: Item): string {
return this.keyForId(item.id);
}
private keyForId(id: string | number): string {
return "_" + id;
}
private addToHistory(record: HistoryRecord): void {
while (this.canRedo) {
this._history.pop();
}
this._history.push(record);
this._position += 1;
}
}
The Item
interface defines the objects that will be tracked (calendar events). At minimum, the tracked objects must have an id
property that stores a unique item number. The tracked objects must be serializable using JSON.stingify()
method.
export interface Item {
id: string | number;
}
Full Calendar Component Source Code
You can also find the source code in the attached Angular project.
import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotCalendarComponent} from "@daypilot/daypilot-lite-angular";
import {DataService} from "./data.service";
import {HistoryRecord, UndoService} from "./undo.service";
import EventData = DayPilot.EventData;
@Component({
selector: 'calendar-component',
templateUrl: './calendar.component.html',
styleUrls: ['./calendar.component.css'],
})
export class CalendarComponent implements AfterViewInit {
@ViewChild("calendar")
calendar!: DayPilotCalendarComponent;
events: any[] = [];
config: DayPilot.CalendarConfig = {
viewType: "Week",
timeRangeSelectedHandling: "Enabled",
onTimeRangeSelected: async args => {
const modal = await DayPilot.Modal.prompt("Create a new event:", "Event 1");
const calendar = args.control;
calendar.clearSelection();
if (modal.canceled) {
return;
}
const data = {
start: args.start,
end: args.end,
id: DayPilot.guid(),
resource: args.resource,
text: modal.result
};
calendar.events.add(data);
this.undoService.add(data, "Event created.");
},
onEventMoved: args => {
this.undoService.update(args.e.data, "Event moved.");
},
onEventResized: args => {
this.undoService.update(args.e.data, "Event resized.");
},
eventDeleteHandling: "Update",
onEventDeleted: args => {
this.undoService.remove(args.e.data, "Event deleted.");
}
};
constructor(private ds: DataService, public undoService: UndoService) {
}
ngAfterViewInit(): void {
const from = this.calendar.control.visibleStart();
const to = this.calendar.control.visibleEnd();
this.ds.getEvents(from, to).subscribe(events => {
this.calendar.control.update({events});
this.undoService.initialize(events);
});
}
undoButtonClick(): void {
let record: HistoryRecord = this.undoService.undo();
switch (record.type) {
case "add":
// added, need to delete now
this.calendar.control.events.remove(record.id);
break;
case "remove":
// removed, need to add now
this.calendar.control.events.add(<EventData>record.previous);
break;
case "update":
// updated
this.calendar.control.events.update(<EventData>record.previous);
break;
}
}
redoButtonClick(): void {
const record: HistoryRecord = this.undoService.redo();
switch (record.type) {
case "add":
// added, need to re-add
this.calendar.control.events.add(<EventData>record.current);
break;
case "remove":
// removed, need to remove again
this.calendar.control.events.remove(record.id);
break;
case "update":
// updated, use the new version
this.calendar.control.events.update(<EventData>record.current);
break;
}
}
}