多階層Directive互動與ngMessages表單驗證實作
前言
需求是要建立一個複雜的動態表單,示意圖約略如下,各顏色表示對應至同一個ViewModel物件類別,因此可以發現畫面上具有許多重複(共用)區塊,其中又會有許多互動邏輯穿插於各區塊中,並且區塊都是可新增多筆的;此外這些區塊不僅只在此表單中出現而已,在其他編輯表單中也會出現,因此有其思考共用之必要性。
環境
* AngularJS v1.4.6
* ASP.NET MVC 5
實作
筆者在使用ASP.NET MVC進行開發時,常會使用Editor Template來處理畫面上共用的區塊;如今若是想要用AngularJS來作為前端開發框架,此時與Editor Template角色最接近的就非 Custom Directive 莫屬了,因此以下將著重於各Directive設計,並且思考如何搭配 ngMessages 來完成表單驗證功能,最後由於先前有提到頁面互動多又雜,所以會想辦法讓各個Directive都可以完成所需之互動功能。
首先來設計單個複雜表單區塊當作範例。示意表單圖示如下,主要就是可以自行新增用戶(User)數量,並且針對指定用戶增加學歷(Education)數量,學歷資訊中包含學校位置(Location)資訊,而所有輸入框都是必填資訊,因此未填寫時會在下方顯示錯誤訊息,並造成表單驗證失敗,讓送出(Submit)表單按鍵失效。
首先依照畫面需求來設計一下ViewModel類別。
/// <summary>
/// 頁面檢視模型
/// </summary>
public class HomeCreateViewModel
{
public List<User> Users { get; set; }
}
/// <summary>
/// 用戶
/// </summary>
public class User
{
public string Name { get; set; }
public string PhoneNumber { get; set; }
public List<Education> Educations { get; set; }
}
/// <summary>
/// 學歷
/// </summary>
public class Education
{
public string UniversityName { get; set; }
public string Major { get; set; }
public Location Location { get; set; }
}
/// <summary>
/// 位置
/// </summary>
public class Location
{
public string Address { get; set; }
public string ZipCode { get; set; }
}
接著建立ASP.NET MVC的Controller (HomeController.cs)。在Create中產生預設ViewModel資料來回傳View至前端顯示;而FetchDefaultNewUser與FetchDefaultNewEducation則會回傳新用戶(User)與學歷(Education)預設顯示資料。
public class HomeController : Controller
{
/// <summary>
/// 建立使用者頁面
/// </summary>
/// <returns></returns>
public ActionResult Create()
{
var vm = new HomeCreateViewModel()
{
Users = new List<ViewModel.Home.User>()
{
new User()
{
Name = "Chris",
PhoneNumber = "0911222333",
Educations = new List<Education>()
{
new Education()
{
UniversityName = "台灣大學",
Major = "電機",
Location = new Location()
{
Address = "台北市大安區羅斯福路四段1號",
ZipCode = "10617"
}
},
new Education()
{
UniversityName = "中央大學",
Major = "資工",
Location = new Location()
{
Address = "桃園縣中壢市中大路300號",
ZipCode = "320"
}
}
}
}
}
};
return View(vm);
}
/// <summary>
/// 取得預設用戶資料
/// </summary>
public ActionResult FetchDefaultNewUser()
{
var user = new User()
{
Name = "Default Name",
PhoneNumber = "Default PhoneNumber"
};
//return Json(term);
return Content(Newtonsoft.Json.JsonConvert.SerializeObject(user), "application/json");
}
/// <summary>
/// 取得預設學歷資料
/// </summary>
public ActionResult FetchDefaultNewEducation()
{
var education = new Education()
{
UniversityName = "Default UniversityName",
Major = "Default Major",
// level 3
Location = new Location()
{
Address = "Default Address",
ZipCode = "Default ZipCode",
}
};
//return Json(term);
return Content(Newtonsoft.Json.JsonConvert.SerializeObject(education), "application/json");
}
}
再來針對AngularJS來進行開發。首先建立module (app.module.js)且注入需使用的模組,接著建立service (home.service.js)來將共用邏輯封存於此,再來建立controller (about.controller.js)定義頁面資料與行為功能,最後就來逐一建立共用directive (js + html)來描述畫面及各自的資料與行為功能。 以下介紹。
AngularJS - App Module (app.module.js)
由於會使用到ngMessages來處理資料驗證錯誤訊息的顯示工作,因此需要在這邊加入ngMessages模組。
angular
.module('app', ['ngMessages']);
AngularJS - ngMessage (error-messages.html)
很多地方都會使用到資料驗證,因此先定義一個共用的錯誤訊息清單。
<!-- 定義共用的錯誤訊息(抽出為獨立檔案) -->
<p ng-message="required" class="text-danger field-validation-error">此欄位必填</p>
<p ng-message="minlength" class="text-danger field-validation-error">輸入太少</p>
<p ng-message="maxlength" class="text-danger field-validation-error">輸入太多</p>
<p ng-message="number" class="text-danger field-validation-error">只能輸入數字</p>
<p ng-message="pattern" class="text-danger field-validation-error">格式錯誤</p>
<p ng-message="date" class="text-danger field-validation-error">只能輸入日期</p>
AngularJS - Home Service (home.service.js)
利用Factory建立此服務,提供fetchDefaultNewUser與fetchDefaultNewEducation兩個方法,讓使用端在新增用戶(User)與學歷(Education)時,透過這兩個方法取得新增物件中預設的顯示資料。
angular
.module('app')
.factory("homeService", function ($http, $q) {
return {
fetchDefaultNewUser: function () {
// Get the deferred object
var deferred = $q.defer();
// Initiates the AJAX call
$http({ method: 'GET', url: '/Home/FetchDefaultNewUser' })
.success(deferred.resolve).error(deferred.reject);
// Returns the promise - Contains result once request completes
return deferred.promise;
},
fetchDefaultNewEducation: function () {
// Get the deferred object
var deferred = $q.defer();
// Initiates the AJAX call
$http({ method: 'GET', url: '/Home/FetchDefaultNewEducation' })
.success(deferred.resolve).error(deferred.reject);
// Returns the promise - Contains result once request completes
return deferred.promise;
}
}
});
AngularJS - Create Controller (create.controller.js)
將先前所定義的homeService注入至controller中使用,利用homeService中fetchDefaultNewUser方法來獲得預設新增使用者資訊,好讓操作者按下新增使用者時,將剛取得的預設使用者加入現存使用者陣列中,達到新增效果;另外,注入viewModel用意在於讓ASP.NET MVC檢視(View)中所定義的ViewModel資料可以一併獲得,有興趣的朋友可以參考 將ViewModel從後端注入AngularJS Controller中 文章,在此就不累述了。筆者也在Controller中也設定一些事件,例如 userNameClicked, educationMajorClicked, 以及locationAddressClicked,這些就是用來測試是否可以接收到各子 directive 資料異動的情況,好方便若有跨各 directive 資料互動的需求時,可以於此統一操作。
angular
.module('app')
.controller("CreateController", ['homeService', '$filter', 'viewModel', function (homeService, $filter, viewModel) {
var vm = this;
// properties
vm.data = viewModel;
// methods
vm.save = save;
vm.addUser = addUser;
vm.removeUser = removeUser;
vm.userNameClicked = userNameClicked;
vm.educationMajorClicked = educationMajorClicked;
vm.locationAddressClicked = locationAddressClicked;
// submit
function save() {
console.log(vm.data);
}
// add new user
function addUser() {
homeService.fetchDefaultNewUser().then(
function (defaultUser) {
if (vm.data.Users == null) {
vm.data.Users = [defaultUser];
} else {
vm.data.Users.push(defaultUser);
}
},
function () {
alert('error while fetchDefaultNewUser from server');
}
)
};
// remove user
function removeUser(index) {
vm.data.Users.splice(index, 1);
};
// user name clicked (called by userInfo directive)
function userNameClicked() {
console.log('one of user name clicked!!');
}
// education major clicked (called by educationInfo directive)
function educationMajorClicked() {
console.log('one of education major clicked!!');
}
// location address clicked (called by locationInfo directive)
function locationAddressClicked() {
console.log('one of location address clicked!!');
}
}]);
接著就是建立 Directive 的時候到了,首先是位置資訊 Directive ( locationInfo)。
AngularJS – locationInfo Directive (locationInfo.js)
首先在scope中設定modelSource作為雙向綁定資料源,可將外部傳入的Location資料綁定至此Directive Template中的元素上(ng-model)。接著使用require闡明此Directive相依於父層 form 及ngControlle Directive,如果不存在時會直接拋出錯誤。其中form的作用是用來取得父層表單,可讓ng-Messages使用來呈現輸入驗證錯誤訊息;而ng-controller就是用來取得主要 Controller (在此例為CreateController),當有些互動需求會因為此Directive資料異動來影響CreateController中的其他Directive(與目前Directive無從屬關係)時,可以由此管道通知CreateController來執行相關Directive資料之互動反應。
若此Directive中的Address被點擊,需要與父層 educationInfo directive 直接進行互動時,我們可以在scope中定義一個可由外部傳入的方法接口,提供父層傳入function來讓此Direction進行互動的觸發。 所以在這邊會多一個 require option 為 educationInfo,也就表示此Directive相依於父層 educationInfo Directive。因此在Address資料被點選時,除了可以通知主要的 Controller (在此例為CreateController)外,亦使用父層傳入function通知父層進行互動。
angular
.module('app')
.directive('locationInfo', function () {
return {
// E = element, A = attribute, C = class
restrict: 'E',
// ^ -- Look for the controller on parent elements, not just on the local scope
// ? -- Don't raise an error if the controller isn't found
require: ['^form', '^ngController'],
// @ reads the attribute value, = provides two-way binding, & works with functions
scope: {
modelSource: '=',
onAddressClicked: '&' // Address被點擊時需要呼叫的外部方法
},
controller: ['$scope', function ($scope) {
var vm = this;
// 點選 Address
vm.clickAddress = function () {
// 通知主要 Controller (滿足所有跨Directive互動需求)
vm.mainctrl.locationAddressClicked();
// 呼叫傳入function
// 通知父層 educationInfo Directive (與上層Directive互動需求)
vm.onAddressClicked();
}
}],
templateUrl: '../App/directive/locationInfo.html',
// Ensure that properties are bound to the controller instead of the scope. (Angular 1.3+)
bindToController: true,
controllerAs: 'vm',
// DOM manipulation
link: function (scope, element, attrs, ctrls) {
scope.vm.form = ctrls[0]; // 提供 ngMessages 使用 form 來呈現錯誤訊息
scope.vm.mainctrl = ctrls[1]; // 使用此 directive 的主要 Controller (滿足跨Directive互動需求)
}
}
});
使用方式如下
locationInfo.html
將外部輸入的資料源(modelSource)綁定至Address及ZipCode輸入元素中,並使用ngMessage來對於表單進行驗證,而傳入ngMessage資訊來自父層取得的form表單。
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title">Location</h3>
</div>
<div class="panel-body">
<div class="form-group">
<label class="control-label col-md-2" for="Address">Address</label>
<div class="col-md-10">
<!-- Address輸入框 -->
<input type="text" id="Address" name="Address" ng-model="vm.modelSource.Address" ng-click="vm.clickAddress()" class="form-control" ng-required="true" />
<!-- Address資料錯誤訊息 -->
<div ng-messages="vm.form.Address.$error" ng-show="vm.form.Address.$dirty">
<div ng-messages-include="\app\common\error-messages.html"></div>
</div>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-2" for="ZipCode">ZipCode</label>
<div class="col-md-10">
<!-- ZipCode輸入框 -->
<input type="text" id="ZipCode" name="ZipCode" ng-model="vm.modelSource.ZipCode" class="form-control" ng-required="true" />
<!-- ZipCode資料錯誤訊息 -->
<div ng-messages="vm.form.ZipCode.$error" ng-show="vm.form.ZipCode.$dirty">
<div ng-messages-include="\app\common\error-messages.html"></div>
</div>
</div>
</div>
</div>
</div>
location-info directive 顯示畫面如下
接著來定義 學歷資訊 Directive ( educationInfo)。
AngularJS – educationInfo Directive (educationInfo.js)
angular
.module('app')
.directive('educationInfo', function () {
return {
// E = element, A = attribute, C = class
restrict: 'E',
// ^ -- Look for the controller on parent elements, not just on the local scope
// ? -- Don't raise an error if the controller isn't found
require: ['^form', '^ngController'],
// @ reads the attribute value, = provides two-way binding, & works with functions
scope: {
modelSource: '='
},
// take the content of the directive and place it in the template
transclude: true,
controller: ['$scope', function ($scope) {
var vm = this;
// 所屬的 locationInfo Directive 之 Address 資料被點選
vm.myOwnLocationAddressClicked = function () {
console.log('my own location address clicked!!');
}
// 點選 Major
vm.clickMajor = function () {
// 通知主要 Controller (滿足所有跨Directive互動需求)
vm.mainctrl.educationMajorClicked();
}
}],
templateUrl: '../App/directive/educationInfo.html',
// Ensure that properties are bound to the controller instead of the scope. (Angular 1.3+)
bindToController: true,
controllerAs: 'vm',
//DOM manipulation
link: function (scope, element, attrs, ctrls) {
scope.vm.form = ctrls[0]; // 提供 ngMessages 使用 form 來呈現錯誤訊息
scope.vm.mainctrl = ctrls[1]; // 使用此 directive 的主要 Controller (滿足跨Directive互動需求)
}
}
});
educationInfo.html
其中vm.myOwnLocationAddressClicked方法就是當所屬location address被點擊時需產生的互動,因此可將此function傳入先前定義locationInfo directive的on-address-clicked標籤中,讓address被點擊時叫用。
<div class="panel panel-success">
<div class="panel-heading">
<h3 class="panel-title">Education</h3>
</div>
<div class="panel-body">
<div class="form-group">
<label class="control-label col-md-2" for="UniversityName">UniversityName</label>
<div class="col-md-10">
<!-- UniversityName輸入框 -->
<input type="text" id="UniversityName" name="UniversityName" ng-model="vm.modelSource.UniversityName" class="form-control" ng-required="true" />
<!-- UniversityName資料錯誤訊息 -->
<div ng-messages="vm.form.UniversityName.$error" ng-show="vm.form.UniversityName.$dirty">
<div ng-messages-include="\app\common\error-messages.html"></div>
</div>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-2" for="Major">Major</label>
<div class="col-md-10">
<!-- Major輸入框 -->
<input type="text" id="Major" name="Major" ng-model="vm.modelSource.Major" ng-click="vm.clickMajor()" class="form-control" ng-required="true" />
<!-- Major資料錯誤訊息 -->
<div ng-messages="vm.form.Major.$error" ng-show="vm.form.Major.$dirty">
<div ng-messages-include="\app\common\error-messages.html"></div>
</div>
</div>
</div>
.
<!-- 使用 locationInfo directive 呈現Location資料 -->
<location-info model-source='vm.modelSource.Location' on-address-clicked='vm.myOwnLocationAddressClicked()'></location-info>
<!-- 插入此Directive的元素位置 -->
<div ng-transclude></div>
</div>
</div>
education-info directive 顯示畫面如下
最後是 用戶資訊 Directive ( userInfo)。
AngularJS - userInfo Directive (userInfo.js)
由於學歷資料是允許多筆,因此會在此定義新增(addEducation)及移除(removeEducation)學歷資料的方法,當新增一筆學歷資料時,可透過注入homeService的fetchNewDefaultEducation來取得新學歷資料的預設值加入陣列中。
angular
.module('app')
.directive('userInfo', function () {
return {
// E = element, A = attribute, C = class
restrict: 'E',
// ^ -- Look for the controller on parent elements, not just on the local scope
// ? -- Don't raise an error if the controller isn't found
require: ['^form', '^ngController'],
// @ reads the attribute value, = provides two-way binding, & works with functions
scope: {
modelSource: '='
},
// take the content of the directive and place it in the template
transclude: true,
controller: ['$scope', 'homeService', function ($scope, homeService) {
var vm = this;
// 點選 Name
vm.clickName = function () {
// 通知主要 Controller (滿足所有跨Directive互動需求)
vm.mainctrl.userNameClicked();
}
// 新增一筆 Education
vm.addEducation = function () {
homeService.fetchDefaultNewEducation().then(
function (defaultEducation) {
if (vm.modelSource.Educations == null) {
vm.modelSource.Educations = [defaultEducation];
} else {
vm.modelSource.Educations.push(defaultEducation);
}
},
function () {
alert('error while fetchDefaultNewEducation from server');
})
}
// 移除 Education
vm.removeEducation = function (index) {
vm.modelSource.Educations.splice(index, 1);
}
}],
templateUrl: '../App/directive/userInfo.html',
// Ensure that properties are bound to the controller instead of the scope. (Angular 1.3+)
bindToController: true,
controllerAs: 'vm',
//DOM manipulation
link: function (scope, element, attrs, ctrls) {
scope.vm.form = ctrls[0]; // 提供 ngMessages 使用 form 來呈現錯誤訊息
scope.vm.mainctrl = ctrls[1]; // 使用此 directive 的主要 Controller (滿足跨Directive互動需求)
}
}
});
userInfo.html
由於是多筆學歷資料,因此需要使用ng-form隔開每筆資料,避免資料驗證時由於元素名稱都相同,而產生錯誤訊息錯亂的情況;另外,由於先前已在eductationInfo directive中開啟transclude為true,因此我們可以插入刪除學歷按鈕至education-info directive中指定位置進行呈現。
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">User</h3>
</div>
<div class="panel-body">
<div class="form-group">
<label class="control-label col-md-2" for="Name">Name</label>
<div class="col-md-10">
<!-- Name輸入框 -->
<input type="text" id="Name" name="Name" ng-model="vm.modelSource.Name" ng-click="vm.clickName()" class="form-control" ng-required="true" />
<!-- Name資料錯誤訊息 -->
<div ng-messages="vm.form.Name.$error" ng-show="vm.form.Name.$dirty">
<div ng-messages-include="\app\common\error-messages.html"></div>
</div>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-2" for="PhoneNumber">PhoneNumber</label>
<div class="col-md-10">
<!-- PhoneNumber輸入框 -->
<input type="text" id="PhoneNumber" name="PhoneNumber" ng-model="vm.modelSource.PhoneNumber" class="form-control" ng-required="true" />
<!-- PhoneNumber資料錯誤訊息 -->
<div ng-messages="vm.form.PhoneNumber.$error" ng-show="vm.form.PhoneNumber.$dirty">
<div ng-messages-include="\app\common\error-messages.html"></div>
</div>
</div>
</div>
<!-- 新增多筆Education資料 -->
<input type="button" value="新增Education" ng-click="vm.addEducation()" class="btn" />
<!-- 要使用ng-form隔開各筆Education資料 讓驗證訊息不會衝突 -->
<ng-form ng-repeat="education in vm.modelSource.Educations" name="subForm">
<!-- 使用 educationInfo directive 呈現Education資料 -->
<education-info model-source='education'>
<!-- 插入移除按鍵至 educationInfo Directive 中 (transclude:true) -->
<input type="button" value="移除Education" ng-click="vm.removeEducation($index)" class="btn" />
</education-info>
</ng-form>
<!-- 插入此Directive的元素位置 -->
<div ng-transclude></div>
</div>
</div>
user-info directive 顯示畫面如下
ASP.NET MVC – View (create.cshtml)
完成AngularJS大部分工作後,接著就來看一下View如何實作。首先要記得設定ng-app作用域(可於_layout.cshtml中設定<html ng-app="app">),再來由於User是陣列型態(多筆),因此也要用ng-form隔開每筆資料,避免資料驗證時由於元素名稱都相同,而產生錯誤訊息錯亂的情況;最後在user-info中插入刪除User按鍵就大功告成了。
@model AngularSampleWebApp.ViewModel.Home.HomeCreateViewModel
<form name="mainForm" ng-controller="CreateController as ctrl" ng-submit="ctrl.save()" novalidate>
<h4>Users</h4>
<hr />
<!-- 新增多筆User資料 -->
<input type="button" value="新增User" ng-click="ctrl.addUser()" class="btn" />
<!-- 要使用ng-form隔開各筆User資料 讓驗證訊息不會衝突 -->
<ng-form ng-repeat="user in ctrl.data.Users" name="subForm">
<!-- 使用 userInfo directive 呈現User資料 -->
<user-info model-source='user'>
<!-- 插入移除按鍵至 userInfo Directive 中 (transclude:true) -->
<input type="button" value="移除User" ng-click="ctrl.removeUser($index)" class="btn" />
</user-info>
</ng-form>
<!-- 送出資料 -->
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button type="submit" class="btn btn-info" ng-disabled="mainForm.$invalid">Submit</button>
</div>
</div>
</form>
@section Scripts {
<script src="~/App/service/home.service.js"></script>
<script src="~/App/controller/Home/create.controller.js"></script>
<script>
// 將Model轉換成JSON
var model = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(this.Model));
// 建立 viewModel Value Provider 供後續注入controller使用
angular.module('app').value("viewModel", model);
</script>
}
看一下結果,進入頁面後如ViewModel設定資料產出相對應畫面。
各自資料驗證及錯誤訊息皆能正常運作顯示。
刪除所有學歷資料後,再增加一筆學歷資料,新資料也如同預期地顯示預設資訊。
接著來測試點選Location-Address, Education-Major 及 User-Name 事件是否正常傳遞。
首先點選Location-Address (如預期觸發2個事件)
然後依序點選 Education-Major 及 User-Name 後,事件也會正常傳遞。
參考資訊
http://www.ng-newsletter.com/posts/directives.html
http://weblogs.asp.net/dwahlin/creating-custom-angularjs-directives-part-6-using-controllers
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !