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?
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.
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)
My questions
I cannot figure out two things. Can anybody help me on any of these two?
this.$eval($ctrl.inputName+'Form.'+$ctrl.inputName)['$error']
and still make the code work with dynamic form and formfield names?Thanks in advance.
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:
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.