Features

  • JavaScript Scheduler with client-side undo/redo functionality
  • Universal UndoService stores history of all changes and provides undo() and redo() functions
  • The application displays the full change history and 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. Buy a license.

JavaScript Scheduler Component

javascript-scheduler-undo-redo-scheduler-component.png

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, the UndoService only requires the "id" property (string | number).
  • The data items must be serializable using JSON.stringify()

initialize(items)

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 record an "add" action for the specified item.

update(item[, text])

The add() method record an "update" action for the specified item.

remove(item[, text])

The add() 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 red() method performs a redo. It returns an object with the last action details. The action object has the following structure:

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: "2018-08-01T00:00:00", end: "2018-08-02T00:00:00", text: "Event 1", resource: "R1"}]);

Recording a "new event" action:

var data = {id: 2, start: "2018-08-03T00:00:00", end: "2018-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: "2018-08-03T00:00:00", end: "2018-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: "2018-07-29T23:15:35.594"
  previous: null,
  current: {"id":2,"start":"2018-08-03T00:00:00","end":"2018-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

javascript-scheduler-undo-redo-undo-button.png

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.removeById(item.id);
      break;
    case "remove":
      // removed, need to add now
      this.dp.events.addByData(item.previous);
      break;
    case "update":
      // updated
      this.dp.events.updateByData(item.previous);
      break;
  }

},

Updating the state (enabled/disabled) - checked after every action:

this.elements.undo.disabled = !this.service.canUndo;

Redo Button

javascript-scheduler-undo-redo-undo-button.png

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.addByData(item.current);
      break;
    case "remove":
      // removed, need to remove again
      this.dp.events.removeById(item.id);
      break;
    case "update":
      // updated, use the new version
      this.dp.events.updateByData(item.current);
      break;
  }
}

Undo History

javascrpit-scheduler-undo-redo-history.png

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.removeById(item.id);
        break;
      case "remove":
        // removed, need to add now
        this.dp.events.addByData(item.previous);
        break;
      case "update":
        // updated
        this.dp.events.updateByData(item.previous);
        break;
    }

  },
  redo: function() {
    var item = this.service.redo();

    switch (item.type) {
      case "add":
        // added, need to re-add
        this.dp.events.addByData(item.current);
        break;
      case "remove":
        // removed, need to remove again
        this.dp.events.removeById(item.id);
        break;
      case "update":
        // updated, use the new version
        this.dp.events.updateByData(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;
      }
    }
  });

};