Click here to Skip to main content
15,867,453 members
Articles / Web Development / ASP.NET / ASP.NET Core

Angular Data CRUD with Advanced Practices of Reactive Forms

Rate me:
Please Sign up or sign in to vote.
4.99/5 (32 votes)
6 Dec 2020CPOL24 min read 93.6K   6.2K   90   18
An Angular sample application that includes selecting, adding, updating, and deleting data with HttpClient service, reactive forms for object and array types, in-line data list editing, custom input validations, and various other features (latest update with Angular 11 CLI and ASP.NET Core 5.0).
The article and attached source code deliver the details of CRUD data operations and workflows with complete examples using the latest versions of the Angular and ASP.NET website hosting technologies. The functionalities and issue resolutions presented in the sample application have general significances for developing and maintaining qualitative business data web applications.

Introduction

The article and companion sample application have been updated several times after being re-written from the AngularJS version and Angular since version 5. The latest source code in Angular 11 CLI and ASP.NET Core 5.0 website has been available for downloading. If you need the sample applications for previous Angular versions, please see the History section by the end of the article.

The features demonstrated in the article and sample application include:

  • Adding and editing data using the reactive form for the single data object on modal dialogs.
  • Inline and dynamically adding and editing multiple data rows in a table using the reactive form and FormArray.
  • Deleting multiple and selective data records using reactive forms.
  • Dynamically displaying refreshed data after adding, updating, or deleting processes with reactive form approaches.
  • Custom and in-line input data validations for reactive forms in the pattern of on-change process and on-blur error message display.
  • Dirty warning when leaving pages related to both Angular internal router and external re-directions.
  • Full support of RESTful API data services.
  • Easy setup for running the sample application.

The sample application ports below Angular components or directives in the updated Angular 11 as subfolders into the project root. Audiences can go to original posts or source code repositories if details are needed although these tools may still be with the previous versions of the Angular.

Build and Run Sample Application

The downloaded sources contain two different Visual Studio solution/project types. Please pick up one or both you would like and do the setup on your local machine. You also need the node.js (recommended version 14.x LTS or above) and Angular CLI (recommended version 11.x or above) installed globally on the local machine. Please check the node.js and Angular CLI documents for details.

You may check the available versions of the TypeScript for Visual Studio in the C:\Program Files (x86)\Microsoft SDKs\TypeScript folder. Both ASP.NET and Core types of the sample application set the version of TypeScript for Visual Studio to 4.0 in the TypeScriptToolsVersion node of SM.NgDataCrud.Web.csproj file. If you don't have the version 4.0 installed, download the installation package from the Microsoft site or install the Visual Studio 2019 version 16.8.x which includes the TypeScript 4.0.

NgDataCrud_AspNetCore_Cli

  1. You need to use the Visual Studio 2019 (version 16.8.x) on the local machine. The .NET Core 5.0 SDK is included in the Visual Studio installation.

  2. Download and unzip the source code file to your local work space.

  3. Go to physical location of your local work space, double click the npm_install.bat and ng_build.bat (or ng_build_local.bat if not installing the Angular CLI globally) files sequentially under the SM.NgDataCrud.Web\AppDev folder.

    NOTE: The ng build command may need to be executed every time after making any change in the TypeScript/JavaScript code, whereas the execution of npm install is just needed whenever there is any update with the node module packages. I do not enable the CLI/Webpack hot module replacement since it could break source code mapping for the debugging in the Visual Studio.

  4. Open the solution with the Visual Studio 2019, and rebuild the solution with the Visual Studio.

NgDataCrud_AspNet_Cli

  1. Download and unzip the source code file to your local work space.

  2. Go to physical location of your local work space, double click the npm_install.bat and ng_build.bat (or ng_build_local.bat if not installing the Angular CLI globally) files sequentially under the SM.NgDataCrud.Web\ClientApp folder (also see the same NOTE for setting up the NgDataCrud_AspNetCore_Cli project).

  3. Open the solution with Visual Studio 2017 or 2019 and rebuild the solution with the Visual Studio.

You can view the Angular source code structures in the ../src/app folder. Since all active Angular UI source code pieces in the SM.NgDataCrud.Web project folders and files are pure client scripts, you can move these folders and files to any other project type with different bundling tools, or even to different platforms.

Image 1

The SM.NgDataCrud.Web application works with the corresponding RESTful API data service and underlying database which are included in the downloaded sources. I recommend setting up the SM.Store.CoreApi solution in your local machine. After opening and building the SM.Store.CoreApi solution with another Visual Studio instance, you can select one of available browsers from the IIS Express button dropdown on the menu bar, and then click that button to start the data service API application.

No database needs to be set up initially since the API application uses the in-memory database with the current configurations. The built-in starting page will show the response data in the JSON format obtained from a service method call, which is just a simple way to start the service application with the IIS Express on the development machine. You can now minimize the Visual Studio screen and keep the data service API running on the background. You can view this post for the details of the SM.Store.CoreApi data service project.

If you would like to use the legacy ASP.NET Web API 2 version of the data services, you can refer to the AngularJS 1.x version of the article for how to set up the data service project on your machine. The SM.Store.WebApi solution code is also included in the downloaded source of this article.

Before you run the sample application, you may check the RESTful API data service URL path in the ../src/app/Services/app.config.ts file to make sure that the correct WebApiRootUrl value is set for the running data services.

When all these are ready, press F5 to start the sample application. You can enter some parameters, or just leave all search parameter field empty, on the Search Products panel and then click the Go. The Product List grid should be displayed.

Image 2

Selecting the Contacts left menu item will open the page with contact list filled in a table. The inline table editing feature is implemented on this page, which will be shown in the later section.

Image 3

Adding and Updating Data Using Modal Dialogs

This topic is the same as the AngularJS version so that I won’t repeat those in common for the code and user case workflow. The major change, besides the Angular version itself, in the code is that the new version uses the reactive form pattern for the data display and editable field entries. The reactive form here is for a single object model that is shown and editable on the popup modal dialog. The major implementation is outlined below.

  1. In the product.component.html, specify the [fromGroup] directive in the <form> tag and fromControlName directive in the form’s editable field elements.

    XML
    <form [formGroup]="productForm" (ngSubmit)="saveProduct(productForm)">
       <input type="text" name="productName" formControlName="productName" />
       - - -
    </form>
  2. In the product.component.ts, create an instance of the FromGroup and set the names with options for all form controls in the ngOnInit method. The optional validator settings will be discussed in the later section.

    JavaScript
    this.productForm = new FormGroup({
        'productName': new FormControl('', Validators.required),
        'category': new FormControl('', [Validator2.required()]),
        'unitPrice': new FormControl('', [Validator2.required(), 
         Validator2.number(), Validator2.maxNumber({ value: 5000, label: "Price" })]),
        'status': new FormControl(''),
        'availableSince': new FormControl('', Validator2.DateRange
         ({ minValue: "1/1/2010", maxValue: "12/31/2023" }))
    });
  3. Define and use a custom or base model object for the product data:

    JavaScript
    model: any = { product: {} };

    This product object will then be used to receive the data response from the AJAX call and as the data source to populate the reactive form controls.

    JavaScript
    let pThis: any = this;
    this.httpDataService.get(url).subscribe(
        data => {
            //Format and conversion.
            data.UnitPrice = parseFloat(data.UnitPrice.toFixed(2));
            data.AvailableSince = { jsdate: new Date(data.AvailableSince) };
            //Assign data to class-level model object.
            pThis.model.product = data;
            //Populate reactive form controls with model object properties.
            pThis.productForm.setValue({
                productName: pThis.model.product.ProductName,
                category: pThis.model.product.CategoryId,
                unitPrice: pThis.model.product.UnitPrice,
                status: pThis.model.product.StatusCode,
                availableSince: pThis.model.product.AvailableSince
            });
    },
    - - -

    When submitting edited data, the base product model is updated from the reactive form controls and acts as the request object for the HTTP Post call.

    JavaScript
    //Assign form control values back to model.
    this.model.product.ProductName = productForm.value.productName;
    this.model.product.CategoryId = productForm.value.category;
    this.model.product.UnitPrice = productForm.value.unitPrice;
    this.model.product.StatusCode = productForm.value.status;
    if (productForm.value.availableSince) {
        this.model.product.AvailableSince = productForm.value.availableSince.jsdate;
    }
    - - -
    this.httpDataService.post(ApiUrl.updateProduct, this.model.product).subscribe(
        data => {
            - - -
        }
    );

    Why using the class-level base model object instead of directly binding data to the built-in form group/controls model? There are at least these advantages of doing so.

    • The form control names or character cases may not be the same as the response data fields (or database columns). For example, the names of model.product properties and form controls are different or in different character cases, such as the “CategoryId” vs “category” and the “StatusCode” vs “status”. Having a base model can keep those differences constant across the entire class.

    • Some fields required in the base model only for data integrity and processing needs can easily be excluded from the form controls, such as ProductId.

    • The base model object is a good source of keeping original loaded data which can be used anytime for dirty comparisons manually, if needed, or restoring the original data display. Note that the form group is not mutable to the base model object instance. Each form control gets the values from individual base model properties.

  4. For adding a new product, the popup dialog can also be used for repeated data record entries, just like its AngularJS ancestor. Between any two entries, both the base model object instance and form group will be reset to the empty value status. The first input field, Product Name, will also be focused for quick key-typing operations.

    JavaScript
    resetAddForm() {
        this.model.product = {
            ProducId: 0,
            ProductName: "",
            CategoryId: "",
            UnitPrice: "",
            StatusCode: "",
            AvailableSince: ""
        };
        this.productForm.reset({
            productName: this.model.product.ProductName,
            category: this.model.product.CategoryId,
            unitPrice: this.model.product.UnitPrice,
            status: this.model.product.StatusCode,
            availableSince: this.model.product.AvailableSince
        });
    
        this.focusProductName();
    }

    Here shows the example of the Update Product modal dialog screen.

    Image 4

In-line Adding and Updating Data

With the Angular reactive form and FormArray structures, the two-way data binding and grid in-line data adding, editing, and deleting operations on the Contact List page are more efficient, elegant, and easier to be implemented than its AngularJS version although the look-and-feel on the screen is the same. The only change regarding the user case workflow is to simplify the status settings. The previous Edit and Add statuses have been merged into the Update status.

  • Read: This is the default status whenever the data is initially loaded or refreshed. No input element is shown except for those cleared checkboxes in the first column. This screenshot is the same shown in the first section.

    Image 5

  • Update: Checking the checkbox in any existing data row or clicking Add button will enable the Update status for which all input fields in the existing row or an add-new row is shown. Multiple rows can be selected and/or added for editing and submission all at once. The user can delete an existing row if it is selected and no field value has been changed. The user can also cancel the edited changes by unselecting rows or clicking the Cancel Changes button any time.

    Image 6

In the Update status, two count numbers, addRowCount and editRowCount, are used to identify the workflow of adding new rows or editing existing rows. The count numbers will increase or decrease based on the number of rows to be added or edited. The saveChanges method submits the updates for both edited or added data rows based on the count numbers.

JavaScript
if (this.editRowCount > 0) {
    //Submit edited contact data.   
    this.httpDataService.post(ApiUrl.updateContacts, editItemList).subscribe(
	 data => {
	   if (this.addRowCount > 0) {
		   //Process add-new rows if exist.
		   this.doSaveAddNewRows(temp2);
		  }
	   else {
		  //Refresh table.
		  this.getContactList();
	   }
	);
}
else if (this.addRowCount > 0) {
	this.doSaveAddNewRows(temp2);
}

It’s somewhat complex to implement the FormGroup and FormArray with child FormControl items for this grid in-line editing form but below are the major tasks done using these Angular structures.

Creating HTML Elements with FormArray Arrangement

The structure shown below is simplified without the elements and attributes for styles, validators, conditional checkers, and buttons. Here, the contactControlList is a variable set in the component class referencing to the contactForm.controls.contactFmArr.controls. The [formGroupName]="$index" is a nested FormGroup instance as an element of the contactFmArr array.

JavaScript
<form [formGroup]="contactForm">
    <div formArrayName="contactFmArr">
        <table>
          - - -
          <tbody>
             <tr [formGroupName]="$index" *ngFor="let item of contactControlList; 
              let $index = index">
                <td>
                    <input type="text" formControlName="ContactName"/>
                </td>
                </td>
                  - - -
                </tr>
          </tbody>
       </table>
    </div>
</form>

Populating FormArray Instance and Binding Data to Form Controls

After obtaining the data from the AJAX call, the original contact data list is deep-cloned for possible record-based cancel or undo later. The code then calls the reusable method to set contact data values from the array.

JavaScript
//Make deep clone of data list for record-based cancel/undo.
this.model.contactList_0 = glob.deepClone(data.Contacts);

this.resetContactFormArray();

Within the resetContactFormArray() method, the forEach loop adds the each nested FormGroup instance as an element into the contactFmArr array.

JavaScript
resetContactFormArray() {
    let pThis: any = this;
    - - -
    //Need to use original structures, not referred contactControlList.
    pThis.contactForm.controls.contactFmArr.controls = [];
    pThis.model.contactList_0.forEach((item: any, index: number) => {
        pThis.contactForm.controls.contactFmArr.push(pThis.loadContactFormGroup(item));
        pThis.checkboxes.items[index] = false;
    });
    //Set reference for data binding.
    pThis.contactControlList = pThis.contactForm.controls.contactFmArr.controls;
}
loadContactFormGroup(contact?: any): FormGroup {
    return new FormGroup({
        //Dummy control for cache key Id.
        "ContactId": new FormControl(contact.ContactId),
        "ContactName": new FormControl(contact.ContactName, Validators.required),
        "Phone": new FormControl(contact.Phone, [Validator2.required(), Validator2.usPhone()]),
        "Email": new FormControl(contact.Email, [Validator2.required(), Validator2.email()]),
        "PrimaryType": new FormControl(contact.PrimaryType)
    });
}

The code for the validators can be ignored this time (details will be in the later section). I also use the FormGroup, instead of the FormBuilder object because the latter doesn’t support the option “updateOn” which could be used and tested in the code (see later section for validators). In addition, the ContractId control defined here is to hold the key Id values for which no equivalent element is set on the HTML view. Fortunately, the array element form group doesn’t complain on it.

Dynamically Adding FormGroup Instance for New Row

Since the loadContactFormGroup method is already defined, creating an instance of the FormGroup as an element of contactFmArr array is quite straightforward.

JavaScript
//Add empty row to the bottom of table.
let newContact = {
    ContactId: 0,
    ContactName: '',
    Phone: '',
    Email: '',
    PrimaryType: 0
};
this.contactForm.controls.contactFmArr.push(this.loadContactFormGroup(newContact));

When the new FormGroup instance is added into the contactFmArr array with the name of array index number, a new empty row is automatically appended to the table and shown on the page.

Image 7

Row Selections with Standalone Checkbox Array

An input element of checkbox type is added into the first <td> element within the territory of the array element form group. However, the checkbox is not included in the index-based form group. It uses the template-driven pattern with the ngModel directive and the standalone option.

XML
<tr [formGroupName]="$index" *ngFor="let item of contactControlList; let $index = index">
    <td>
        <input type="checkbox" 
            [(ngModel)]="checkboxes.items[$index]"
            [ngModelOptions]="{standalone: true}"
            (change)="listCheckboxChange($index)" />
    </td>
    - - -
</tr>

This is a very nice feature in that we can use the reactive form in general but any standalone form control that isn’t within the scope of the form group and form array. Using this approach, any checkbox action doesn’t affect the overall status of the data operations of the form group and form array. For example, we can now monitor if the form array is dirty without being worried about the unexpected “dirty form” caused by clicking a checkbox to select a row.

The checkboxes.items array is defined in the ContactsComponent class and all elements are set to false by default in the forEach loop of the resetContactFormArray() method:

JavaScript
this.checkboxes.items[index] = false;

The index numbers of the checkboxes.items array are always synchronized with the contactFmArr array for any data row operations.

When adding a new row:

JavaScript
//Add element to contactFmArr.
(<FormArray>this.contactForm.controls.contactFmArr).push(this.loadContactFormGroup(newContact));
//Add element to checkboxes.items.       
this.checkboxes.items[this.checkboxes.items.length] = true;

When removing an existing row:

JavaScript
//Remove element from contactFmArr.
(<FormArray>this.contactForm.controls.contactFmArr).removeAt(listIndex);
//Remove element from checkboxes.items.
this.checkboxes.items.splice(listIndex, 1);

Cancelling Editing Tasks

The logic for cancelling editing tasks in the sample application is much simplified than the AngularJS version although the ways to initiate the cancel processes are the same.

  • Uncheck any checked row by calling the cancelChangeRow(listIndex) method. You can see the code comment lines for explanations. The “discard changes” warning confirmation is provided by the checkbox click-event method from the caller, which is not shown here. Note that the form array’s removeAt(listIndex) method for the contactFmArr and the splice(listIndex, n) method for the checkboxes.items automatically handle the array index shift if removing an element in any middle position of the array.

    JavaScript
    cancelChangeRow(listIndex) {
        //Reset form if no checkbox checked, else do individual row.
        let hasChecked: boolean = false;
        for (let i = 0; i < this.checkboxes.items.length; i++) {
            if (this.checkboxes.items[i]) {
                hasChecked = true;
                break;
            }
        }
        if (!hasChecked) {
            //Reset entire array.
            this.resetContactFormArray();            
        }
        else {
            if (listIndex > this.maxEditableIndex) {
                //Remove add-new row.
                (<FormArray>this.contactForm.controls.contactFmArr).removeAt(listIndex);
                this.checkboxes.items.splice(listIndex, 1);
    
                //Reduce addRowCount.
                this.addRowCount -= 1;                
            }
            else {
               //Edit row: reset array item.
               (<FormArray>this.contactForm.controls.contactFmArr).controls
                [listIndex].reset(glob.deepClone(this.model.contactList_0[listIndex]));
       
               //Reduce editRowCount.
               this.editRowCount -= 1;
            } 
        }        
    }
  • Click the Cancel Changes button or uncheck the top checkbox, if it’s checked, to call the cancelAllChangeRows method. This will clear all edited existing rows and added new rows. The form will then be reset to its original loaded situation. If no data value is changed after selecting any existing row, the action will simply uncheck any checked checkbox and return the form to the Read status.

    JavaScript
    cancelAllChangeRows(callFrom) {    
        //Check dirty for call from topCheckbox only.
        //Cancel button is enabled only if contactFmArr is dirty.
        if ((<FormArray>this.contactForm.controls.contactFmArr).dirty || 
             callFrom == "cancelButton") {
            this.exDialog.openConfirm({
                title: "Cancel Confirmation",
                message: message
            }).subscribe((result) => {            
                if (result) {
                    //Reset all.
                    pThis.resetContactFormArray();
                }
                else {
                    //Set back checked.
                    if (callFrom == "topCheckbox")
                        pThis.checkboxes.topChecked = true;
                }
            }); 
        }
        else {
            //Uncheck all checkboxes in edit rows.
            for (let i = 0; i <= this.maxEditableIndex; i++) {
                if (this.checkboxes.items[i]) {
                    this.checkboxes.items[i] = false;
                }
            } 
            this.checkboxes.topChecked = false;
            this.editRowCount = 0;
        }
    }

Input Data Validations

The Angular built-in and basic validators usually do not meet the needs by a business data application. Thus, I created full-range custom sync validators specifically for reactive forms. All validator functions are included in the Validator2 class. Audiences can see the details in the file, app/InputValidator/reactive-validator.ts. But here shows an example of the function used for validating an email address:

JavaScript
static email(args?: ValueArgs): ValidatorFn {        
    return (fc: AbstractControl): ValidationErrors => {
        if (fc.value) {
            let reg = /^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/;
            if (args && args.value) {
                //Set first arg as message if error text passed from the first arg.
                if (typeof args.value === "string") {
                    args.message = args.value;
                }
                else {
                    reg = args.value;
                }
            }
            const isValid = reg.test(fc.value);
            let label = "email address";
            if (args && args.label) label = args.label;
            const errRtn = {
                "custom": {
                    "message": args && args.message ? args.message : "Invalid " + label + "."
                }
            };
            return isValid ? null : errRtn;
        }
    }
}

The validators are set for the form controls when initiating the instance of the form group containing these form controls. The code below has already partially been shown in previous sections, but we focus on the option arguments for validators this time.

For the productForm:

JavaScript
this.productForm = new FormGroup({
    'productName': new FormControl('', Validators.required),
    'category': new FormControl('', [Validator2.required()]),
    'unitPrice': new FormControl('', [Validator2.required(), 
     Validator2.number(), Validator2.maxNumber({ value: 5000, label: "Price" })]),
    'status': new FormControl(''),
    'availableSince': new FormControl
    ('', Validator2.DateRange({ minValue: "1/1/2010", maxValue: "12/31/2023" }))
});

The Product form validation results are displayed like this:

Image 8

For the contact form:

JavaScript
loadContactFormGroup(contact?: any): FormGroup {
    return new FormGroup({
        //Dummy control for cache key Id.
        "ContactId": new FormControl(contact.ContactId),         
        "ContactName": new FormControl(contact.ContactName, Validators.required),
        "Phone": new FormControl(contact.Phone, [Validators.required, Validator2.usPhone()]),
        "Email": new FormControl(contact.Email, [Validator2.required(), Validator2.email()]),
        "PrimaryType": new FormControl(contact.PrimaryType)
    });
}

The Contact form validation results are displayed like this:

Image 9

Some details you may need to know for setting and using the validators.

Mixing Built-in and Custom Validators

You can still use the built-in Validators, if available, together with the custom Validator2, even for the same form control. See the example of the validator settings shown above for the contact.Phone.

Passing Arguments for the Custom Validators

Any method of the custom Validate2 accepts an argument of object type, either the ValueArgs or RangeArgs, which are defined in the validator-common.ts.

JavaScript
export class ValueArgs {
    value?: any;    
    label?: string;
    message?: string;
}
export class RangeArgs {
    minValue: any;
    maxValue: any;
    label?: string;
    message?: string;    
}

All properties of the argument objects are optional except the minValue and maxValue that are mandatory for any validation on a date or number range (see the above code to validate the date range for the availableSince field input).

If validating any size of single number, date, or text length, the value property of the ValueArgs is also needed since no default value can be pre-set for the corresponding validators (see the above code of maxNumber validator for the unitPrice field input.).

Displaying Inline Error Messages

In the sample application, any error message is displayed with the reusable ValidateErrorComponent triggered from the errors tag which is placed just under each HTML input element, for examples:

XML
<errors [control]="productForm.controls.unitPrice"></errors>

<errors [control]="contactControlList[$index].controls.Email" ></errors> 

Where the form control itself is passed to the ValidateErrorComponent in which the error messages are categorized and rendered to its child template. I do not list code of that component class here. Audiences can view the code in the app/InputValidator/validate-error.component.ts file if interested.

Handling On-Change and On-Blur Scenarios Associated With the Validations

The Angular 2 and 4 only uses the default on-change setting for updating models and thus the validation workflow. The Angular 5 or above provides the option to set the updateOn value on the FormGroup or the FormControl level although the equivalent settings were in the AngularJS. Whatever the updateOn setting applies, the time points of model updates and input validations influence both the process workflow and visual effects.

To help understand the logic in this section, it’s necessary to list the following categories of commonly input data for validations:

  • All or none: such as required field
  • Type: such as numeric or text
  • Size: such as minimum and/or maximum numbers
  • Exclusive: such as no particular symbol allowed
  • Expression: such as email, phone, or password. The date value is also a special kind of expressions.

Now let’s play the data inputs and look at the error message display when using the option { updateOn: 'change' }. The code doesn’t need to explicitly be there since it’s the default setting. We are also not concerned about the performance impact this time.

  • For all data input categories, except the Expression, an error message is immediately shown whenever the rule is broken during the typing, which is good as expected.
  • An Expression validation rule checks the entire data input, whereas the on-change scenario renders and shows the error for any single character entry if the current input doesn’t abide the rule, which is not what we would like.

    Image 10

What if we change the option to { updateOn: 'blur' } by adding it to the FromGroup initiation?

JavaScript
this.productForm = new FormGroup({
    //Form controls for input fields.
    - - -
}, { updateOn: 'blur' });

This fixes the issue of the error message display for Expression data inputs. Any error message is then shown after the input field loses focus. But this also comes up with a “no last blur” issue when directly moving mouse pointer from the last input field that has validators to the action or cancel buttons.

  • Problem #1: If the Save button will be dynamically enabled when the form is valid and dirty, then the button won’t be enabled for the clicking action unless you click on any other element or a blank area to have the on-blur event take in effect. This issue can be fixed by using the “virtually disabled button” approach described later.

    XML
    <button type="submit" [disabled]="!(productForm.valid && productForm.dirty)">Save</button>
  • Problem #2: Clicking the Cancel button when the last input field breaks the validation rule but has not lost focus will transfer the focus to the button the first time and display the error message. Then the second clicking is needed to send the real command. Changing the click to the mousedown event seems to have the on-blur event fire before the button is focused, but the on-blur event for the input field has been bypassed. As a result, the invalid input has not been validated. Thus, a dirty form could be unloaded without any notice.

  • Problem #3: When moving the mouse pointer from an value-changed input field to another available router/menu item, browser history back button, or even the x close button of the browser, the global dirty warning is not kicked in due to the inability to perform the on-blur model update and validation. Using the on-change pattern doesn’t have such a side-effect. See more details from global dirty warning topic in the next section.

Below are the workaround to solve these problems:

  • Using the default on-change pattern for all model updates and input data validations. All non-Expression data validations, Save and Cancel button actions, and global dirty warning should work well with the setting.

  • Deferring the possible error message until the field is out of the focus for any Expression data input. Firstly, we need to add a custom property, showInvalid, into the form controls with default value of true.

    JavaScript
    //Add showInvalid property for onBlur display validation error message.
    for (let prop in this.productForm.controls) {
        if (this.productForm.controls.hasOwnProperty(prop)) {
            this.productForm.controls[prop]['showInvalid'] = true;
        }
    }        

    The flag value is toggled from the focus and blur events of any input control that holds the Expression data that needs to be validated. We here still take the availableSince control in the productForm as the example.

    In the product.component.html:

    XML
    <input type="text" formControlName="availableSince"        
      (focus)="setShowInvalid(productForm.controls.availableSince, 0)"
      (blur)="setShowInvalid(productForm.controls.availableSince, 1)"/>        

    The setShowInvalid function in the product.component.ts:

    JavaScript
    //Set flag for control to display validation error message onBlur.
    setShowInvalid(control: any, actionType: number) {
        if (actionType == 0) {
            control.showInvalid = false;
        }
        else if (actionType == 1) {
            control.showInvalid = true;
        }
    }        

    In the ValidateErrorComponent (app/InputValidator/validate-error.component.ts), the showInvalid property checker is added into the showErrors method:

    JavaScript
    showErrors(): boolean { 
        let showErr: boolean = false;
        if (this.control &&
            this.control.errors &&
            (this.control.dirty || this.control.touched) &&
            this.control.showInvalid) {            
            showErr = true;            
        }
        return showErr;
    }           
  • Implementing the virtually-disabled buttons. For the Save button, neither disabled directive nor JavaScript code is used to directly disable the button. However, the look and feel of the button can still be toggled between enabled and disabled statuses with the ngClass settings. Clicking the button anytime will send the command to the saveProduct method in the ProductComponent class. If the form is invalid or not dirty, then the process will stop at the very first line of the method to achieve the same disabled effect.

    In the product.component.html:

    XML
    <button type="submit" class="dialog-button" 
    #saveButton (mouseover)="focusOnButton('save')"
    [ngClass]="{'dialog-button-primary': productForm.valid && productForm.dirty, 
    'dialog-button-primary-disabled': 
    !(productForm.valid && productForm.dirty)}">Save</button>        

    In the product.component.cs:

    JavaScript
    saveProduct(productForm: FormGroup) {
        //Need to check and exit if form is invalid for "onblur" validation.
    	if (productForm.invalid || !productForm.dirty) return;
        - - - 
    }       

    On the browser, when entering the invalid date value to the Available Since field like this:

    Image 11

    Then move the mouse immediately to the Save button. The inline validation error message is shown and the Save button is virtually disabled due to the dirty and invalid form status.

    Image 12

You can test all scenarios and cases mentioned above by temporarily replacing the product.component.ts and product.component.html with the files having the same names in the folders:

  • Test_Replacement/ProductComponent_OnChange: for all on-change validation-only workflow operations.

  • Test_Replacement/ProductComponent_OnBlur: for all on-blur validation-only workflow operations.

  • Test_Replacement/ProductComponent_Final: the same files as in the normal app/PageContents folder when downloaded, which use the custom on-change validations with on-blur error message display for Expression data inputs. Copying the files back to the app/PageContents folder will resume the code to downloaded originals after the on-change and on-blur validation-only tests.

NOTE: When changing the .ts and .html files, and refreshing the browser window or restarting the application, make sure the code is built and the browser's Cached Images and Files are cleand up.

Dirty Warnings When Leaving Pages

In the AngularJS version of the sample application, two approaches are implemented for rendering the dirty warnings:

  • The AngularJS scope based $locationChangeStart: This event can be triggered by any internal route switching and the redirection from any external site back to the AngularJS routed application URL. The handler can be cancelled by calling the event.preventDefault method.

  • The native JavaScript window.onbeforeunload: This event is triggered by leaving the AngularJS application for any external site including refreshing the page and close the browser.

With the Angular, the window.onbeforeunload still works as expected with the same code as in the AngularJS version since it's the native JavaScipt function. However, the equivalent method for switching between routes, NavigationStart, loses the native event reference so that no way is available to cancel the current routing process and stay in the current page as a result of user’s negative response.

Fortunately, the Angular provides the ComponentCanDeactivate interface and canDeactivate method that we can implement as a route guard. I have used this approach as an alternative for the global dirty warnings in this sample application. Here are the implementation details.

  1. Defining a global variable as the dirty flag in the app/Services/globals.ts.

    JavaScript
    export let caches: any = {
        pageDirty: false,
        - - -
    };
  2. Implementing the ComponentCanDeactivate in the DirtyWarning class (app/Services/dirty-warning.ts) as a service. The window.confirm dialog box and custom message text are set in the canDeactivate method. The logic for closing any possible opened exDialog box is also included.

    JavaScript
    @Injectable()
    export class DirtyWarning implements CanDeactivate<ComponentCanDeactivate> {
        constructor(private exDialog: ExDialog) { }
    
        canDeactivate(component: ComponentCanDeactivate): boolean | Observable<boolean> {
            // if there are no pending changes, just allow deactivation; else confirm first
            let rtn = component.canDeactivate();
            if (rtn) {
                //Close any Angular dialog if opened.
                if (this.exDialog.hasOpenDialog()) {
                    this.exDialog.clearAllDialogs();
                }
            }
            else {
                if (window.confirm("WARNING: You have unsaved changes. 
                    Press Cancel to go back and save these changes, 
                    or OK to ignore these changes.")) {
                    //Close any Angular dialog if opened.
                    if (this.exDialog.hasOpenDialog()) {                                
                        this.exDialog.clearAllDialogs();                                
                    }                
                    glob.caches.pageDirty = false;
                    rtn = true;
                }
                else {
                    //Cancel leaving action and stay on the page.
                    rtn = false;                            
                }
            }
            return rtn;        
        }
    }
  3. Registering this service in the app.module.ts:

    JavaScript
    @NgModule({
        - - -
        providers: [        
            [DirtyWarning],        
        ],
    	- - -    
    })
  4. Adding the canDeactivate as a property into each route’s path object:

    JavaScript
    export const routes: Routes = [
        { path: "", redirectTo: "product-list", 
          pathMatch: "full", canDeactivate: [DirtyWarning]  },
        { path: 'product-list', component: ProductListComponent, 
          canDeactivate: [DirtyWarning] },
        { path: 'contacts', component: ContactsComponent, canDeactivate: [DirtyWarning] }    
    ];
  5. Creating the canDeactivate method in the component that needs the dirty warning, which returns the global dirty flag value. For the ProductComponent, this method should be placed in its parent, the ProductListComponent.

    JavaScript
    //Route deactivate for dirty warning.
    canDeactivate(): Observable<boolean> | boolean {    
        //Returning true will navigate away silently.
        //Returning false will pass handler to caller for dirty warning.
        if (glob.caches.pageDirty) {
            return false;
        }
        else {
            return true;
        }
    }
  6. Using the form’s valueChanges method to update the global dirty flag whenever the dirty status of the form is changed.

    JavaScript
    //Update global dirty flag.
    this.productForm.valueChanges.subscribe((x) => {
        if (this.productForm.dirty) {
            glob.caches.pageDirty = true;
        }
        else {
            glob.caches.pageDirty = false;
        }
    })

This route guard type of global dirty warnings then works fine as expected. On the Chrome, the same type of the dialog box is used for both Angular internal route and browser redirections. The browser built-in text message is shown rather than those custom messages we place in the code.

Image 13

For IE 11, the dialog boxes for the Angular internal route and external browser redirections look somewhat different. But our custom warning messages are shown on the dialog boxes, respectively.

The dialog box shown for the Angular internal route redirections:

Image 14

The dialog box shown for the external browser redirections:

Image 15

Since the sample application is implemented with the on-change model updates and validations, but partially using on-blur error message display, the dirty warning process always works without the “no last blur” issue. If you are curious about how the “no last blur” issue affects the global dirty warnings, you can reproduce the issue with these steps.

  1. Replace the product.component.ts and product.component.html in the app/PageContents folder with the files in the Test_Replacement/ProductComponent_OnBlur folder.

  2. Start the website, select Contacts from the left menu.

  3. Select Product List from the left menu, click Go button, and then click Add Product button.

  4. Enter any text into the Product Name fields.

  5. Directly move the mouse pointer to browser back button and click it.

The browser will be back to the Contacts page without any notice, whereas the expected result should be displaying a dirty warning dialog box. You can see the normal behavior after you copy back the product.component.ts and product.component.html from the Test_Replacement/ProductComponent_Final folder and repeat the steps 2 - 5 above.

NOTE: When changing the .ts and .html files, and refreshing the browser window or restarting the application, make sure the code is built and the browser's Cached Images and Files are cleand up.

Summary

As the Angular has been more mature, the development of a complex data CRUD business web application is becoming feasible especially with the reactive form structures. The sample application in Angular presented here has been migrated from the AngularJS version and all issues were addressed during the migration tasks. The article describes the implementation details and resolutions for most of the issues. Hope that the sample application and discussions can be a helpful resource for the web application development using the Angular. As usual, it’s my pleasure to share the code and my experience with the developer communities.

History

  • 17th June, 2018
    • Original post for sample application in Angular version 5
  • 10th August, 2018
    • Added sample application in Angular version 6
    • Rewrote the setup instructions
  • 7th September, 2018
    • Added project type of ASP.NET Core 2.1 with Angular CLI 6
    • Rewrote the setup instructions
    • Re-structured download sources which include only source code in Angular 6
    • If you need the source code with the Angular 5 (only project types with Webpack and SystemJs available), you can download these here.
  • 4th November, 2018
    • Added project type of ASP.NET 5 with Angular CLI 6
    • Updated and simplified the setup processes of the sample application so that audiences can more focus on the real application content
  • 12th December, 2018
    • Using the updated NgExTable and NgExDialog tools
    • Updated format of the articles
    • Updated NgDataCrud_AspNetCore_Cli project type setup structures with pure client-side configurations in the code and setup instructions in the article
    • If you would like to have the previous project source code with server-side UseSpa midware, you can download the zip file here
  • 14th October, 2019
    • Updated source code in Angular 8 CLI and Bootstrap 4.3 CSS
    • Fixed a couple of minor bugs in the source code
    • Edited text in some sections
    • If needed, you can download the previous source code with Angular 6 CLI and Bootstrap 3.3 CSS: NgDataCrud_Ng6_Cli_All.zip
  • 5th December, 2019
    • Added source code with the ASP.NET Core 3.0 website for the Visual Studio 2019
    • Setup instructions for the sample application with the ASP.NET Core 3.0
    • Included ApiDataService source code with the ASP.NET Core 3.0 data service application
    • Fixed a bug in code for updating/adding contact data items
  • 6th December, 2020
    • Updated sample application source code in Angular version 11 and ASP.NET Core 5.0
    • Edited article text related to the source code updates
    • Included ApiDataService source code for the data service applications written in ASP.NET Core 5.0, 3.1, 2.1, and ASP.NET Web API 2.0
    • If you need to run the sample application with previous Angular version 8, 9, or 10, you can download the package.json file for the application, Package.json_Ng8-9-10.zip, replace the package.json file in the existing application with the version you would like, than do the same based on the instructions in the Build and Run Sample Application section. The Angular 11 source code of the sample application is fully compatible with the Angular version 8, 9, and 10 without major breaking changes.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
United States United States
Shenwei is a software developer and architect, and has been working on business applications using Microsoft and Oracle technologies since 1996. He obtained Microsoft Certified Systems Engineer (MCSE) in 1998 and Microsoft Certified Solution Developer (MCSD) in 1999. He has experience in ASP.NET, C#, Visual Basic, Windows and Web Services, Silverlight, WPF, JavaScript/AJAX, HTML, SQL Server, and Oracle.

Comments and Discussions

 
QuestionApiDataService Pin
ameur_ca24-Jun-21 10:14
ameur_ca24-Jun-21 10:14 
AnswerRe: ApiDataService Pin
Shenwei Liu30-Jun-21 16:48
Shenwei Liu30-Jun-21 16:48 
QuestionIntegrated in Data-To-Code Pin
ignatandrei11-Dec-20 22:58
professionalignatandrei11-Dec-20 22:58 
QuestionAngular version 10 Pin
Member 115677033-Nov-20 5:16
Member 115677033-Nov-20 5:16 
NgExDialog,NgExTable, ngxMyDatePicker is not support in angular 10
AnswerRe: Angular version 10 Pin
Shenwei Liu6-Dec-20 5:01
Shenwei Liu6-Dec-20 5:01 
PraiseReviewed: Angular Data CRUD with Advanced Practices of Reactive Forms Pin
L.Patrick Boyce11-Sep-20 5:53
L.Patrick Boyce11-Sep-20 5:53 
QuestionSample do not work Pin
sajmon723-Aug-20 1:08
sajmon723-Aug-20 1:08 
GeneralMy vote of 5 Pin
Carsten V2.05-Dec-19 10:55
Carsten V2.05-Dec-19 10:55 
QuestionUpgrade to NET.Core 3.0 Pin
Philipos Sakellaropoulos18-Oct-19 5:16
professionalPhilipos Sakellaropoulos18-Oct-19 5:16 
AnswerRe: Upgrade to NET.Core 3.0 Pin
Shenwei Liu14-Nov-19 11:56
Shenwei Liu14-Nov-19 11:56 
GeneralRe: Upgrade to NET.Core 3.0 Pin
Shenwei Liu5-Dec-19 17:20
Shenwei Liu5-Dec-19 17:20 
QuestionShenwei - some help with setup Pin
DumpsterJuice15-Jul-19 4:15
DumpsterJuice15-Jul-19 4:15 
GeneralMy vote of 5 Pin
Robert_Dyball19-Sep-18 17:34
professionalRobert_Dyball19-Sep-18 17:34 
QuestionIs this really optimal? Pin
Member 1158571813-Aug-18 16:22
Member 1158571813-Aug-18 16:22 
AnswerRe: Is this really optimal? Pin
Shenwei Liu15-Sep-18 18:08
Shenwei Liu15-Sep-18 18:08 
AnswerRe: Is this really optimal? Pin
HaBiX6-Nov-18 23:45
HaBiX6-Nov-18 23:45 
QuestionCannot find your angular typescript code from your download. Pin
Member 1019312023-Jun-18 7:48
professionalMember 1019312023-Jun-18 7:48 
AnswerRe: Cannot find your angular typescript code from your download. Pin
Shenwei Liu14-Jul-18 18:16
Shenwei Liu14-Jul-18 18:16 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.