Angular 数据 CRUD 与响应式表单的高级实践
一个 Angular 示例应用程序,包括使用 HttpClient 服务选择、添加、更新和删除数据,用于对象和数组类型的响应式表单,内联数据列表编辑,自定义输入验证以及各种其他功能(最新更新与 Angular 11 CLI 和 ASP.NET Core 5.0)。
- 下载 NgDataCrud_AspNetCore_Ng11_Cli - 203.6 KB
- 下载 NgDataCrud_AspNet_Ng11_Cli - 205.4 KB
- 下载 ApiDataServices - 509.9 KB
引言
在从 AngularJS 版本 和 Angular 5 版之后重写后,本文和配套示例应用程序已经更新多次。Angular 11 CLI 和 ASP.NET Core 5.0 网站的最新源代码已可供下载。如果您需要以前 Angular 版本的示例应用程序,请参阅本文末尾的 历史部分。
本文和示例应用程序中演示的功能包括
- 使用模态对话框上单个数据对象的响应式表单添加和编辑数据。
- 使用响应式表单和
FormArray
在表格中内联和动态添加和编辑多行数据。 - 使用响应式表单删除多个和选择性数据记录。
- 使用响应式表单方法在添加、更新或删除过程后动态显示刷新数据。
- 在
on-change
过程和on-blur
错误消息显示模式下,对响应式表单进行自定义和内联输入数据验证。 - 离开与 Angular 内部路由器和外部重定向相关的页面时的脏警告。
- 完全支持 RESTful API 数据服务。
- 易于设置以运行示例应用程序。
示例应用程序将以下 Angular 组件或指令在更新的 Angular 11 中作为子文件夹移植到项目根目录。如果需要详细信息,尽管这些工具可能仍是以前版本的 Angular,但读者可以访问原始帖子或源代码存储库。
构建和运行示例应用程序
下载的源包含两种不同的 Visual Studio 解决方案/项目类型。请选择您喜欢的一种或两种并在本地机器上进行设置。您还需要在本地机器上全局安装 node.js(建议版本 14.x LTS 或更高版本)和 Angular CLI(建议版本 11.x 或更高版本)。请查看 node.js 和 Angular CLI 文档以获取详细信息。
您可以在 C:\Program Files (x86)\Microsoft SDKs\TypeScript 文件夹中检查 Visual Studio 可用的 TypeScript 版本。示例应用程序的 ASP.NET 和 Core 类型都在 SM.NgDataCrud.Web.csproj 文件的 TypeScriptToolsVersion
节点中将 Visual Studio 的 TypeScript 版本设置为 4.0。如果您没有安装版本 4.0,请从 Microsoft 网站下载 安装包 或安装包含 TypeScript 4.0 的 Visual Studio 2019 版本 16.8.x。
NgDataCrud_AspNetCore_Cli
-
您需要在本地机器上使用 Visual Studio 2019(版本 16.8.x)。.NET Core 5.0 SDK 包含在 Visual Studio 安装中。
-
下载源代码文件并将其解压到您的本地工作区。
-
进入本地工作区的物理位置,依次双击 SM.NgDataCrud.Web\AppDev 文件夹下的 npm_install.bat 和 ng_build.bat(如果未全局安装 Angular CLI,则为 ng_build_local.bat)文件。
注意:每次更改 TypeScript/JavaScript 代码后,可能都需要执行
ng build
命令,而npm install
的执行仅在 node 模块包有任何更新时才需要。我没有启用 CLI/Webpack 热模块替换,因为它可能会破坏 Visual Studio 中调试的源代码映射。 -
使用Visual Studio 2019打开解决方案,并使用Visual Studio重新构建解决方案。
NgDataCrud_AspNet_Cli
-
下载源代码文件并将其解压到您的本地工作区。
-
进入本地工作区的物理位置,依次双击 SM.NgDataCrud.Web\ClientApp 文件夹下的 npm_install.bat 和 ng_build.bat(如果未全局安装 Angular CLI,则为 ng_build_local.bat)文件(另请参阅设置 NgDataCrud_AspNetCore_Cli 项目的相同注意)。
-
使用 Visual Studio 2017 或 2019 打开解决方案,并使用 Visual Studio 重新构建解决方案。
您可以在 ../src/app 文件夹中查看 Angular 源代码结构。由于 SM.NgDataCrud.Web 项目文件夹和文件中的所有活动 Angular UI 源代码都是纯客户端脚本,您可以将这些文件夹和文件移动到任何其他项目类型,使用不同的打包工具,甚至移动到不同的平台。
SM.NgDataCrud.Web
应用程序与相应的 RESTful API 数据服务和底层数据库一起工作,这些都包含在下载的源代码中。我建议在您的本地机器上设置 SM.Store.CoreApi 解决方案。在使用另一个 Visual Studio 实例打开并构建 SM.Store.CoreApi
解决方案后,您可以从菜单栏上的 IIS Express 按钮下拉列表中选择一个可用的浏览器,然后点击该按钮以启动数据服务 API 应用程序。
无需最初设置数据库,因为 API 应用程序使用当前配置的内存数据库。内置的启动页面将显示从服务方法调用获得的 JSON 格式的响应数据,这只是在开发机器上使用 IIS Express 启动服务应用程序的简单方法。您现在可以最小化 Visual Studio 屏幕并将数据服务 API 在后台运行。您可以查看此 帖子 以获取 SM.Store.CoreApi
数据服务项目的详细信息。
如果您想使用数据服务的旧版 ASP.NET Web API 2 版本,您可以参考 AngularJS 1.x 版本的文章,了解如何在您的机器上设置数据服务项目。SM.Store.WebApi
解决方案代码也包含在本文的下载源中。
在运行示例应用程序之前,您可以检查 ../src/app/Services/app.config.ts 文件中的 RESTful API 数据服务 URL 路径,以确保为正在运行的数据服务设置了正确的 WebApiRootUrl
值。
当所有这些都准备好后,按 F5 启动示例应用程序。您可以在“搜索产品”面板上输入一些参数,或者将所有搜索参数字段留空,然后单击 Go. 按钮。此时应显示产品列表网格。
选择 联系人 左侧菜单项将打开一个页面,其中包含一个表格中填充的联系人列表。本页面实现了内联表格编辑功能,将在后面的部分中展示。
使用模态对话框添加和更新数据
此主题与 AngularJS 版本 相同,因此我不会重复代码和用户案例工作流中的共同点。除了 Angular 版本本身之外,代码的主要变化是新版本使用 响应式表单 模式进行数据显示和可编辑字段条目。这里的响应式表单用于在弹出模态对话框上显示和可编辑的单个对象模型。主要实现概述如下。
-
在 product.component.html 中,在
<form>
标签中指定[fromGroup]
指令,并在表单的可编辑字段元素中指定fromControlName
指令。<form [formGroup]="productForm" (ngSubmit)="saveProduct(productForm)"> <input type="text" name="productName" formControlName="productName" /> - - - </form>
-
在 product.component.ts 中,创建
FromGroup
实例,并在ngOnInit
方法中为所有表单控件设置名称和选项。可选的验证器设置将在后面的部分中讨论。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" })) });
-
为
product
数据定义和使用自定义或基本模型对象model: any = { product: {} };
此
product
对象随后将用于接收来自 AJAX 调用的数据响应,并作为数据源填充响应式表单控件。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 }); }, - - -
提交编辑数据时,基本产品模型会根据响应式表单控件进行更新,并作为 HTTP Post 调用的请求对象。
//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 => { - - - } );
为什么使用类级别的基本模型对象而不是直接将数据绑定到内置的表单组/控件模型?这样做至少有以下优点。
-
表单控件名称或字符大小写可能与响应数据字段(或数据库列)不相同。例如,
model.product
属性和表单控件的名称不同或字符大小写不同,例如“CategoryId
”与“category
”和“StatusCode
”与“status
”。拥有一个基本模型可以使这些差异在整个类中保持一致。 -
基本模型中仅用于数据完整性和处理需求的一些字段可以很容易地从表单控件中排除,例如
ProductId
。 -
基本模型对象是保留原始加载数据的好来源,如果需要,可以随时用于手动进行脏比较,或恢复原始数据显示。请注意,表单组对基本模型对象实例不是可变的。每个表单控件从单个基本模型属性获取值。
-
-
对于添加新产品,弹出对话框也可以用于重复数据记录条目,就像它的 AngularJS 祖先 一样。在任何两个条目之间,基本模型对象实例和表单组都将重置为空值状态。第一个输入字段
Product Name
也将获得焦点,以便快速进行按键操作。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(); }
这里显示了更新产品模态对话框屏幕的示例。
内联添加和更新数据
借助 Angular 响应式表单和 FormArray
结构,在“联系人列表”页面上的双向数据绑定和网格内联数据添加、编辑和删除操作,比其 AngularJS 版本 更高效、优雅且易于实现,尽管屏幕上的外观相同。用户案例工作流方面唯一的改变是简化了状态设置。之前的“编辑”和“添加”状态已合并到“更新”状态。
-
读取:这是数据首次加载或刷新时的默认状态。除了第一列中清除的复选框外,不显示任何输入元素。此屏幕截图与第一部分中显示的相同。
-
更新:勾选任何现有数据行中的
checkbox
或点击 添加 按钮将启用更新状态,此时现有行或新添加行中的所有输入字段都将显示。可以同时选择和/或添加多行进行编辑和提交。如果选择了一行且未更改任何字段值,则用户可以删除现有行。用户还可以随时取消编辑的更改,方法是取消选择行或点击 取消更改 按钮。
在 更新 状态下,使用两个计数数字,addRowCount
和 editRowCount
,来标识添加新行或编辑现有行的工作流程。计数数字将根据要添加或编辑的行数增加或减少。saveChanges
方法根据计数数字提交编辑或添加的数据行的更新。
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);
}
为这个网格内联编辑表单实现 FormGroup
和 FormArray
以及子 FormControl
项有些复杂,但下面是使用这些 Angular 结构完成的主要任务。
创建带有 FormArray 排列的 HTML 元素
下面显示的结构已简化,不包含样式、验证器、条件检查器和按钮的元素和属性。这里,contactControlList
是在组件类中设置的变量,引用 contactForm.controls.contactFmArr.controls
。[formGroupName]="$index"
是一个嵌套的 FormGroup
实例,作为 contactFmArr
数组的一个元素。
<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>
填充 FormArray 实例并将数据绑定到表单控件
从 AJAX 调用获取数据后,原始联系人数据列表将进行深层克隆,以便将来可能进行基于记录的取消或撤消。然后代码调用可重用方法从数组中设置联系人数据值。
//Make deep clone of data list for record-based cancel/undo.
this.model.contactList_0 = glob.deepClone(data.Contacts);
this.resetContactFormArray();
在 resetContactFormArray()
方法中,forEach
循环将每个嵌套的 FormGroup
实例添加为 contactFmArr
数组的一个元素。
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)
});
}
此时可以忽略验证器的代码(详细信息将在后面部分介绍)。我还使用了 FormGroup
,而不是 FormBuilder
对象,因为后者不支持选项“updateOn
”,这可以在代码中使用和测试(请参阅后面部分了解验证器)。此外,这里定义的 ContractId
控件用于保存键 Id
值,在 HTML 视图上没有设置等效元素。幸运的是,数组元素表单组对此没有抱怨。
动态添加新行的 FormGroup 实例
由于 loadContactFormGroup
方法已经定义,所以创建 FormGroup
实例作为 contactFmArr
数组的一个元素非常直接。
//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));
当新的 FormGroup
实例以数组索引号的名称添加到 contactFmArr
数组时,一个新的空行会自动附加到表格并显示在页面上。
使用独立复选框数组选择行
在数组元素表单组区域内的第一个 <td>
元素中添加了一个 checkbox
类型的输入元素。然而,该 checkbox
不包含在基于索引的表单组中。它使用带有 ngModel
指令的模板驱动模式和独立选项。
<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>
这是一个非常好的功能,因为我们通常可以使用响应式表单,但是任何不在表单组和表单数组范围内的独立表单控件。使用这种方法,任何复选框操作都不会影响表单组和表单数组的数据操作的整体状态。例如,我们现在可以监视表单数组是否脏,而无需担心因单击 checkbox
选择一行而导致的意外“脏表单”。
checkboxes.items
数组在 ContactsComponent
类中定义,所有元素在 resetContactFormArray()
方法的 forEach
循环中默认设置为 false
this.checkboxes.items[index] = false;
checkboxes.items
数组的索引号始终与 contactFmArr
数组同步,以进行任何数据行操作。
添加新行时
//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;
删除现有行时
//Remove element from contactFmArr.
(<FormArray>this.contactForm.controls.contactFmArr).removeAt(listIndex);
//Remove element from checkboxes.items.
this.checkboxes.items.splice(listIndex, 1);
取消编辑任务
示例应用程序中取消编辑任务的逻辑比 AngularJS 版本 简化了许多,尽管启动取消过程的方式是相同的。
-
通过调用
cancelChangeRow(listIndex)
方法取消勾选任何已勾选的行。您可以查看代码注释行以获取解释。调用方通过checkbox
点击事件方法提供“放弃更改”警告确认,此处未显示。请注意,contactFmArr
的表单数组的removeAt(listIndex)
方法和checkboxes.items
的splice(listIndex, n)
方法会自动处理数组索引移位,如果删除数组中间位置的任何元素。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; } } }
-
点击 取消更改 按钮或取消选中顶部的
checkbox
(如果已选中)以调用cancelAllChangeRows
方法。这将清除所有已编辑的现有行和已添加的新行。然后表单将重置为最初加载的状态。如果在选择任何现有行后没有更改数据值,该操作将简单地取消选中任何已选中的checkbox
并将表单返回到“读取”状态。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; } }
输入数据验证
Angular 内置的基本验证器通常无法满足业务数据应用程序的需求。因此,我专门为响应式表单创建了全范围的自定义同步验证器。所有验证器函数都包含在 Validator2
类中。读者可以在文件 app/InputValidator/reactive-validator.ts 中查看详细信息。但这里显示了一个用于验证电子邮件地址的函数示例
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;
}
}
}
当初始化包含这些表单控件的表单组实例时,会为表单控件设置验证器。下面的代码已经在前面的部分中部分显示,但这次我们重点关注验证器的选项参数。
对于 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" }))
});
产品表单验证结果显示如下
对于联系人表单
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)
});
}
联系人表单验证结果显示如下
您可能需要了解一些设置和使用验证器的详细信息。
混合使用内置和自定义验证器
如果可用,您仍然可以使用内置的 Validators
,以及自定义的 Validator2
,即使对于同一个表单控件也是如此。请参阅上面显示的 contact.Phone
验证器设置示例。
为自定义验证器传递参数
自定义 Validate2
的任何方法都接受一个对象类型的参数,可以是 ValueArgs
或 RangeArgs
,它们在 validator-common.ts 中定义。
export class ValueArgs {
value?: any;
label?: string;
message?: string;
}
export class RangeArgs {
minValue: any;
maxValue: any;
label?: string;
message?: string;
}
除了 minValue
和 maxValue
(日期或数字范围的任何验证都必须使用)之外,参数对象的所有属性都是可选的(请参阅上面的代码,用于验证 availableSince
字段输入的日期范围)。
如果验证单个数字、日期或文本长度的任何大小,ValueArgs
的 value 属性也是必需的,因为无法为相应的验证器预设默认值(请参见上面 unitPrice
字段输入的 maxNumber
验证器代码)。
显示内联错误消息
在示例应用程序中,任何错误消息都通过可重用的 ValidateErrorComponent
显示,该组件由放置在每个 HTML 输入元素下方的错误标签触发,例如
<errors [control]="productForm.controls.unitPrice"></errors>
<errors [control]="contactControlList[$index].controls.Email" ></errors>
其中表单控件本身被传递给 ValidateErrorComponent
,在该组件中,错误消息被分类并渲染到其子模板。我在此处不列出该组件类的代码。如果感兴趣,读者可以在 app/InputValidator/validate-error.component.ts 文件中查看代码。
处理与验证相关的 On-Change 和 On-Blur 场景
Angular 2 和 4 只使用默认的 on-change
设置来更新模型和验证工作流。Angular 5 或更高版本提供了在 FormGroup
或 FormControl
级别设置 updateOn
值的选项,尽管 AngularJS 中也有等效设置。无论 updateOn
设置如何应用,模型更新和输入验证的时间点都会影响流程工作流和视觉效果。
为了帮助理解本节中的逻辑,有必要列出以下常见的输入数据验证类别
- 全部或无:例如必填字段
- 类型:例如数字或文本
- 大小:例如最小和/或最大数字
- 独占:例如不允许特定符号
- 表达式:例如电子邮件、电话或密码。日期值也是一种特殊的表达式。
现在让我们来玩数据输入,看看使用选项 { updateOn: 'change' }
时错误消息的显示。代码不需要明确存在,因为它是默认设置。这次我们也不关注性能影响。
- 对于除 Expression 之外的所有数据输入类别,只要在键入过程中违反规则,就会立即显示错误消息,这符合预期。
-
表达式验证规则会检查整个数据输入,而
on-change
场景会为不符合规则的任何单个字符条目渲染并显示错误,这不是我们想要的。
如果我们通过将其添加到 FromGroup
初始化中,将选项更改为 { updateOn: 'blur' }
呢?
this.productForm = new FormGroup({
//Form controls for input fields.
- - -
}, { updateOn: 'blur' });
这解决了 Expression 数据输入的错误消息显示问题。任何错误消息都会在输入字段失去焦点后显示。但这也会带来一个“无最后失焦”问题,即当鼠标指针直接从带有验证器的最后一个输入字段移动到操作或取消按钮时。
-
问题 #1:如果表单有效且脏时,“保存”按钮将动态启用,那么除非您单击任何其他元素或空白区域以使
on-blur
事件生效,否则该按钮将不会启用点击操作。此问题可以通过使用稍后描述的“虚拟禁用按钮”方法来解决。<button type="submit" [disabled]="!(productForm.valid && productForm.dirty)">Save</button>
-
问题 #2:当最后一个输入字段违反验证规则但尚未失去焦点时,点击“取消”按钮会第一次将焦点转移到该按钮并显示错误消息。然后需要第二次点击才能发送实际命令。将
click
更改为mousedown
事件似乎在按钮获得焦点之前触发了on-blur
事件,但输入字段的on-blur
事件已被绕过。结果,无效输入未经验证。因此,脏表单可能会在没有任何通知的情况下被卸载。 -
问题 #3:当鼠标指针从一个值已更改的输入字段移动到另一个可用的路由器/菜单项、浏览器后退按钮,甚至浏览器的关闭按钮时,由于无法执行
on-blur
模型更新和验证,全局脏警告不会触发。使用on-change
模式没有这样的副作用。请参阅下一节中全局脏警告主题的更多详细信息。
以下是解决这些问题的变通方法
-
对所有模型更新和输入数据验证使用默认的
on-change
模式。所有非表达式数据验证、保存和取消按钮操作以及全局脏警告都应在设置下正常工作。 -
延迟可能的错误消息,直到字段失去焦点,以处理任何表达式数据输入。首先,我们需要向表单控件添加一个自定义属性
showInvalid
,其默认值为true
。//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; } }
该标志值从任何包含需要验证的表达式数据的输入控件的
focus
和blur
事件中切换。这里我们仍然以productForm
中的availableSince
控件为例。在 product.component.html 中
<input type="text" formControlName="availableSince" (focus)="setShowInvalid(productForm.controls.availableSince, 0)" (blur)="setShowInvalid(productForm.controls.availableSince, 1)"/>
product.component.ts 中的
setShowInvalid
函数//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; } }
在
ValidateErrorComponent
(app/InputValidator/validate-error.component.ts) 中,showInvalid
属性检查器被添加到showErrors
方法中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; }
-
实现虚拟禁用按钮。对于 保存 按钮,既不使用禁用指令也不使用 JavaScript 代码直接禁用按钮。但是,按钮的外观和感觉仍然可以通过
ngClass
设置在启用和禁用状态之间切换。随时单击按钮都会将命令发送到ProductComponent
类中的saveProduct
方法。如果表单无效或不脏,则该过程将在方法的第一行停止,以实现相同的禁用效果。在 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>
在 product.component.cs 中
saveProduct(productForm: FormGroup) { //Need to check and exit if form is invalid for "onblur" validation. if (productForm.invalid || !productForm.dirty) return; - - - }
在浏览器上,当在 可提供日期 字段中输入无效日期值时,如下所示
然后立即将鼠标移动到 保存 按钮。显示内联验证错误消息,并且 保存 按钮由于脏且无效的表单状态而实际上被禁用。
您可以通过暂时将 product.component.ts 和 product.component.html 替换为文件夹中具有相同名称的文件来测试上面提到的所有场景和情况
-
Test_Replacement/ProductComponent_OnChange:用于所有
on-change
仅验证工作流操作。 -
Test_Replacement/ProductComponent_OnBlur:用于所有
on-blur
仅验证工作流操作。 -
Test_Replacement/ProductComponent_Final:与下载时正常 app/PageContents 文件夹中的文件相同,它们使用自定义
on-change
验证和on-blur
错误消息显示用于表达式数据输入。在on-change
和on-blur
仅验证测试后,将文件复制回 app/PageContents 文件夹将使代码恢复为下载的原始代码。
注意:更改 .ts 和 .html 文件后,刷新浏览器窗口或重新启动应用程序时,请确保代码已构建,并且浏览器的缓存图像和文件已清除。
离开页面时的脏警告
在示例应用程序的 AngularJS 版本 中,实现了两种方法来呈现脏警告
-
基于 AngularJS 作用域的
$locationChangeStart
:此事件可由任何内部路由切换和从任何外部站点重定向回 AngularJS 路由应用程序 URL 触发。处理程序可以通过调用event.preventDefault
方法取消。 -
原生 JavaScript
window.onbeforeunload
:此事件在离开 AngularJS 应用程序到任何外部站点时触发,包括刷新页面和关闭浏览器。
在 Angular 中,window.onbeforeunload
仍然像 AngularJS 版本中一样正常工作,因为它是一个原生 JavaScript 函数。然而,在路由之间切换的等效方法 NavigationStart
失去了原生事件引用,因此无法取消当前路由过程并根据用户的否定响应停留在当前页面。
幸运的是,Angular 提供了 ComponentCanDeactivate
接口和 canDeactivate
方法,我们可以将其实现为路由守卫。我已将此方法用作此示例应用程序中全局脏警告的替代方案。以下是实现细节。
-
在 app/Services/globals.ts 中将全局变量定义为脏标志。
export let caches: any = { pageDirty: false, - - - };
-
在
DirtyWarning
类(app/Services/dirty-warning.ts)中将ComponentCanDeactivate
实现为服务。window.confirm
对话框和自定义消息文本在canDeactivate
方法中设置。还包括关闭任何可能已打开的exDialog
对话框的逻辑。@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; } }
-
在 app.module.ts 中注册此服务
@NgModule({ - - - providers: [ [DirtyWarning], ], - - - })
-
将
canDeactivate
作为属性添加到每个路由的路径对象中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] } ];
-
在需要脏警告的组件中创建
canDeactivate
方法,该方法返回全局脏标志值。对于ProductComponent
,此方法应放置在其父组件ProductListComponent
中。//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; } }
-
使用表单的
valueChanges
方法,在表单的脏状态发生变化时更新全局脏标志。//Update global dirty flag. this.productForm.valueChanges.subscribe((x) => { if (this.productForm.dirty) { glob.caches.pageDirty = true; } else { glob.caches.pageDirty = false; } })
这种路由守卫类型的全局脏警告按预期正常工作。在 Chrome 上,相同类型的对话框用于 Angular 内部路由和浏览器重定向。显示的是浏览器内置的文本消息,而不是我们放在代码中的自定义消息。
对于 IE 11,Angular 内部路由和外部浏览器重定向的对话框外观略有不同。但我们的自定义警告消息分别显示在对话框中。
Angular 内部路由重定向时显示的对话框
外部浏览器重定向时显示的对话框
由于示例应用程序是使用 on-change
模型更新和验证实现的,但部分使用了 on-blur
错误消息显示,因此脏警告过程始终正常工作,没有“无最后失焦”问题。如果您好奇“无最后失焦”问题如何影响全局脏警告,您可以通过以下步骤重现该问题。
-
将 app/PageContents 文件夹中的 product.component.ts 和 product.component.html 替换为 Test_Replacement/ProductComponent_OnBlur 文件夹中的文件。
-
启动网站,从左侧菜单中选择 联系人。
-
从左侧菜单中选择 产品列表,点击 Go 按钮,然后点击 添加产品 按钮。
-
在 产品名称 字段中输入任意文本。
-
直接将鼠标指针移至浏览器后退按钮并点击。
浏览器将返回到“联系人”页面,没有任何通知,而预期结果应显示一个脏警告对话框。在您将 product.component.ts 和 product.component.html 从 Test_Replacement/ProductComponent_Final 文件夹复制回来并重复上述步骤 2 - 5 后,您可以看到正常行为。
注意:更改 .ts 和 .html 文件后,刷新浏览器窗口或重新启动应用程序时,请确保代码已构建,并且浏览器的缓存图像和文件已清除。
摘要
随着 Angular 越来越成熟,复杂数据 CRUD 业务 Web 应用程序的开发变得可行,尤其是使用响应式表单结构。本文介绍的 Angular 示例应用程序已从 AngularJS 版本 迁移而来,并且在迁移过程中解决了所有问题。本文描述了大多数问题的实现细节和解决方案。希望示例应用程序和讨论能为使用 Angular 进行 Web 应用程序开发提供有用的资源。一如既往,我很高兴与开发人员社区分享代码和我的经验。
历史
- 2018 年 6 月 17 日
- Angular 5 版本示例应用程序的原始帖子
- 2018 年 8 月 10 日
- 添加了 Angular 6 版本的示例应用程序
- 重写了设置说明
- 2018 年 9 月 7 日
- 添加了 ASP.NET Core 2.1 与 Angular CLI 6 的项目类型
- 重写了设置说明
- 重新构建了下载源,其中仅包含 Angular 6 的源代码
- 如果您需要 Angular 5 的源代码(仅有 Webpack 和 SystemJs 的项目类型可用),您可以在 此处下载。
- 2018 年 11 月 4 日
- 添加了带有 Angular CLI 6 的 ASP.NET 5 项目类型
- 更新并简化了示例应用程序的设置过程,以便读者可以更专注于实际应用程序内容
- 2018 年 12 月 12 日
- 使用更新的 NgExTable 和 NgExDialog 工具
- 更新了文章的格式
- 更新了
NgDataCrud_AspNetCore_Cli
项目类型设置结构,其中包含代码中的纯客户端配置和文章中的设置说明 - 如果您想拥有包含服务器端
UseSpa
中间件的旧项目源代码,您可以在 此处下载 zip 文件
- 2019 年 10 月 14 日
- 更新了 Angular 8 CLI 和 Bootstrap 4.3 CSS 中的源代码
- 修复了源代码中的一些小错误
- 编辑了某些部分的文本
- 如果需要,您可以下载旧版源代码,其中包含 Angular 6 CLI 和 Bootstrap 3.3 CSS:NgDataCrud_Ng6_Cli_All.zip
- 2019 年 12 月 5 日
- 添加了 ASP.NET Core 3.0 网站的源代码,适用于 Visual Studio 2019
- ASP.NET Core 3.0 示例应用程序的设置说明
- 包含 ASP.NET Core 3.0 数据服务应用程序的
ApiDataService
源代码 - 修复了更新/添加联系人数据项代码中的错误
- 2020 年 12 月 6 日
- 更新了 Angular 11 和 ASP.NET Core 5.0 版本的示例应用程序源代码
- 编辑了与源代码更新相关的文章文本
- 包含了用 ASP.NET Core 5.0、3.1、2.1 和 ASP.NET Web API 2.0 编写的数据服务应用程序的
ApiDataService
源代码 - 如果您需要使用以前的 Angular 版本 8、9 或 10 运行示例应用程序,您可以下载应用程序的 package.json 文件 Package.json_Ng8-9-10.zip,用您想要的版本替换现有应用程序中的 package.json 文件,然后按照 构建和运行示例应用程序 部分中的说明进行操作。示例应用程序的 Angular 11 源代码与 Angular 8、9 和 10 完全兼容,没有大的破坏性更改。