Features

  • Client-side implementation of undo/redo queue for Angular 4 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
  • Includes a trial version of DayPilot Pro for JavaScript (see License below)

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.

Live Demo

You can test this project in a live demo:

Project Initialization

In order to download the dependencies (node_modules) it's necessary to initialize the project using npm:

npm install

Running the Project

You can run the project using npm:

npm run start

This command will start an embedded web server at http://localhost:4200.

Scheduler Configuration

angular-scheduler-undo-redo-basic-configuration.png

The installation and configuration of the Angular Scheduler component is not covered by this tutorial. For an introduction and a boilerplate Angular 4 project please see the following tutorial:

Undo Service API

angular-scheduler-undo-service.png

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 string
  • the 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 the Undo Service with Angular Scheduler Component

angular-scheduler-undo-redo-initialization.png

When starting with the Scheduler quick start project we already have a configured Scheduler component (SchedulerComponent in src/app/scheduler/scheduler.component.ts). We will 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 DataService 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: any = {
    
    // ...

    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.

Undo Button

angular-scheduler-undo-button.png

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

Redo Button

angular-scheduler-redo-button.png

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

angular-scheduler-undo-redo-history.png

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

undo.service.ts

import { Injectable } from '@angular/core';
import 'rxjs/Rx';
import {DayPilot} from "daypilot-pro-angular";

@Injectable()
export class UndoService {

  private _items: any;

  private _history: HistoryItem[] = [];

  private _position: number = 0;

  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 = [];
  }

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

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

  get canUndo(): boolean {
    return this._position > 0;
  }

  get canRedo(): boolean {
    return this._position < this._history.length;
  }

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

  get position(): number {
    return this._position;
  }

  get history(): HistoryItem[] {
    return this._history;
  }

}

export interface HistoryItem {
  id: string | number;
  time: DayPilot.Date;
  previous: Item;
  current: Item;
  text: string;
  type: string;
  active?: boolean;
}

export interface Item {
  id: string | number;
}