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

angular calendar component open source undo redo config

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)

angular calendar component open source undo redo history

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

angular calendar component open source undo redo drag drop

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

angular calendar component open source undo

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 the record.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

angular calendar component open source redo

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;
    }
  }

}