Angular Auto Save, Undo and Redo

This article shows how to implement auto save, Undo and Redo commands in an Angular SPA. The Undo and the Redo commands work for the whole application and not just for single components. The Angular app uses an ASP.NET Core service implemented in the previous blog.

Code: https://github.com/damienbod/AngularAutoSaveCommands

History

2021-01-20: Updated to ASP.NET Core 5, Angular CLI 11.0.9
2019-07-31: Updated to ASP.NET Core 3.0 Preview 7, Updated to Angular 8.1.3
2019-02-16: Updated to Angular 7.2.4, ASP.NET Core 2.2 nuget packages
2018-11-22: Updated to Angular 7.1.0, nuget packages
2018-09-28: Updated to ASP.NET Core 2.1.4 and Angular 6.1.9
2018-06-16: Updated to ASP.NET Core 2.1 and Angular 6.0.5
2018-02-11: Updated to ASP.NET Core All 2.0.5 and Angular 5.2.4
2017-08-19: Updated to ASP.NET Core 2.0 and Angular 4.3.5
2017.02.03: Updated to Angular 2.4.5 and webpack 2.2.1, VS2017 RC3, msbuild3
2016.12.23: Updated to Visual Studio 2017 and ASP.NET Core 1.1
2016.08.19 Updated to Angular 2 release, ASP.NET Core 1.0.1

Other articles in this series:

  1. Implementing UNDO, REDO in ASP.NET Core
  2. Angular Auto Save, Undo and Redo
  3. ASP.NET Core Action Arguments Validation using an ActionFilter

The CommandDto class is used for all create, update and delete HTTP requests to the server. This class is used in the different components and so the payload is always different. The CommandType defines the type of command to be executed. Possible values supported by the server are ADD, UPDATE, DELETE, UNDO, REDO. The PayloadType defines the type of object used in the Payload. The PayloadType is used by the server to convert the Payload object to a c# specific class object. The ActualClientRoute is used for the Undo, Redo functions. When an Undo command is executed, or a Redo, the next client path is returned in the CommandDto response. As this is an Angular 4 application, the Angular routing value is used.

export class CommandDto {

    public commandType: string;
    public payloadType: string;
    public payload: any;
    public actualClientRoute: string;

    constructor(commandType: string, payloadType: string, payload: any, actualClientRoute: string) {

        this.commandType = commandType;
        this.payloadType = payloadType;
        this.payload = payload;
        this.actualClientRoute = actualClientRoute;

    }
}

The CommandService is used to access the ASP.NET Core API implemented in the CommandController class. The service implements the Execute, Undo and Redo HTTP POST requests to the server using the CommandDto as the body. The service also implements an EventEmitter output which can be used to update child components, if an Undo command or a Redo command has been executed. When the function UndoRedoUpdate is called, the event is sent to all listeners.

import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, EventEmitter, Output } from '@angular/core';
import { Observable } from 'rxjs';

import { Configuration } from '../app.constants';
import { CommandDto } from './command-dto';

@Injectable()
export class CommandService {

    @Output() OnUndoRedo = new EventEmitter<string>();

    private actionUrl: string;
    private headers: HttpHeaders;

    constructor(private http: HttpClient, configuration: Configuration) {

        this.actionUrl = `${configuration.Server}api/command/`;

        this.headers = new HttpHeaders();
        this.headers = this.headers.set('Content-Type', 'application/json');
        this.headers = this.headers.set('Accept', 'application/json');
    }

    public Execute = (command: CommandDto): Observable<CommandDto> => {
        const url = `${this.actionUrl}execute`;
        return this.http.post<CommandDto>(url, command, { headers: this.headers });
    }

    public Undo = (): Observable<CommandDto> => {
        const url = `${this.actionUrl}undo`;
        return this.http.post<CommandDto>(url, '', { headers: this.headers });
    }

    public Redo = (): Observable<CommandDto> => {
        const url = `${this.actionUrl}redo`;
        return this.http.post<CommandDto>(url, '', { headers: this.headers });
    }

    public GetAll = (): Observable<any> => {
        return this.http.get<any>(this.actionUrl, { headers: this.headers });
    }

    public UndoRedoUpdate = (payloadType: string) => {
        this.OnUndoRedo.emit(payloadType);
    }
}

The app.component implements the Undo and the Redo user interface.

<div class="container" style="margin-top: 15px;">

    <nav class="navbar navbar-inverse">
        <div class="container-fluid">
            <div class="navbar-header">
                <a class="navbar-brand" [routerLink]="['/commands']">Commands</a>
            </div>
            <ul class="nav navbar-nav">
                <li><a [routerLink]="['/home']">Home</a></li>
                <li><a [routerLink]="['/about']">About</a></li>
                <li><a [routerLink]="['/httprequests']">HTTP API Requests</a></li>
            </ul>
            <ul class="nav navbar-nav navbar-right">
                <li><a (click)="Undo()">Undo</a></li>
                <li><a (click)="Redo()">Redo</a></li>
                <li><a href="https://twitter.com/damien_bod"><img src="assets/damienbod.jpg" height="40" style="margin-top: -10px;" /></a></li>               

            </ul>
        </div>
    </nav>

    <router-outlet></router-outlet>

    <footer>
        <p>
            <a href="https://twitter.com/damien_bod">twitter(damienbod)</a>&nbsp; <a href="https://damienbod.com/">damienbod.com</a>
            &copy; 2021
        </p>
    </footer>
</div>

The Undo method uses the _commandService to execute an Undo HTTP POST request. If successful, the UndoRedoUpdate function from the _commandService is executed, which broadcasts an update event in the client app, and then the application navigates to the route returned in the Undo commandDto response using the ActualClientRoute.

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { CommandService } from './services/command-service';
import { CommandDto } from './services/command-dto';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})

export class AppComponent {

    constructor(private router: Router, private _commandService: CommandService) {
    }

    public Undo() {
        let resultCommand: CommandDto;

        this._commandService.Undo()
            .subscribe(
                data => resultCommand = data,
                error => console.log(error),
                () => {
                    this._commandService.UndoRedoUpdate(resultCommand.payloadType);
                    this.router.navigate(['/' + resultCommand.actualClientRoute]);
                }
            );
    }

    public Redo() {
        let resultCommand: CommandDto;

        this._commandService.Redo()
            .subscribe(
                data => resultCommand = data,
                error => console.log(error),
                () => {
                    this._commandService.UndoRedoUpdate(resultCommand.payloadType);
                    this.router.navigate(['/' + resultCommand.actualClientRoute]);
                }
            );
    }
}

The HomeComponent is used to implement the ADD, UPDATE, DELETE for the HomeData object. A simple form is used to add, or update the different items with an auto save implemented on the input element using the keyup event. A list of existing HomeData items are displayed in a table which can be updated or deleted.

<div class="container">
    <div class="col-lg-12">
        <h1>Selected Item: {{model.id}}</h1>
        <form *ngIf="active" (ngSubmit)="onSubmit()" #homeItemForm="ngForm">

            <input type="hidden" class="form-control" id="id" [(ngModel)]="model.id" name="id" #id="ngModel">
            <input type="hidden" class="form-control" id="deleted" [(ngModel)]="model.deleted" name="deleted" #id="ngModel">

            <div class="form-group">
                <label for="name">Name</label>
                <input type="text" class="form-control" id="name" required  (keyup)="createCommand($event)" [(ngModel)]="model.name" name="name" #name="ngModel">
                <div [hidden]="name.valid || name.pristine" class="alert alert-danger">
                    Name is required
                </div>
            </div>

            <button type="button" class="btn btn-default" (click)="newHomeData()">New Home</button>

        </form>
    </div>
</div>

<hr />

<div>

    <table class="table">
        <thead>
            <tr>
                <th>Id</th>
                <th>Name</th>
                <th></th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            <tr style="height:20px;" *ngFor="let homeItem of HomeDataItems">
                <td>{{homeItem.id}}</td>
                <td>{{homeItem.name}}</td>
                <td>
                    <button class="btn btn-default" (click)="Edit(homeItem)">Edit</button>
                </td>
                <td>
                    <button class="btn btn-default" (click)="Delete(homeItem)">Delete</button>
                </td>
            </tr>
        </tbody>
    </table>

</div>


The HomeDataService is used to selected all the HomeData items using the ASP.NET Core service implemented in rhe HomeController class.

import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

import { Configuration } from '../app.constants';

@Injectable()
export class HomeDataService {

    private actionUrl: string;
    private headers: HttpHeaders;

    constructor(private http: HttpClient, configuration: Configuration) {

        this.actionUrl = `${configuration.Server}api/home/`;

        this.headers = new HttpHeaders();
        this.headers = this.headers.set('Content-Type', 'application/json');
        this.headers = this.headers.set('Accept', 'application/json');
    }

    public GetAll = (): Observable<any> => {
        return this.http.get<any>(this.actionUrl, { headers: this.headers });
    }
}

The HomeComponent implements the different CUD operations and also the listeners for Undo, Redo events, which are relevant for its display. When a keyup is received, the createCommand is executed. This function adds the data to the keyDownEvents subject. A deboucedInput Observable is used together with debounceTime, so that only when the user has not entered any inputs for more than a second, a command is sent to the server using the OnSumbit function.

The component also subscribes to the OnUndoRedo event sent from the _commandservice. When this event is received, the OnUndoRedoRecieved is called. The function updates the table with the actual data if the undo, redo command has changed data displayed in this component.

import { Component, OnInit } from '@angular/core';
import { HomeData } from './home-data';
import { CommandService } from '../services/command-service';
import { CommandDto } from '../services/command-dto';
import { HomeDataService } from '../services/home-data-service';

import { distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { Observable ,  Subject } from 'rxjs';

@Component({
    selector: 'app-home-component',
    templateUrl: 'home.component.html'
})

export class HomeComponent implements OnInit {

    public message: string;
    public model: HomeData = { id: 0, name: '', deleted: false };
    public submitted = false;
    public active = false;
    public HomeDataItems: HomeData[] = [];

    private deboucedInput: Observable<string> | undefined;
    private keyDownEvents = new Subject<string>();

    constructor(private _commandService: CommandService, private _homeDataService: HomeDataService) {
        this.message = 'Hello from Home';
        this._commandService.OnUndoRedo.subscribe((item: any) => this.OnUndoRedoRecieved(item));
    }

    ngOnInit() {
        this.model = new HomeData(0, 'name', false);
        this.submitted = false;
        this.active = true;
        this.GetHomeDataItems();

        this.deboucedInput = this.keyDownEvents;
        this.deboucedInput.pipe(
            debounceTime(1000),
            distinctUntilChanged())
            .subscribe(() => {
                this.onSubmit();
        });
    }

    public GetHomeDataItems() {
        console.log('HomeComponent starting...');
        this._homeDataService.GetAll()
            .subscribe((data) => {
                this.HomeDataItems = data;
            },
            error => console.log(error),
            () => {
                console.log('HomeDataService:GetAll completed');
            }
        );
    }

    public Edit(aboutItem: HomeData) {
        this.model.name = aboutItem.name;
        this.model.id = aboutItem.id;
    }

    // TODO remove the get All request and update the list using the return item
    public Delete(homeItem: HomeData) {
        const myCommand = new CommandDto('DELETE', 'HOME', homeItem, 'home');

        console.log(myCommand);
        this._commandService.Execute(myCommand)
            .subscribe(
            () => this.GetHomeDataItems(),
            error => console.log(error),
            () => {
                if (this.model.id === homeItem.id) {
                    this.newHomeData();
                }
            }
        );
    }

    public createCommand(event: any) {
        this.keyDownEvents.next(this.model.name);
    }

    public onSubmit() {
        if (this.model.name !== '') {
            this.submitted = true;
            const myCommand = new CommandDto('ADD', 'HOME', this.model, 'home');

            if (this.model.id > 0) {
                myCommand.commandType = 'UPDATE';
            }

            console.log(myCommand);
            this._commandService.Execute(myCommand)
                .subscribe(
                data => {
                    this.model.id = data.payload.Id;
                    this.GetHomeDataItems();
                },
                error => console.log(error),
                () => console.log('Command executed')
             );
        }
    }

    public newHomeData() {
        this.model = new HomeData(0, 'add a new name', false);
        this.active = false;
        setTimeout(() => this.active = true, 0);
    }

    private OnUndoRedoRecieved(payloadType: any) {
        if (payloadType === 'HOME') {
            this.GetHomeDataItems();
           // this.newHomeData();
            console.log('OnUndoRedoRecieved Home');
            console.log(payloadType);
        }
    }
}

When the application is built (both server and client) and started, the items can be added, updated or deleted using the commands.

angular2autosaveundoredo_01

The executed commands can be viewed using the commands tab in the Angular 2 application.

angular2autosaveundoredo_03

And the commands or the data can also be viewed in the SQL database.

angular2autosaveundoredo_02

Links

http://blog.thoughtram.io/angular/2016/02/22/angular-2-change-detection-explained.html

https://angular.io/docs/ts/latest/guide/forms.html

One comment

  1. Yinghan Wang · · Reply

    the sample data is small. when it becomes big would you save the entire data or just the delta…?

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: