2

I have a commonly reused set of form inputs that are reused throughout my application, so I am trying to encapsulate them in a custom directive. I want to set an ngModel on my directive and have that split up to be editable in several different inputs (some of them are directives themselves) within the main directive.

At the same time, I need the form validation results to be passed up the chain to a parent form so that I can display appropriate messages and styles.

What is the simplest and most idiomatic way to implement this?

These (simplified) templates should give you an example of what I'm going for...

OuterTemplate.html

<form name="outerForm">
  <my-directive
    ng-model="ctrl.myComplexModel"
    name="myDirectiveInstance"
    custom-required="ctrl.EnableValidateOne"
    toggle-another-validation="ctrl.EnableValidateTwo">
  </my-directive>
  <div ng-messages="outerForm.myDirectiveInstance.$error">
    <ng-message when="customRequired">This is required.</ng-message>
    <ng-message when="anotherValidation">This is required.</ng-message>
    <ng-message when="innerValidationOne">Something wrong with field 1.</ng-message>
    <ng-message when="innerValidationTwo">Something wrong with field 2.</ng-message>
    <ng-message when="innerValidationThree">Something wrong with field 3.</ng-message>
    <!-- etc... -->
  </div>
</form>

myDirectiveTemplate.html

<div ng-form="myDirectiveForm">
  <div ng-class="{'has-error': myDirectiveForm.fieldOne.$invalid}">
    <ui-select
      ng-model="model.fieldOne"
      name="fieldOne"
      required>
    </ui-select>
  </div>
  <div ng-class="{'has-error': myDirectiveForm.fieldTwo.$invalid}">
    <input
      type="number"
      ng-model="model.fieldTwo"
      name="fieldTwo"
      ng-pattern="directiveCtrl.someRegEx"
      ng-required="directiveCtrl.fieldTwoIsRequired">
  </div>
  <!-- etc... -->
</div>

At the moment, both myDirectiveForm and myDirectiveInstance are publishing themselves as properties of the outerForm FormController. I hoping to make this directive a black box, so the fact that myDirectiveForm is attaching directly to outerForm bothers me and seems to indicate that I'm doing something wrong.

Here's what my directive definition looks like right now.

myDirective.js

app.directive('myDirective', function() {
  return {
    restrict: 'E',
    template: 'myDirectiveTemplate.html',
    controller: 'MyDirectiveCtrl',
    scope: {
      model: '=ngModel',
      customRequired: '=?',
      toggleAnotherValidation: '=?'
    },
    require: 'ngModel',
    link: function(scope, iElem, iAttrs, ngModelController) {

      // Black-box the internal validators

      // Custom validator to avoid conflicts with ngRequired
      ngModelController.$validators.customRequired = function(modelValue, viewValue) {
        if(!scope.customRequired)
          return true;

        // On first digest the field isn't registered on the form controller yet
        if(angular.isUndefined(scope.myDirectiveForm.fieldOne))
          return true;

        return !scope.myDirectiveForm.fieldOne.$error.required;
      };

      ngModelController.$validators.anotherValidation = function(modelValue, viewValue) {
        if(!scope.anotherValidation)
          return true;

        return scope.passesBusinessRule();
      };

      ngModelController.$validators.innerValidationOne = function(modelValue, viewValue) {
        if(!scope.anotherValidation)
          return true;

        if(angular.isUndefined(scope.myDirectiveForm.fieldTwo))
          return true;

        return !scope.myDirectiveForm.fieldTwo.$error.pattern;
      };

      /* etc... */

      // Deep-watching model so that validations will trigger on updates of properties
      scope.$watch('model', function() {
        ngModelController.$validate();
      }, true);
    }
  };
});
Josh Rickert
  • 309
  • 2
  • 9

2 Answers2

0

this is how I understand the directives. In this case both ng-model and myDirective are directives. It's not clear to me that if you are doing a

1) wrapper on ng-model OR 2) custom directive

Because if you want to do custom directive, you can just pass in the data, ex.

{ scope: { data: '=' }

And if you want to do a wrapper, you probably shouldn't pass in the other properties related to ngModel, which means you can still pass in the data

ctrl.myComplexModel, btw. model object can not be assigned to ng-model, because ng-model does not hold the object, it just holds the data.

NOTE: actually i found this post, AngularJS - Create a directive that uses ng-model

and apparently, you can pass in model, https://docs.angularjs.org/api/ng/type/ngModel.NgModelController

Anyway, it's just too complicated for me :) If you want to make a wrapper, the pattern seems to me

  1. pass in the data
  2. "has a" object

But apparently you might be doing "is a" object.

Community
  • 1
  • 1
windmaomao
  • 7,120
  • 2
  • 32
  • 36
  • I'm trying to do a custom directive that implements the `ngModelController` api so that I can use Angular's native form validation (which relies on `ngModelController`) when using my custom directive inside a `form`. – Josh Rickert Oct 05 '15 at 16:25
0

I've worked out a decent solution. In brief, I've removed the NgModelController implementation from my custom directive, and I'm relying entirely on the internal FormController from the form directive inside my custom directive. As far as I can tell, NgModelController just wasn't designed to wrap a form in a custom directive. However, nested forms are supported quite nicely in Angular, so this is the way to go.

Something I hadn't realized was that you can dynamically assign a name to a form as of Angular 1.3. While I can't prevent the "black box" from leaking up and attaching itself to a parent form controller, I can at least control the name it uses to publish itself in the parent scope, which is acceptable and is very similar to the API provided by ngModel.

Updated examples below.

OuterTemplate.html

<form name="outerForm">
  <my-directive
    model="ctrl.myComplexModel"
    name="myDirectiveInstance"
    custom-required="ctrl.EnableValidateOne"
    toggle-another-validation="ctrl.EnableValidateTwo">
  </my-directive>
  <div>
    <span ng-if="outerForm.myDirectiveInstance.fieldOne.$error.required">Internal field 1 is required.</span>
    <span ng-if="outerForm.myDirectiveInstance.fieldTwo.$error.required">Internal field 2 is required.</span>
    <span ng-if="outerForm.myDirectiveInstance.fieldTwo.$error.pattern">Internal field 2 format error.</span>
    <!-- etc... -->
    <ng-messages for="outerForm.myDirectiveInstance.$error">
      <ng-message when="required">At least one required field is missing.</ng-message>
      <ng-message when="custom">
        Some directive-wide error set by validate-custom on outerForm.myDirectiveInstance.internalField
      </ng-message>
      <!-- etc... -->
    </ng-messages>
  </div>
</form>

In the outer template, I removed the ng-model directive in favor of a custom attribute. The name property may still be used to determine what name the internal form is published under.

Alternativately, the ng-model could be kept around, and an attribute form-name (with an appropriate alteration to the isolate scope binding below) could be used to publish the custom directive's FormController to the parent FormController, but this could be somewhat misleading since the ng-model directive isn't being used for anything except an isolate scope binding.

Either way, ng-model should not be used in conjunction with the name property for this use case. Otherwise, there may be conflicts as the NgModelController and the FormController attempt to publish themselves to the parent FormController (outerForm) under the same property name (outerForm.myDirectiveInstance).

Since validation errors bubble up to parent form directives, ngMessages may be used with this custom directive as shown. For more granular error handling, the internal fields of the directives can be accessed as well.

myDirectiveTemplate.html

<div ng-form="{{ formName }}">
  <div ng-class="{'has-error': isInvalid('fieldOne')}">
    <ui-select
      ng-model="model.fieldOne"
      name="fieldOne"
      required>
    </ui-select>
  </div>
  <div ng-class="{'has-error': isInvalid('fieldTwo')}">
    <input
      type="number"
      ng-model="model.fieldTwo"
      name="fieldTwo"
      ng-pattern="directiveCtrl.someRegEx"
      ng-required="directiveCtrl.fieldTwoIsRequired">
  </div>
  <!-- etc... -->
  <input
    type="hidden"
    ng-model="someCalculatedValue"
    name="internalField"
    validate-custom>
</div>

The internal template of the directive stays mostly the same. The big difference is that the name for ngForm is now dynamically set.

To handle that with ngClass, angular expressions wouldn't work, so I updated my example to use a function on the $scope instead.

Last, for directive-wide business rules, I used a hidden input with an ngModel directive and a name set. I attached a custom mini-directive for validation to just this field. Validation errors on this field will bubble up to be used by the parent directive.

myDirective.js

app.directive('myDirective', function() {
  return {
    restrict: 'E',
    template: 'myDirectiveTemplate.html',
    controller: 'MyDirectiveCtrl',
    scope: {
      model: '=',
      customRequired: '=?',
      toggleAnotherValidation: '=?',
      formName: '@name'
    },
  };
});

Pretty much all the logic has been removed from the directive definition now.

Josh Rickert
  • 309
  • 2
  • 9