[AngularJS] 多階層Directive互動與ngMessages表單驗證實作

多階層Directive互動與ngMessages表單驗證實作

前言

需求是要建立一個複雜的動態表單,示意圖約略如下,各顏色表示對應至同一個ViewModel物件類別,因此可以發現畫面上具有許多重複(共用)區塊,其中又會有許多互動邏輯穿插於各區塊中,並且區塊都是可新增多筆的;此外這些區塊不僅只在此表單中出現而已,在其他編輯表單中也會出現,因此有其思考共用之必要性。

image

 

環境

* 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)表單按鍵失效。

image

首先依照畫面需求來設計一下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)來描述畫面及各自的資料與行為功能。 以下介紹。

image

 

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)

image

 

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資料之互動反應。

image

 

若此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互動需求)
            
            }
        }
    });

 

使用方式如下

image

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 顯示畫面如下

image

 

接著來定義 學歷資訊 Directive ( educationInfo)

image

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 顯示畫面如下

image

 

最後是 用戶資訊 Directive ( userInfo)

image

 

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 顯示畫面如下

image

 

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設定資料產出相對應畫面。

image

 

各自資料驗證及錯誤訊息皆能正常運作顯示。

image

 

刪除所有學歷資料後,再增加一筆學歷資料,新資料也如同預期地顯示預設資訊。

image

 

接著來測試點選Location-Address, Education-Major 及 User-Name 事件是否正常傳遞。

首先點選Location-Address (如預期觸發2個事件)

image

 

然後依序點選 Education-Major 及 User-Name 後,事件也會正常傳遞。

image

 

參考資訊

http://www.ng-newsletter.com/posts/directives.html

http://weblogs.asp.net/dwahlin/creating-custom-angularjs-directives-part-6-using-controllers


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !