javascripthtmlangularjsangularjs-ng-transcludeng-messages

Ng-messages dynamic form and input names


tl;dr;

It seems the like ng-messages directive cannot be transcluded. When you transclude it it doesn't bind correctly. I have 2 workarounds, but both have their downsides. Anyone has a better solution or am I doing something wrong?

I am using typescript.


Versions

AngularJS: 1.6.1

angular-animate: 1.6.1

Angular-aria: 1.6.1

Angular-messages: 1.6.1

Angular-material: 1.1.0

TypeScript: 3.10.5


What do I want

I have a component that formats a date (does some more, but that isn't relevant) and shows it. A few of these components are included on the same page. Surrounding these components is a form. I want to validate this form with custom ng-messages, say an error message is shown when the date is in the future.


What is the problem

When I transclude the custom error messages to the component the binding fails. There is an error (Angular-material shows a red line under the input field) but the message that belongs to that error isn't shown.


When does it work?

The error messages are shown when I don't transclude the ng-messages, but put them inline with html of the component. The only problem here is that the formname and the input name are dynamic (defined via component bindings) so I need to use this.$eval($ctrl.inputName+'Form.'+$ctrl.inputName)['$error'], what I realy don't want to use (comes from GitHub AngularJS issues).


How does it look like?

Angular usage of component

Within the WorkHoursController I check the dates for validity. If one is not valid I set the $setValidity of the subform's input field from there. That is why I am using dynamic form and form item names (also there is more then 1 datepicker.html transcluded to the workhours.html so I cannot use static names).


What I want (and doesn't work)

Transcluding whole ng-message div into the component, without this.$eval()

workhours.html

<div layout="column" layout-fill ng-cloak>
    <md-content>
        <form name="bigForm" novalidate>
            <div class="formCard md-whiteframe-2dp">
                <h4>Workhours</h4>
                <date-picker title="Select workhours"
                                       model="$ctrl.time.workhour"
                                       input-name="workhour">
                    <div ng-messages="workhourForm.workhour.$error">
                        <div ng-message="inFuture">You cannot plan workhours in the future</div>
                    </div>
                </date-picker>
            </div>
        </form>
    </md-content>
</div>

datePicker.html

<ng-form name="{{$ctrl.inputName}}Form">
    <md-input-container class="md-block" ng-click="$ctrl.showPicker()">
        <input ng-model="$ctrl.formattedModel"
               aria-label="Open dateTimePicker"
               name="{{$ctrl.inputName}}"
               readonly
               ng-transclude>
    </md-input-container>
</ng-form>

When doing it this way the error message won't be shown. Only the red line. So there is something going wrong the the transclusion.

Missing error message


I can think of 2 workarrounds. Both have their downsides..

Option 1 - Working code

Defining the ng-messages in the component itself

Datepicker.html

<ng-form name="{{$ctrl.inputName}}Form">
    <md-input-container class="md-block" ng-click="$ctrl.showPicker()">
        <input ng-model="$ctrl.formattedModel"
               aria-label="Open dateTimePicker"
               name="{{$ctrl.inputName}}"
               readonly>
        <div ng-messages="this.$eval($ctrl.inputName+'Form.'+$ctrl.inputName)['$error']">
            <div ng-message="inFuture">You cannot plan workhours in the future</div>
        </div>
    </md-input-container>
</ng-form>

This way all the messages are defined in the Datepicker.html. Due to this I cannot add any custom messages and I can only use predefined messages. This is not what I want, since this component can be used in multiple situations, with every situation having its own business rules.


Option 2 - Working code (with 1 error)

Transcluding ng-messages into the component

datepicker.html

<ng-form name="{{$ctrl.inputName}}Form">
    <md-input-container class="md-block" ng-click="$ctrl.showPicker()">
        <input ng-model="$ctrl.formattedModel"
               aria-label="Open dateTimePicker"
               name="{{$ctrl.inputName}}"
               readonly>
        <div ng-messages="this.$eval($ctrl.inputName+'Form.'+$ctrl.inputName)['$error']" ng-transclude>

        </div>      
    </md-input-container>
</ng-form>

workhours.html

<div layout="column" layout-fill ng-cloak>   
    <md-content>
        <form name="bigForm" novalidate>
            <div class="formCard md-whiteframe-2dp">
                <h4>workhours</h4>
                <date-picker title="Select workhours"
                                       model="$ctrl.time.workhours"
                                       input-name="workhours">
                    <div ng-message="inFuture">You cannot plan workhours in the future</div>               
                </date-picker>
            </div>
        </form>
    </md-content>
</div>

With this option the ng-messages are dynamic and whatever you want can be added. But the class md-input-message-animation is not added. What results in big text and no animation of the error message. This class can be added manually, but there is something going wrong in the transclusion.

(left is Wrong, right is right)

Image that shows what class is missing


My questions

I cannot figure out two things. Can anybody help me on any of these two?

  1. How can I transclude ng-messages without making my component a directive (placing a directive in between the HTML and the component is an option)?
  2. How can I remove the usage of this.$eval($ctrl.inputName+'Form.'+$ctrl.inputName)['$error'] and still make the code work with dynamic form and formfield names?

Thanks in advance.


Solution

  • After reading some other posts and by trying a lot of stuff I have found an answer to my questions.

    Solution to question 1

    It is a problem in material design. I could probably fix this with a $postLink function in my date-picker component. Yet this problem is not the responsibility of this component. So a solution that would also fit the Object Oriented guidelines I would need to make a directive that adds the missing class to each transcluded element.

    The directive (custom-ng-message)

    class CustomNgMessageLinkController {
        constructor(scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) {
            element.addClass('md-input-message-animation');
        }
    }
    
    function CustomNgMessageDirective(): ng.IDirective {
       return {
           restrict: 'A',
           scope: {},
           link: CustomNgMessageLinkController
       };
    }
    

    With this directive I can do the following:

    <div custom-ng-message ng-message="inFuture">You cannot plan workhours in the future</div>
    

    This will transclude to the expected result:

    enter image description here


    Solution to question 2

    I based this solution on the 'option 2 - Working code (with 1 error)' example in my question. To make this work I had to create and extra scope property (formName), create the form name in the controller (and not in the HTML as I did before) and add extra function in the controller that returned the field (an object of ng.INgModelController).

    I've added in the constructor of the date-picker component:

    constructor(private $scope: ng.IScope) {
        this.formName = this.inputName + 'Form';
    }
    

    And the new method in the date-picker component:

    /**
     * Gets the form of the scope and returns the input field
     * @return {ng.INgModelController}
     */
    getFormInput(): ng.INgModelController {
        let formName = this.inputName + 'Form';
        return this.$scope[formName][this.inputName];
    }
    

    This method does almost the same as the this.$eval. The only difference is is that it isn't creating a security issue.

    Now I can use the formName in the HTML like so:

    <ng-form name="{{$ctrl.formName}}">
        <md-input-container class="md-block" ng-click="$ctrl.showPicker()">
            <input ng-model="$ctrl.formattedModel"
                   aria-label="Open dateTimePicker"
                   name="{{$ctrl.inputName}}"
                   readonly>
            <div ng-messages="$ctrl.getFormInput().$error" ng-transclude>
            </div>
        </md-input-container>
    </ng-form>
    

    This allows me to use dynamic form and input names. This is useful, because I have more then one of this component on one page.


    How I set the error

    In my WorkHours controller I can validate the MomentJS date and set the error message like so:

    constructor($scope: IWorkHourScope) {
       $scope.$watch(() => {
           return this.time.workhour;
       }, (newValue: Moment, oldValue: Moment) => {
           if (newValue > moment()) {                       
              $scope.timeSheetForm.workHourForm.workhour.$setValidity('inFuture', false);
           }
       });
    }
    

    Also this makes creating the required interfaces for my form items a lot easier, since all the forms have different names I can assign the right properties to each subform.

    interface IWorkDateForm {
        workhour: ng.INgModelController;
    }
    
    interface IIllNessDateForm {
        illness: ng.INgModelController;
    }
    
    interface ITimeForm extends ng.IFormController {
        workhourForm: IWorkDateForm;
        illNessForm: IIllNessDateForm;       
    }
       
    interface ITimeSheetScope extends ng.IScope {
        timeForm: ITimeForm;
    }
    

    This way I don't have to use 'any' for my forms and I can have autocomplete from the AngularJS library.