65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 AngularJS 和 Web API 进行全面的 CRUD 操作

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (77投票s)

2015年9月23日

CPOL

22分钟阅读

viewsIcon

206080

downloadIcon

8093

在具有 AngularJS 和 WebAPI 的 Web 应用程序上进行详细的 CRUD 操作

 

更新通知 (2018年6月17日)

包含 AngularJS 1.5x 组件和 TypeScript 的示例应用程序源代码可供下载。请前往 SM.Store.Client.Web/Scripts/appConfig.ts 文件,将 apiBaseUrl 的值更改为您的 Web API 地址。当您使用 Visual Studio 2015 Update 3 或 Visual Studio 2017(及更高版本)运行更新后的示例应用程序时,无需进行任何额外配置。如果您对使用 Angular 编写的新示例应用程序感兴趣,请阅读我的文章《Angular 数据 CRUD 与响应式表单的高级实践》,并在那里下载源代码文件。

引言

我之前的一篇文章展示了如何使用 AngularJS 和 Web API 访问和显示服务器端分页数据集。在这篇文章中,我扩展了这个主题,并使用 CRUD (创建, 读取, 更新, 和 删除) 数据操作增强了示例应用程序。尽管新示例应用程序和文章中的讨论主要强调添加、更新和删除数据,但在数据更改过程之前和之后,读取数据始终应涉及加载和刷新数据集。文章和示例应用程序中呈现的重要功能包括:

  • 使用模态对话框添加和更新数据项
  • 在表格中内联添加和更新多行数据
  • 在表格中删除多个数据记录
  • 提交数据更改后动态刷新显示
  • 自定义、内联和失去焦点(on-blur)样式下的输入数据验证
  • 执行命令的活动模式
  • 离开当前页面时对 Angular SPA 内部和外部重定向的脏数据警告
  • 完整的 Web API 应用程序和代码,以促进 CRUD 工作流程,尽管 Web API 的详细信息未在文章中描述

示例应用程序中使用了以下主要库和工具:

  • AngularJS 1.2.6 (更新版本 1.5.8)
  • Bootstrap 3.1
  • ngTable
  • ngExDialog
  • Web API 2.2
  • .NET Framework 4.5
  • Entity Framework 6.1
  • SQL Server 2012 或 2014 LocalDB
  • Visual Studio 2013 或 2015 及 IIS Express(更新版本:Visual Studio 2015 Update 3 或更高版本)

设置和运行示例应用程序

下载的源代码包含两个独立的 Visual Studio 解决方案,分别用于 Web API 和客户端网站。每个解决方案将使用一个 Visual Studio 实例打开。

以下是设置和运行 SM.Store.WebApi 解决方案的说明:

  • 示例应用程序默认连接到 SQL Server LocalDB 2014。如果您使用 2012 版本,则需要在 web.config 文件中启用相应的 connectionString

  • 重新构建 SM.Store.WebApi 解决方案,它将自动从 NuGet 下载所有配置的库。您的本地机器需要有活跃的互联网连接。

  • 确保将 SM.Store.Api.Web 设置为启动项目,然后按 F5。这将启动 IIS Express 和 Web API 主机站点,自动在您的 LocalDB 实例中创建数据库,并用所有示例数据记录填充表格。

  • Web API 项目中的一个测试页面将被渲染,表明 Web API 数据提供程序已准备就绪。您可以将该页面最小化。

  • 如果您在运行测试客户端网站时不需要在调试模式下进入 Web API 代码,您可以在完成上述初始设置后,每次通过在命令提示符中执行以下行来简单地启动 Web API 的 IIS Express:

    如果您使用 Visual Studio 2013 构建 SM.Store.WebApi 解决方案

    "C:\Program Files\IIS Express\iisexpress.exe" /site:SM.Store.Api.Web"

    如果您使用 Visual Studio 2015 或更高版本构建 SM.Store.WebApi 解决方案(将 [WebApiSolutionPath] 替换为您的本地驱动器中的路径)

    "C:\Program Files\IIS Express\iisexpress.exe" /site:SM.Store.Api.Web 
    /config:"[WebApiSolutionPath]\SM.Store.WebApi\.vs\config\applicationhost.config"

由于 SM.Store.Client.Web 解决方案中只有一个项目,并且该项目将 index.html 设置为起始页,您只需从 Visual Studio 打开解决方案并按 F5 即可运行。默认显示 产品列表 页面,并在表格中显示分页数据项。本节将讨论在该页面上使用模态对话框添加和更新数据。

选择 联系人列表 顶部菜单项将打开一个页面,其中表格中填充了数据记录。该页面实现了内联表格编辑功能,这将在稍后的部分中展示。

使用模态对话框上的数据表单

使用单独页面或模态对话框上的数据表单管理数据非常常见,尤其是在需要将数据显示为只读的表格或网格中。这种设计对于更复杂的表格或网格结构很有益,例如分页、分组甚至分层数据集。之前创建的 AngularJS 模态对话框 ngExDialog 可以轻松用于数据添加和更新操作,因为视图模板可以自定义,并且完全支持数据 AJAX 调用。感兴趣的读者可以从我的上一篇文章中查看 ngExDialog 基本实现和代码的详细信息。

在“**产品列表**”页面上,通过点击表格列中的“**产品名称**”链接文本,传入现有产品ID值作为参数id,打开模态对话框,然后通过AJAX调用获取数据,从而启动现有数据编辑模式。点击页面底部的“**添加**”按钮将为id传入未定义值,并打开一个带有空数据表单的模态对话框。用于打开数据表单对话框的参数对象包括beforeCloseCallback属性,该属性在关闭模态对话框之前执行refreshGrid函数,以刷新主表中已添加或更新的数据记录。

//Called from clicking Product Name link in table.
$scope.paging.openProductForm = function (id) {
    $scope.productId = undefined;
    if (id != undefined) {
        $scope.productId = id;
    }
    exDialog.openPrime({
        scope: $scope,
        template: 'Pages/_product.html',
        controller: 'productController',
        width: '450px',
        beforeCloseCallback: refreshGrid
    });
};

这里显示了“**更新产品**”模态对话框屏幕的示例。

虽然在打开的对话框中编辑和保存现有数据是基于单记录的过程,但添加和保存新数据可以在刷新数据表之前在同一个数据表单对话框上重复执行多次记录。此功能通过在提交当前记录后清除所有输入字段的先前数据值来实现。用户可以响应确认对话框,选择是否继续添加新数据项。

您还可以通过 maxAddPerLoad 变量的值来指定在刷新数据表之前可以添加的最大记录数。当达到预设值(默认为 10)时,对话框将通知用户即将添加的最后一条新记录。

请注意,在当前的实现中,页面数据提交和从 Web API 调用数据库仍然是基于单记录的,尽管可以将重复添加的新数据项累积起来,并通过单个数据库调用提交新数据记录数组。

添加新产品时,会创建 $scope.newProductIds 数组来缓存从 Web API 响应中返回的产品 ID 值,以便将新数据项添加到数据库中。使用此数据数组有两个目的。一是跟踪添加的记录数量。另一个是检索一组新添加的数据记录,表格将用这些记录进行刷新。用户可以清晰地查看添加了哪些新数据行。

使用添加的数据行刷新表格需要获取不同于常规过滤和分页数据集的数据集。此过程通过在 JSON 对象中传递 $scope.newProductIds 数组来调用相同的 Web API 方法 GetProductList_P()。Web API 控制器中的方法随后将调用重定向,以根据 $scope.newProductIds 数组列表中的值检索新添加的数据记录。

AngularJS 控制器中的代码如下:

//For refreshing add-new data. 
if ($scope.newProductIds.length > 0) {
    filterJson.json += "\"NewProductIds\": " + JSON.stringify($scope.newProductIds) + ", "
    //Reset array.
    $scope.newProductIds = [];
}
else {
    //Build normal filtered, sorted, and paginated filterJson.json string.
    ...
}

Web API 控制器中的代码如下:

[Route("~/api/getproductlist_p")]
public ProductListResponse Post_GetProductList([FromBody] GetProductsBySearchRequest request)
{
    . . .
    if (request.NewProductIds != null && request.NewProductIds.Count > 0)
    {
        //For refresh data with newly added products.
        IList<Models.ProductCM> rtnList = bs.GetProductListNew(request.NewProductIds);
        resp.Products.AddRange(rtnList);
    }
    else
    {
        //For obtaining regular filtered, sorted, and paginated data list.
		. . .
        IList<Models.ProductCM> rtnList = bs.GetProductList(request.searchParameters);
        resp.Products.AddRange(rtnList);        
    }
    return resp;
} 

表格中的内联数据编辑

凭借 AngularJS 的双向数据绑定功能,数据表上的内联编辑操作可以更有效、更优雅。与许多UI布局中,在列中放置相同按钮、超链接或图标数组用于行的添加、编辑和提交操作不同,示例应用程序的“**联系人列表**”页面展示了更精确的用户界面,可以为单个请求提交执行内联添加或更新多行。

页面具有这些状态设置:

  • 读取:这是数据最初加载或刷新时的默认状态。它也可以是“添加”和“编辑”状态设置之间的中间状态。下面的截图与上一节“设置和运行示例应用程序”中所示相同,但在此再次显示,以便于与随后的“编辑”和“添加”状态进行比较。

  • 编辑:通过勾选复选框选择任何现有数据行将启用该行中的所有输入字段。可以同时选择多行进行编辑。用户可以编辑并提交字段值,也可以取消更改。

  • 添加:在表格下方,每次点击“**添加**”按钮(当它启用时)都会附加一个空行用于新数据输入。“添加”和“编辑”状态设置是互斥的,这意味着任何数据操作都必须在切换到另一种状态之前完成或取消。

以下是与控制和跟踪这些状态设置相关的重要变量或结构。

  • maxEditableIndex:将新添加的数据记录与现有数据记录分开进行编辑的位置。任何大于此值的索引号都表示该项是一个新的数据记录。

  • $scope.checkboxes.items:一个用于复选框项的数组。重要的一点是,复选框数组的大小和项位置应与 contactList 数组的大小和项位置相同。两个数组的索引号是一对一关联的,尽管数据表中的复选框字段不是 contactList 的成员。

  • $scope.model.contactList_0:主数据列表的深拷贝,在初始数据加载期间填充。它是用于检查脏状态和恢复数据更改的基础数据记录。表单的 $dirty 属性反映了除表单中真实数据记录之外的更改,例如复选框值更改,因此不能用于检查主数据列表是否脏。

  • $scope.rowDisables:一个整数数组,临时缓存“添加”状态下禁用的现有行 checkbox 项的索引号。

  • $scope.addRowCount:一个用于跟踪新添加行的数字。该数字大于 0 表示“添加”状态。

  • $scope.editRowCount:一个用于跟踪正在编辑的现有行的数字。该数字大于 0 表示“编辑”状态。

  • $scope.isAddDirty:如果为 true,则任何新添加的行中都输入了数据。

  • $scope.isEditDirty:如果为 true,则任何现有行中都进行了更改。

“**联系人列表**”页面的代码使用自定义标志 $scope.isAddDirty$scope.isEditDirty,而不是 AngularJS 内置的 form.$dirty。原因是复选框中任何与实际数据项无关的值更改都会使 form.$dirty 变为 true

向表中添加新行的代码非常直接。它还包括限制新添加项行最大数量的逻辑。默认的 $scope.maxAddNumber = 10 设置在控制器级别。

$scope.addNewContact = function () {        
    //Set max added-row number limit.
    if ($scope.addRowCount + 1 == $scope.maxAddNumber) {
        exDialog.openMessage({
            scope: $scope,
            title: "Warning",
            icon: "warning",
            message: "The maximum number (" + $scope.maxAddNumber + ") 
                      of added rows for one submission is approached."
        });            
    }      

    //Add empty row to the bottom of grid.
    var newContact = {
        ContactID: 0,
        ContactName: '',
        Phone: '',
        Email: '',
        PrimaryType: 0
    };
    $scope.model.contactList.push(newContact);

    //Add new item to base array.
    $scope.model.contactList_0.push(angular.copy(newContact));

    //Add to checkboxes.items.        
    seqNumber += 1;
    $scope.checkboxes.items[maxEditableIndex + seqNumber] = true;

    //Update addRowCount.
    $scope.addRowCount += 1;                
};

关于复选框的代码逻辑有些复杂。它将启用“**编辑**”状态,或取消“**编辑**”或“**添加**”状态设置。$scope.listCheckboxChange() 函数在通过勾选或取消勾选 checkbox 选择或取消选择行时执行主要操作。

$scope.listCheckboxChange = function (listIndex) {        
    //Click a single checkbox for row.
    if ($scope.checkboxes.items[listIndex]) {
        //Increase editRowCount when checking the checkbox.
        $scope.editRowCount += 1;            
    }
    else {
        //Cancel row operation when unchecking the checkbox.
        if (listIndex > maxEditableIndex) {
            //Add status.
            if (dataChanged($scope.model.contactList[listIndex],
                            $scope.model.contactList_0[listIndex])) {                
                exDialog.openConfirm({
                    scope: $scope,
                    title: "Cancel Confirmation",
                    message: "Are you sure to discard changes and remove this new row?"
                }).then(function (value) {
                    cancelAddRow(listIndex);
                }, function (forCancel) {
                    undoCancelRow(listIndex);
                });
            }
            else {
                //Remove added row silently.
                cancelAddRow(listIndex);
            }
        }
        else {
            //Editing mode.
            if (dataChanged($scope.model.contactList[listIndex],
                            $scope.model.contactList_0[listIndex])) {
                //Popup for cancel.
                exDialog.openConfirm({
                    scope: $scope,
                    title: "Cancel Confirmation",
                    message: "Are you sure to discard changes and cancel editing for this row?"
                }).then(function (value) {
                    cancelEditRow(listIndex, true);
                }, function (forCancel) {
                    undoCancelRow(listIndex);
                });
            }
            else {                    
                //Resume display row silently.
                cancelEditRow(listIndex);
            }                
        }
    }        
    //Sync top checkbox.
    if ($scope.addRowCount > 0 && $scope.editRowCount == 0)        
        //Always true in Add status.
        $scope.checkboxes.topChecked = true;
    else if ($scope.addRowCount == 0 && $scope.editRowCount > 0)
        $scope.checkboxes.topChecked = !hasUnChecked();
};

取消编辑的行是通过将对象项从基础数据数组复制回来并从 $scope.editRowCount 中减去 1 来完成的。

var cancelEditRow = function (listIndex, copyBack) {
    if (copyBack) {
        //Copy back data item.
        $scope.model.contactList[listIndex] = 
               angular.copy($scope.model.contactList_0[listIndex]);
    }
    //Reduce editRowCount.
    $scope.editRowCount -= 1;
};

取消已添加的新行会导致该行被移除。然而,当移除数组中非最后位置的任何行时,可能会出现问题。如果这样做,剩余的行位置和索引号可能会在数据项数组中向前移动,导致数据项分配错误。为了解决这个问题,任何已添加的新行,如果被取消,仍将保留在数组中,但标记为 undefined。相应的数据绑定迭代将排除那些具有 undefined 值的行项。

cancelAddRow() 函数中的代码处理了所有可能的情况,以正确移除行项。

var cancelAddRow = function (listIndex) {
    //Handles array element position shift issue. 
    if (listIndex == $scope.checkboxes.items.length - 1) {
        //It's the last row.
        //Remove rows including all already undefined rows after the last active (defined) row.
        for (var i = listIndex; i > maxEditableIndex; i--) {
            //Do contactList_0 first to avoid additional step in watching cycle.
            $scope.model.contactList_0.splice(i, 1);
            $scope.model.contactList.splice(i, 1);
            $scope.checkboxes.items.splice(i, 1);

            //There is only one add-row.
            if (i == maxEditableIndex + 1) {
                //Reset addRowCount.
                $scope.addRowCount = 0;

                //Reset seqNumber.
                seqNumber = 0;
            }
            else {
                //Reduce $scope.addRowCount.
                $scope.addRowCount -= 1;

                //Exit loop if next previous row is not undefined.
                if ($scope.model.contactList[i - 1] != undefined) {
                    break;
                }
            }
        }
    }
    else {
        //It's not the last row, then set the row to undefined.
        $scope.model.contactList_0[listIndex] = undefined;
        $scope.model.contactList[listIndex] = undefined;
        $scope.checkboxes.items[listIndex] = undefined;

        //Reduce $scope.addRowCount
        $scope.addRowCount -= 1;
    }        
};

相应的 ng-if 检查器已添加到 contactList.html 中用于 ng-repeat 操作的 tr 标签中。

<tr ng-repeat="item in $data" ng-if="$data[$index] != undefined">

取消操作可以根据数据工作流程的阶段进行进一步的不同处理。

  1. 原始状态:数据或空项已加载,或输入框已获得焦点,但未输入或更改任何数据值。此情况的条件是“$scope.addRowCount > 0 and $scope.isAddDirty == false”(针对“添加”状态)和“$scope.editRowCount > 0 and $scope.isEditDirty == false”(针对“编辑”状态)。在此阶段的取消操作应静默执行,无需任何确认过程。

  2. 脏状态:任何输入字段中存在任何添加或更改的数据值。此情况的条件是“$scope.isAddDirty == true”(针对“添加”状态)和“$scope.isEditDirty == true”(针对“编辑”状态)。会显示确认对话框,允许用户选择是继续取消还是返回到上一个屏幕。

所有工作行的“**添加**”或“**编辑**”状态也可以一次性取消,通过点击“**取消更改**”按钮调用 $scope.cancelChanges() 函数,或者通过取消勾选列标题中的 checkbox(顶部复选框)调用 $scope.topCheckboxChange() 函数。前者将取消所有工作行上的操作,无论这些行是否全部被选中。后者仅当所有行都被选中以进行当前状态时才执行取消操作。点击按钮和取消勾选顶部复选框的操作都会在处于“**添加**”状态时调用 cancelAllAddRows() 函数,或在处于“**编辑**”状态时调用 cancelAllEditRows() 函数。感兴趣的读者可以从下载的源代码中查看这些函数的代码详情。

删除表格中的数据行

删除数据行始终可以以内联和多行样式执行。通过勾选 checkbox 选择任何行将启用删除操作,并生成一个包含所选产品 ID 值的数组。然后代码将使用 deleteProducts 数据服务调用 Web API 方法。根据通用规则,delete 操作在执行之前始终需要确认。

$scope.deleteContacts = function () {
    var idsForDelete = [];
    angular.forEach($scope.checkboxes.items, function (item, index) {
        if (item == true) {
            idsForDelete.push($scope.model.contactList[index].ContactID);
        }
    });
    if (idsForDelete.length > 0) {
        var temp = "s";
        var temp2 = "s have"
        if (idsForDelete.length == 1) {
            temp = "";
            temp2 = " has";
        }
        exDialog.openConfirm({
            scope: $scope,
            title: "Delete Confirmation",
            message: "Are you sure to delete selected contact" + temp + "?"
        }).then(function (value) {
            deleteContacts.post(idsForDelete, function (data) {
                exDialog.openMessage({
                    scope: $scope,
                    message: "The " + temp2 + " successfully been deleted."
                });
                //Refresh grid - dummy setting just for triggering data re-load. 
                $scope.tableParams.count($scope.tableParams.count() + 1);

            }, function (forCancel) {
                exDialog.openMessage
                  ($scope, "Error deleting contact data.", "Error", "error");
            });
        });
    }
};

调用上述函数时,删除确认对话框和底层屏幕如下所示:

输入数据验证

AngularJS 提供了基于作用域的表单和元素验证控制对象,以促进输入验证和内联消息显示。我们将这些称为“作用域表单对象”和“作用域元素对象”,以避免与 DOM 表单对象和 DOM 元素对象混淆。示例应用程序使用了 ngValidator,这是一个最初从 GitHub 下载的自定义验证指令,但为了扩展功能进行了大量修改。示例应用程序中的验证过程在任何输入字段失去焦点时触发,这被称为失去焦点事件类型验证(on-blur),尽管指令中也提供了其他事件类型,例如 on-dirty 和 on-submit。您会发现失去焦点验证更自然、更用户友好。ngValidator 中的代码逻辑不是本文讨论的重点。感兴趣的读者可以在 directives.js 中浏览 ngValidator 代码以获取详细信息。

“**更新/添加产品**”模态对话框实现了对文本、货币数字和日期输入字段的验证。以下是“**单价**”字段的属性设置,作为一个典型示例。

<input type="text" class="form-control" 
       id="txtUnitPrice" name="txtUnitPrice"
       data-ng-model="model.Product.UnitPrice"
       validate-on="blur"
       clear-on="focus"
       required required-message="'Price is required'"
       number invalid-number-message="'Invalid number'"
       max-number="10000" max-number-message="'Price cannot exceed $10,000'"
       message-display-class="replace-label-dent"
       ng-focus="setDrag(true);setVisited('txtUnitPrice')" 
       ng-blur="setDrag(false)" >

所有错误消息都以内联样式显示,既适用于模态对话框上的单记录表单,也适用于内联表格添加/编辑页面。

脏字段也用琥珀色边框表示。这是通过为 input 元素的父 div 元素设置 has-warning CSS 类来实现的。

<div class="form-group" ng-class="{'has-warning' : productForm.txtUnitPrice.$dirty}">

对于内联添加/编辑表格,为每行设置验证存在一个主要问题。AngularJS 只能为表格中的整个列创建一个单一的作用域元素对象。它不支持为通过 ng-repeat 迭代的每个输入字段元素动态生成作用域元素对象,尤其是在元素通过启用编辑操作(例如选择一行)稍后激活和可见时。解决方案是当行被选中时,为行中的元素制作一个 AngularJS 原始作用域元素对象的浅拷贝。新生成的作用域元素对象需要将行索引号作为对象名称的后缀。示例应用程序为此目的使用 setNameObeject 指令。

.directive('setNameObject', function ($timeout) {
    return {
        restrict: 'A',        
        link: function (scope, iElement, iAttrs, ctrls) {
            var name = iElement[0].name;
            var baseName = name.split("_")[0];                       
            var scopeForm = scope[iElement[0].form.name];

            scope.$watch(scopeForm, function () {
                $timeout(function () {
                    if (scopeForm[name] != undefined) {
                        //Shallow copy to reference existing object 
                        //(deep copy doesn't work here).
                        scopeForm[baseName + '_' + scope.$index] = scopeForm[name];
                        //Change $name property.
                        scopeForm[baseName + '_' + 
                        scope.$index].$name = baseName + '_' + scope.$index;
                    }
                });
            });     
        }
    };
})

当选择多行以在 Contact List 页面上启动“**编辑**”状态时,每个需要验证的字段的多个作用域元素对象会添加到作用域表单对象中。下面的截图显示了在 Contact List 表格中将第一行和第二行设置为可编辑时(请参阅上一节“**表格中的内联编辑数据**”中的“**编辑**”状态截图)的作用域表单对象及其所有子作用域元素对象。带有“_{{$index}}”后缀的元素对象是那些由 AngularJS 原始过程静态添加的,每列只有一个,并且没有替换 {{$index}} 变量值。带有索引号作为后缀的突出显示的元素对象是由上述指令代码为每个输入元素动态创建的。如果没有这些添加的自定义对象,内联表格输入验证将不会生效。

尽管输入验证的失去焦点模式看起来不错,但其处理逻辑比脏数据(on-dirty)或提交(on-submit)模式更复杂。还可能引发一些副作用和衍生问题。以下是与失去焦点输入验证相关的两个问题和解决方案示例。

  1. ng-click 功能障碍。当任何输入框获得焦点且值无效时,触发“**添加**”或“**取消**”按钮的 ng-click 事件将什么都不做,只会使输入框失去焦点并渲染内联错误消息。似乎当前获得焦点的输入框的 on-blur 事件发生在 ng-click 事件之前。因此,ng-click 事件处理程序中的代码将无法执行。使用 ng-mousedown 事件代替可以解决问题。通过 Visual Studio 或浏览器脚本调试器,工作流程表明 ng-mousedown 事件中的代码在 on-blur 事件启动之前执行。在示例应用程序中,ng-mousedown 事件为“**联系人列表**”页面(contactList.html)上的“**添加**”按钮以及“**更新/添加产品**”对话框(_product.html)和 联系人列表 页面上的“**取消**”类型按钮设置。

    通过执行以下步骤可以重现该问题:

    • 将 HTML 文件中这些按钮的 ng-mousedown 更改为 ng-click
    • 运行应用程序并打开任何输入表单。
    • 在任何输入框中进行无效输入。
    • 点击按钮,问题将显现。
  2. 通过代码设置焦点会在原始状态下渲染验证错误。这发生在最新版本的浏览器 MS-Edge、Chrome 和 Firefox 上,而不是 IE。以 Angular 方式设置输入元素的焦点通常使用输入元素标签内的 auto-focus 属性。在示例应用程序中,唯一需要使用 JavaScript 代码设置焦点的地方是重置数据表单,以便在“**添加产品**”对话框上重复添加新的产品项。问题也可能是由执行 HTML 5 内置验证属性(如 required)的时间问题引起的。在 productContorller.clearAddForm() 函数中,如果将设置焦点的代码行放入匿名函数中,然后使用 $timeout 服务调用,则在重置表单时输入元素会正常获得焦点。

    //Need DOM operation to auto focus the ProductName field 
    //although using DOM code in a controller is not a good practice.        
    //Also need $timeout service for MS-Edge, Chrome, Firefox (but not IE). 
    //Otherwise, the box will be focused with "required" validation error.
    $timeout(function () {
        angular.element(document.querySelector('#txtProductName'))[0].focus();
    });

    要重现此问题,只需按照以下步骤操作:

    • 注释掉 productContorller.clearAddForm() 中用于函数和 $timeout 服务的行。
    • 使用 MS-Edge、Chrome 或 Firefox 浏览器运行应用程序。
    • 打开“**添加产品**”对话框,添加一个新项目并保存。
    • 继续添加另一个项目。
    • 表单将被重置,并且问题将显示。

重要提示:每次更改任何 HTML 或 JavaScript 文件中的代码后,开发人员都需要清除浏览器缓存的页面文件和数据。

主动或被动命令模式?

主动命令模式意味着只有当命令执行合法且数据有效时,命令(例如数据提交或编辑取消)才可用。发出命令后不应采取进一步干预。另一方面,被动模式下,命令始终可用,并在启动命令后,进程将检查执行的有效性。被动命令的一个简单案例是删除命令,其中“**删除**”按钮可以随时点击。如果在点击按钮时未选择任何数据项,将弹出一个对话框通知用户在再次点击按钮之前选择数据项。然而,对于主动模式,在用户可以点击“**删除**”按钮之前,会强制执行必要的规则。主动模式应该比被动模式更好、逻辑更清晰。在示例应用程序中,所有用于数据提交和取消的按钮,只要可能,都使用主动模式实现。在代码中,这些按钮使用 AngularJS 的 ng-disabled 指令有条件地启用或禁用。以下代码示例取自 contactList.html

  • “**添加**”按钮仅在状态不是“**编辑**”时启用。
    ng-disabled="editRowCount > 0 || addRowCount >= maxAddNumber"
  • “**删除**”按钮仅在“**编辑**”状态下至少选择一行时启用。
    ng-disabled="(isEditDirty || editRowCount == 0)"
  • “**保存更改**”按钮仅在有任何脏数据且所有数据项都通过验证时启用。
    ng-disabled="(!isEditDirty && !isAddDirty) || contactForm.$invalid"
  • “**取消更改**”按钮仅在原始阶段或有任何脏数据时启用。
    ng-disabled="!isEditDirty && !isAddDirty && editRowCount == 0 && addRowCount == 0"

离开当前页面时的脏数据警告

通常,每个数据修改网页在离开页面时,如果存在已更改的数据,都会通知用户保存。但是,由于 AngularJS 采用 SPA 架构,并且路由器配置为在应用程序内部切换页面,因此离开页面的场景在内部或外部(相对于应用程序领域)发生时会有所不同。处理警告通知的事件也不同。我们通常使用以下两个事件来发出脏数据警告:

  1. 基于 AngularJS 作用域的 $locationChangeStart:此事件可由任何内部页面切换以及从任何外部站点重定向回 AngularJS 路由应用程序 URL 触发。自动关闭已打开的 ngExDialog 实例的代码逻辑就是使用此事件处理程序的示例。

  2. 原生 JavaScript window.onbeforeunload:此事件由离开 AngularJS 应用程序到任何外部站点触发。

这两个事件处理程序放置在顶级 bodyController 中。对象变量 $scope.body 及其属性 dirty 可以通过原型继承被所有子作用域访问。这是 bodyController 的完整代码块:

.controller('bodyController', ['$scope', 'exDialog', '$location', 
             function ($scope, exDialog, $location) {
    //Object variable can be accessed by all child scopes.
    $scope.body = {};

    //Dirty warning and auto closing Angular dialog within the application.
    $scope.$on('$locationChangeStart', function (event, newUrl, oldUrl) {
        if (newUrl != oldUrl)
        {
            //Dirty warning when clicking browser navigation button 
            //or entering router matching URL.
            if ($scope.body.dirty) {
                //Use browser built-in dialog here. 
                //Any HTML template-based Angular dialog is processed 
                //after router action that has already reloaded target page. 
                if (window.confirm("Do you really want to discard data changes
                                    \nand leave the page?")) {
                    //Close any Angular dialog if opened.
                    if (exDialog.hasOpenDialog()) {
                        exDialog.closeAll();
                    }
                    //Reset flag.
                    $scope.body.dirty = false;
                }
                else {
                    //Cancel leaving action and stay on the page.
                    event.preventDefault();
                }                
            }
            else {
                //Auto close dialog if any is opened.
                if (exDialog.hasOpenDialog()) {
                    exDialog.closeAll();
                }
            }            
        }
    });

    //Dirty warning when redirecting to any external site either 
    //by clicking button or entering site URL.
    window.onbeforeunload = function (event) {
        if ($scope.body.dirty) {
            return "The page will be redirected to another site 
                    but there is unsaved data on this page.";
        }        
    };    
}])

当作用域表单的 $dirty 属性发生任何更改时,$scope.body.dirty 的值会动态更新,并且 $scope.$watch 用于表单 $dirty 更改。否则,在任何数据提交或取消后,有必要将 $scope.body.dirty 设置为 false。这样做可以避免将表单脏标志传播到父作用域,并在之后离开基础或父页面时导致不必要的脏数据警告。重置表单最简单且一劳永逸的方法是调用这行代码:

//$setPristine will reset form, set form and element dirty flags to false, 
//and clean up all items in $error object. 
//This will also auto set $scope.body.dirty to false to disable dirty warning 
//when using $scope.$watch for form $dirty changes.
$scope.productForm.$setPristine();

令人不满意的是,我们无法指定自己的模态对话框样式。在 $locationChangeStart 事件处理程序中必须使用原生 window.confirm 对话框,因为任何基于 HTML 模板的 AngularJS 对话框都在路由页面切换后处理。因此,提供对话框并获取用户关于离开或停留在页面上的反馈为时已晚。不同的浏览器可能会以不同的样式显示内置确认对话框,但功能是相同的。以下是 Microsoft Edge 显示的对话框:

如果代码返回自定义字符串,window.onbeforeunload 事件将始终显示内置对话框。您可以删除 return 代码以阻止浏览器弹出对话框,但无法阻止页面离开。只有“**取消**”按钮命令才能在您不想离开时保留当前页面。这是市场上任何浏览器的内置安全功能,已在开发者社区中广泛讨论。当从本示例应用程序中的页面重定向到外部站点时,Microsoft Edge 上的对话框显示如下:

摘要

数据修改始终是任何面向数据应用程序的关键部分。AngularJS 和 Web API 使基于 Web 的数据 CRUD 操作更加通用和高效。本文和示例应用程序提供了使用 AngularJS 和 Web API 进行数据 CRUD 的深入技术细节、实际实践和问题解决方案。

历史

  • 2015年9月23日
    • 原始帖子
  • 2015年11月26日
    • 添加了关于使用命令行启动由 Visual Studio 2015 编译的 IIS Web API 站点的说明
    • 同时修复了 AngularJS controller.js 中添加新联系人记录的问题
    • 源代码文件已更新
  • 2018年6月17日
    • 添加了更新为 Angular 1.5x 组件和 TypeScript 的示例应用程序源代码文件
  • 2020年12月6日
    • 调整了一些地方的文本,以避免读者看到文章更新时间戳“2020年12月6日”时产生的困惑,该时间戳是codeproject编辑器意外设置的。
    • 我没有计划在2018年6月17日上次更新后更新文章和源代码。文章和源代码中的所有内容都反映了2-3年前的文字和技术情况。谷歌团队已经宣布 AngularJS 将在2021年底达到EOL(生命周期结束)。这篇文章和示例应用程序的意义现在仅限于在进行迁移任务(或学习历史)时与 Angular 进行比较,而不是用于任何新的实现。
© . All rights reserved.