Features
Client-side implementation of undo/redo queue for Angular Scheduler component from DayPilot Pro for JavaScript.
Unlimited undo/redo steps
Displays the action history and current position
Supports event creating, moving, resizing, and deleting
License
Licensed for testing and evaluation purposes. Please see the license agreement included in the sample project. You can use the source code of the tutorial if you are a licensed user of DayPilot Pro for JavaScript.
Project Initialization
In order to download the dependencies (node_modules
) it's necessary to initialize the project using npm:
npm install
Running the Angular Project
You can run the project using npm
:
npm run start
This command will start an embedded web server at http://localhost:4200
.
Angular Scheduler Configuration
The installation and configuration of the Angular Scheduler component is not covered by this tutorial. For an introduction and a boilerplate Angular project please see the following tutorial:
Scheduler Undo Service API
This project includes UndoService
class that provides undo/redo queue for tracking event/appointment changes in the Scheduler control.
However, the implementation is generic and can be used for any kind of object that meets the following conditions:
the object has an
id
property with type of number or stringthe objects is serializable using
JSON.stringify()
initialize(items: Item[]): void
Initializes the UndoService instance with the initial state of all events. It's necessary to initialize the Undo service before registering user actions.
add(item: Item, text?: string): HistoryItem
Registers an "add" action.
update(item: Item, text?: string): HistoryItem
Registers an "update" action.
remove(item: Item, text?: string): HistoryItem
Registers a "remove" action.
get canUndo(): boolean
Returns true if "undo" is possible. You can use this property to enable/disable the Undo button.
get canRedo(): boolean
Returns true if "redo" is possible. You can use this property to enable/disable the Redo button.
undo(): HistoryItem
Performs "undo" on the internal state and returns HistoryItem object with action details so the action can be reverted.
redo(): HistoryItem
Performs "redo" on the internal state and returns HistoryItem object with actions details so the action can be replayed.
get history(): HistoryItem[]
Returns the full undo/redo queue with action details.
get position(): number
Gets the current position in the undo/redo queue.
Using Undo Service with Angular Scheduler Component
When starting with a project generated using DayPilot UI Builder we already have a configured Scheduler component (SchedulerComponent
in src/app/scheduler/scheduler.component.ts
). Now we can modify this component to add the undo/redo functionality.
1. First, we need an instance of UndoService
:
export class SchedulerComponent implements AfterViewInit {
constructor(/*... */, public us: UndoService) {
console.log("UndoService injected as this.us");
}
// ...
}
2. The UndoService
needs to be initialized with the initial event set. As soon as we receive the event array from the DataServic
e we load the events in the Scheduler and initialize the UndoService
:
export class SchedulerComponent implements AfterViewInit {
ngAfterViewInit(): void {
// ...
var from = this.scheduler.control.visibleStart();
var to = this.scheduler.control.visibleEnd();
this.ds.getEvents(from, to).subscribe(result => {
this.events = result;
this.us.initialize(this.events);
});
}
// ...
}
3. Now we need to register all changes made to the events. We will do this by adding the UndoService action registration calls to the Scheduler events handlers (onTimeRangeSelected, onEventMoved, onEventResized, onEventDeleted):
export class SchedulerComponent implements AfterViewInit {
// ...
config: SchedulerConfig = {
// ...
timeRangeSelectedHandling: "Enabled",
onTimeRangeSelected: args => {
let component = this;
DayPilot.Modal.prompt("Create a new event:", "Event 1").then(function(modal) {
let dp = args.control;
dp.clearSelection();
if (!modal.result) { return; }
let data = {
start: args.start,
end: args.end,
id: DayPilot.guid(),
resource: args.resource,
text: modal.result
};
dp.events.add(new DayPilot.Event(data));
component.us.add(data, "Event created.");
});
},
onEventMoved: args => {
this.us.update(args.e.data, "Event moved.");
},
onEventResized: args => {
this.us.update(args.e.data, "Event resized.");
},
eventDeleteHandling: "Update",
onEventDeleted: args => {
this.us.remove(args.e.data, "Event deleted.");
}
};
// ...
}
When calling the registration methods [add()
, update()
, remove()
] we provide the new event state (that's why we are using event handlers that are called after the default action, e.g. onEventMoved
instead of onEventMove
) and also a text that describes the action. The text is stored in the history and we can use it to provide more details to users.
As the next steps, we will add the Undo and Redo buttons and History to the template.
How to Add “Undo” Button
HTML Template
<button (click)="undo()" [disabled]="!us.canUndo">Undo</button>
TypeScript
undo(): void {
let item: HistoryItem = this.us.undo();
switch (item.type) {
case "add":
// added, need to delete now
this.scheduler.control.events.remove(item.id);
break;
case "remove":
// removed, need to add now
this.scheduler.control.events.add(<EventData> item.previous);
break;
case "update":
// updated
this.scheduler.control.events.update(<EventData> item.previous);
break;
}
}
The undo()
method calls the UndoService.undo()
method to update the internal state and to get information about the last action.
The HistoryItem
object holds information about the event id, a copy of the previous object state, a copy of the new object state. We use the previous state to revert the change.
How to Add “Redo” Button
HTML Template
<button (click)="redo()" [disabled]="!us.canRedo">Redo</button>
TypeScript
redo(): void {
let item: HistoryItem = this.us.redo();
switch (item.type) {
case "add":
// added, need to re-add
this.scheduler.control.events.add(<EventData> item.current);
break;
case "remove":
// removed, need to remove again
this.scheduler.control.events.remove(item.id);
break;
case "update":
// updated, use the new version
this.scheduler.control.events.update(<EventData> item.current);
break;
}
}
The redo()
method calls the UndoService.redo()
method to update the internal state and to get information about the last undo.
The HistoryItem
object holds information about the event id, a copy of the previous object state, a copy of the new object state. We use the new object state to replay the change.
Scheduler Undo/Redo History
As a visual hint, this project also displays the current undo/redo queue and the current position.
HTML
<h2>History</h2>
<div *ngFor="let item of us.history; let i = index" [class.highlighted]="i === us.position" class="history-item">{{item.time}}: {{item.text}} (ID: {{item.id}})</div>
<div [class.highlighted]="us.history.length && us.position === us.history.length"></div>
CSS
.history-item {
margin-bottom: 10px;
}
.highlighted {
position: relative;
}
.highlighted::before {
content: "position";
position: absolute;
width: 60px;
height: 1px;
top: -10px;
border-top: 2px solid red;
color: red;
font-size: 8px;
}
Undo Service Source Code
Here is the source code of the UndoService
class which provides the undo/redo functionality for the Scheduler component.
undo.service.ts
import {Injectable} from '@angular/core';
import {DayPilot} from "daypilot-pro-angular";
@Injectable()
export class UndoService {
private _items: any;
private _history: HistoryItem[] = [];
get history(): HistoryItem[] {
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): HistoryItem {
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: HistoryItem = {
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): HistoryItem {
let key = this.keyForItem(item);
if (this._items[key]) {
throw "Item is already in the list";
}
let record: HistoryItem = {
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): HistoryItem {
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: HistoryItem = {
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(): HistoryItem {
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(): HistoryItem {
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: HistoryItem): void {
while (this.canRedo) {
this._history.pop();
}
this._history.push(record);
this._position += 1;
}
}
export interface HistoryItem {
id: string | number;
time: DayPilot.Date;
previous: Item | null;
current: Item | null;
text: string;
type: string;
active?: boolean;
}
export interface Item {
id: string | number;
}