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
-
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.
-
Download and unzip the source code file to your local work space.
-
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.
-
Open the solution with the Visual Studio 2019, and rebuild the solution with the Visual Studio.
NgDataCrud_AspNet_Cli
-
Download and unzip the source code file to your local work space.
-
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).
-
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.
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.
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.
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.
-
In the product.component.html, specify the [fromGroup]
directive in the <form>
tag and fromControlName
directive in the form’s editable field elements.
<form [formGroup]="productForm" (ngSubmit)="saveProduct(productForm)">
<input type="text" name="productName" formControlName="productName" />
- - -
</form>
-
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.
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" }))
});
-
Define and use a custom or base model object for the product
data:
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.
let pThis: any = this;
this.httpDataService.get(url).subscribe(
data => {
data.UnitPrice = parseFloat(data.UnitPrice.toFixed(2));
data.AvailableSince = { jsdate: new Date(data.AvailableSince) };
pThis.model.product = data;
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.
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.
-
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.
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.
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.
-
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.
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.
if (this.editRowCount > 0) {
this.httpDataService.post(ApiUrl.updateContacts, editItemList).subscribe(
data => {
if (this.addRowCount > 0) {
this.doSaveAddNewRows(temp2);
}
else {
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.
<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.
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.
resetContactFormArray() {
let pThis: any = this;
- - -
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;
});
pThis.contactControlList = pThis.contactForm.controls.contactFmArr.controls;
}
loadContactFormGroup(contact?: any): FormGroup {
return new FormGroup({
"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.
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.
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.
<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:
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:
(<FormArray>this.contactForm.controls.contactFmArr).push(this.loadContactFormGroup(newContact));
this.checkboxes.items[this.checkboxes.items.length] = true;
When removing an existing row:
(<FormArray>this.contactForm.controls.contactFmArr).removeAt(listIndex);
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.
cancelChangeRow(listIndex) {
let hasChecked: boolean = false;
for (let i = 0; i < this.checkboxes.items.length; i++) {
if (this.checkboxes.items[i]) {
hasChecked = true;
break;
}
}
if (!hasChecked) {
this.resetContactFormArray();
}
else {
if (listIndex > this.maxEditableIndex) {
(<FormArray>this.contactForm.controls.contactFmArr).removeAt(listIndex);
this.checkboxes.items.splice(listIndex, 1);
this.addRowCount -= 1;
}
else {
(<FormArray>this.contactForm.controls.contactFmArr).controls
[listIndex].reset(glob.deepClone(this.model.contactList_0[listIndex]));
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.
cancelAllChangeRows(callFrom) {
if ((<FormArray>this.contactForm.controls.contactFmArr).dirty ||
callFrom == "cancelButton") {
this.exDialog.openConfirm({
title: "Cancel Confirmation",
message: message
}).subscribe((result) => {
if (result) {
pThis.resetContactFormArray();
}
else {
if (callFrom == "topCheckbox")
pThis.checkboxes.topChecked = true;
}
});
}
else {
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:
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
:
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:
For the contact form:
loadContactFormGroup(contact?: any): FormGroup {
return new FormGroup({
"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:
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.
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:
<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.
What if we change the option to { updateOn: 'blur' }
by adding it to the FromGroup
initiation?
this.productForm = new FormGroup({
- - -
}, { 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.
<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
.
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:
<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:
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:
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:
<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:
saveProduct(productForm: FormGroup) {
if (productForm.invalid || !productForm.dirty) return;
- - -
}
On the browser, when entering the invalid date value to the Available Since field like this:
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.
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.
-
Defining a global variable as the dirty flag in the app/Services/globals.ts.
export let caches: any = {
pageDirty: false,
- - -
};
-
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.
@Injectable()
export class DirtyWarning implements CanDeactivate<ComponentCanDeactivate> {
constructor(private exDialog: ExDialog) { }
canDeactivate(component: ComponentCanDeactivate): boolean | Observable<boolean> {
let rtn = component.canDeactivate();
if (rtn) {
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.")) {
if (this.exDialog.hasOpenDialog()) {
this.exDialog.clearAllDialogs();
}
glob.caches.pageDirty = false;
rtn = true;
}
else {
rtn = false;
}
}
return rtn;
}
}
-
Registering this service in the app.module.ts:
@NgModule({
- - -
providers: [
[DirtyWarning],
],
- - -
})
-
Adding the canDeactivate
as a property into each route’s path object:
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] }
];
-
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
.
canDeactivate(): Observable<boolean> | boolean {
if (glob.caches.pageDirty) {
return false;
}
else {
return true;
}
}
-
Using the form’s valueChanges
method to update the global dirty flag whenever the dirty status of the form is changed.
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.
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:
The dialog box shown for the external browser redirections:
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.
-
Replace the product.component.ts and product.component.html in the app/PageContents folder with the files in the Test_Replacement/ProductComponent_OnBlur folder.
-
Start the website, select Contacts from the left menu.
-
Select Product List from the left menu, click Go button, and then click Add Product button.
-
Enter any text into the Product Name fields.
-
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.