Features
JavaScript Scheduler with client-side undo/redo functionality
The universal
UndoService
stores history of all changes and providesundo()
andredo()
functionsThe application displays the full change history and the current position for demonstration purposes
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.
JavaScript Scheduler Component
First, we need to add the JavaScript scheduler component to our application. The Scheduler configuration (including the initial HTML5/JavaScript source code) was generated using Scheduler UI builder - the online JavaScript Scheduler configurator.
The common drag and drop operations (creating, moving, resizing) are enabled by default. We need to enable event deleting manually (using eventDeleteHandling
property).
Note that the default event handlers (onTimeRangeSelected
, onEventMoved
, onEventResized
, onEventDeleted
) are modified to call app.service
methods which record the action in the undo history. The app.service
property holds an instance of UndoService
which provides the undo functionality - see the API description below.
var dp = new DayPilot.Scheduler("dp", {
timeHeaders: [{"groupBy":"Month"},{"groupBy":"Day","format":"d"}],
scale: "Day",
days: DayPilot.Date.today().daysInMonth(),
startDate: DayPilot.Date.today().firstDayOfMonth(),
eventHeight: 40,
headerHeight: 30,
onTimeRangeSelected: function (args) {
var dp = this;
DayPilot.Modal.prompt("Create a new event:", "Event 1").then(function(modal) {
dp.clearSelection();
if (!modal.result) { return; }
var data = {
start: args.start,
end: args.end,
id: DayPilot.guid(),
resource: args.resource,
text: modal.result
};
dp.events.add(new DayPilot.Event(data));
app.service.add(data, "New event created");
app.updateHistory();
});
},
onEventMoved: function (args) {
app.service.update(args.e.data, "Event moved");
app.updateHistory();
},
onEventResized: function (args) {
app.service.update(args.e.data, "Event moved");
app.updateHistory();
},
eventDeleteHandling: "Update",
onEventDeleted: function (args) {
app.service.remove(args.e.data, "Event removed");
app.updateHistory();
}
});
dp.resources = [
{name: "Resource 1", id: "R1"},
{name: "Resource 2", id: "R2"}
];
dp.events.list = [];
dp.init();
Undo Service API
The main undo/redo logic is implemented in UndoService
class (daypilot-undo.js
in the project root). It's a generic implementation that works in other scenarios as well. However, we are using it in connection with the Scheduler to record and undo/redo drag and drop actions performed by the users.
Notes:
In this application, the data items are raw event data objects (DayPilot.Event.data). The items contain properties like
id
,start
,end
,resource
,text
to describe the event. However, theUndoService
only requires theid
property (string
|number
).The data items must be serializable using
JSON.stringify()
.
initialize(items)
The initialize()
method clears the internal state and stores the initial event set (items).
The items parameter is an array of raw event data items ().
add(item[, text])
The add()
method records an "add" action for the specified item.
update(item[, text])
The update()
method records an "update" action for the specified item.
remove(item[, text])
The remove()
method record an "remove" action for the specified item.
undo()
The undo()
method performs an undo. It returns an object with the last action details. The action object has the following structure:
id
- item id (string
|number
)time
- action time (DayPilot.Date
object)previous
- previous object state (object
|null
)current
- current object state (object
|null
)text
- text description of the action (string
)type
- action type ("add"
|"remove"
|"update"
)
redo()
The redo()
method performs a redo. It returns an object with the last action details.
position
A property that returns the current position in the action history.
history
A property that returns an array of action objects.
canUndo
A property that returns true
if the current state allows "undo" action.
canRedo
A property that returns true
if the current state allows "redo" action.
Examples
Initialization (empty event set)
var service = new UndoService();
Initialization (loading the starting event set):
var service = new UndoService();
service.initialize([{id: 1, start: "2021-08-01T00:00:00", end: "2021-08-02T00:00:00", text: "Event 1", resource: "R1"}]);
Recording a "new event" action:
var data = {id: 2, start: "2021-08-03T00:00:00", end: "2021-08-05T00:00:00", text: "Event 2", resource: "R1"};
service.add(data);
Recording a "new event" action, with a description:
var data = {id: 2, start: "2021-08-03T00:00:00", end: "2021-08-05T00:00:00", text: "Event 2", resource: "R1"};
service.add(data, "Event 2 created");
Performing an undo:
var action = service.undo();
/* action object:
{
id: 2,
time: "2021-07-29T23:15:35.594"
previous: null,
current: {"id":2,"start":"2021-08-03T00:00:00","end":"2021-08-05T00:00:00","text":"Event 2","resource":"R1"},
text: "Event 2 created",
type: "add"
}
*/
You can use the type
and previous
properties to get the information required to revert the action. That can be done using the JavaScript Scheduler API. Read on to see the actual implementation ("Undo Button" and "Redo Button" sections).
Undo Button
Undo button reference:
elements: {
undo: document.getElementById("undo"),
// ...
},
Defining a click event handler:
this.elements.undo.addEventListener("click", function() {
app.undo();
app.updateHistory();
});
app.undo() implementation:
undo: function() {
var item = this.service.undo();
switch (item.type) {
case "add":
// added, need to delete now
this.dp.events.remove(item.id);
break;
case "remove":
// removed, need to add now
this.dp.events.add(item.previous);
break;
case "update":
// updated
this.dp.events.update(item.previous);
break;
}
},
Updating the state (enabled/disabled) - checked after every action:
this.elements.undo.disabled = !this.service.canUndo;
Redo Button
Redo button reference:
elements: {
redo: document.getElementById("redo"),
// ...
},
Defining the click event handler:
this.elements.redo.addEventListener("click", function() {
app.redo();
app.updateHistory();
});
app.redo()
implementation:
redo: function() {
var item = this.service.redo();
switch (item.type) {
case "add":
// added, need to re-add
this.dp.events.add(item.current);
break;
case "remove":
// removed, need to remove again
this.dp.events.remove(item.id);
break;
case "update":
// updated, use the new version
this.dp.events.update(item.current);
break;
}
}
Undo History
The history is stored in UndoService.history
property. It's an array of action objects that hold details about every action performed by the user.
You can use it to display a list of actions and the current position:
updateHistory: function() {
var items = this.service.history;
this.elements.history.innerHTML = '';
for(var i = 0; i < items.length; i++) {
var item = document.createElement("div");
item.innerHTML = items[i].type + ": " + items[i].text;
item.className = "history-item";
if (i === this.service.position) {
item.className += " highlighted";
}
this.elements.history.appendChild(item);
}
if (this.service.history.length && this.service.position === this.service.history.length) {
var last = document.createElement("div");
last.className = "highlighted";
this.elements.history.appendChild(last);
}
this.elements.undo.disabled = !this.service.canUndo;
this.elements.redo.disabled = !this.service.canRedo;
}
App Source Code
var app = {
elements: {
undo: document.getElementById("undo"),
redo: document.getElementById("redo"),
history: document.getElementById("history")
},
service: new UndoService(),
dp: dp,
init: function() {
this.elements.undo.addEventListener("click", function() {
app.undo();
app.updateHistory();
});
this.elements.redo.addEventListener("click", function() {
app.redo();
app.updateHistory();
});
this.updateHistory();
},
undo: function() {
var item = this.service.undo();
switch (item.type) {
case "add":
// added, need to delete now
this.dp.events.remove(item.id);
break;
case "remove":
// removed, need to add now
this.dp.events.add(item.previous);
break;
case "update":
// updated
this.dp.events.update(item.previous);
break;
}
},
redo: function() {
var item = this.service.redo();
switch (item.type) {
case "add":
// added, need to re-add
this.dp.events.add(item.current);
break;
case "remove":
// removed, need to remove again
this.dp.events.remove(item.id);
break;
case "update":
// updated, use the new version
this.dp.events.update(item.current);
break;
}
},
updateHistory: function() {
var items = this.service.history;
this.elements.history.innerHTML = '';
for(var i = 0; i < items.length; i++) {
var item = document.createElement("div");
item.innerHTML = items[i].type + ": " + items[i].text;
item.className = "history-item";
if (i === this.service.position) {
item.className += " highlighted";
}
this.elements.history.appendChild(item);
}
if (this.service.history.length && this.service.position === this.service.history.length) {
var last = document.createElement("div");
last.className = "highlighted";
this.elements.history.appendChild(last);
}
this.elements.undo.disabled = !this.service.canUndo;
this.elements.redo.disabled = !this.service.canRedo;
}
};
app.init();
Undo Service Source Code
var UndoService = function() {
this._items = {};
this._history = [];
this._position = 0;
this.initialize = function(items) {
// 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 = [];
};
this._keyForItem = function(item) {
return this._keyForId(item.id);
};
this._keyForId = function(id) {
return "_" + id;
};
this._addToHistory = function(record) {
while (this.canRedo) {
this._history.pop();
}
this._history.push(record);
this._position += 1;
};
this.update = function(item, text) {
var key = this._keyForItem(item);
var 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.";
}
var record = {
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;
};
this.add = function(item, text) {
var key = this._keyForItem(item);
if (this._items[key]) {
throw "Item is already in the list";
}
var record = {
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;
};
this.remove = function(item, text) {
var 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.";
}
var record = {
id: item.id,
time: new DayPilot.Date(),
previous: item,
current: null,
text: text,
type: "remove"
};
this._items[key] = null;
this._addToHistory(record);
return record;
};
this.undo = function() {
if (!this.canUndo) {
throw "Can't undo";
}
this._position -= 1;
var record = this._history[this._position];
var 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;
};
this.redo = function() {
if (!this.canRedo) {
throw "Can't redo";
}
var record = this._history[this._position];
this._position += 1;
var 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;
};
Object.defineProperties(this, {
canUndo: {
get: function() {
return this._position > 0;
}
},
canRedo: {
get: function() {
return this._position < this._history.length;
}
},
position: {
get: function() {
return this._position;
}
},
history: {
get: function() {
return this._history;
}
}
});
};