]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
LP2045292 Color contrast for AngularJS patron bills
[Evergreen.git] / Open-ILS / src / eg2 / src / app / share / fm-editor / fm-editor.component.ts
1 import {Component, OnInit, Input, ViewChild,
2     Output, EventEmitter, TemplateRef,
3     Directive, HostBinding} from '@angular/core';
4 import {NgForm, AbstractControl, NG_VALIDATORS, ValidationErrors,
5     Validator, Validators} from '@angular/forms';
6 import {IdlService, IdlObject} from '@eg/core/idl.service';
7 import {Observable} from 'rxjs';
8 import {PcrudService} from '@eg/core/pcrud.service';
9 import {OrgService} from '@eg/core/org.service';
10 import {DialogComponent} from '@eg/share/dialog/dialog.component';
11 import {ToastService} from '@eg/share/toast/toast.service';
12 import {StringComponent} from '@eg/share/string/string.component';
13 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
14 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
15 import {FormatService} from '@eg/core/format.service';
16 import {TranslateComponent} from '@eg/share/translate/translate.component';
17 import {FmRecordEditorActionComponent} from './fm-editor-action.component';
18 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
19
20 interface CustomFieldTemplate {
21     template: TemplateRef<any>;
22
23     // Allow the caller to pass in a free-form context blob to
24     // be addedto the caller's custom template context, along
25     // with our stock context.
26     context?: {[fields: string]: any};
27 }
28
29 export interface CustomFieldContext {
30     // Current create/edit/view record
31     record: IdlObject;
32
33     // IDL field definition blob
34     field: any;
35
36     // additional context values passed via CustomFieldTemplate
37     [fields: string]: any;
38 }
39
40 // Collection of extra options that may be applied to fields
41 // for controling non-default behaviour.
42 export interface FmFieldOptions {
43
44     // Render the field as a combobox using these values, regardless
45     // of the field's datatype.
46     customValues?: ComboboxEntry[];
47
48     // Provide / override the "selector" value for the linked class.
49     // This is the field the combobox will search for typeahead.  If no
50     // field is defined, the "selector" field is used.  If no "selector"
51     // field exists, the combobox will pre-load all linked values so
52     // the user can click to navigate.
53     linkedSearchField?: string;
54
55     // When true for combobox fields, pre-fetch the combobox data
56     // so the user can click or type to find values.
57     preloadLinkedValues?: boolean;
58
59     // Additional search conditions to include when constructing
60     // the query for a linked field's combobox
61     linkedSearchConditions?: {[field: string]: string};
62
63     // Directly override the required state of the field.
64     // This only has an affect if the value is true.
65     isRequired?: boolean;
66
67     // If this function is defined, the function will be called
68     // at render time to see if the field should be marked are required.
69     // This supersedes all other isRequired specifiers.
70     isRequiredOverride?: (field: string, record: IdlObject) => boolean;
71
72     // Directly apply the readonly status of the field.
73     // This only has an affect if the value is true.
74     isReadonly?: boolean;
75
76     // If this function is defined, the function will be called
77     // at render time to see if the field should be marked readonly.
78     // This supersedes all other isReadonly specifiers.
79     isReadonlyOverride?: (field: string, record: IdlObject) => boolean;
80
81     // Render the field using this custom template instead of chosing
82     // from the default set of form inputs.
83     customTemplate?: CustomFieldTemplate;
84
85     // Use this persistKey if the field is an org field
86     persistKey?: StringComponent;
87
88     // help text to display via a popover
89     helpText?: StringComponent;
90
91     // minimum and maximum permitted values for int fields
92     min?: number;
93     max?: number;
94 }
95
96 @Component({
97     selector: 'eg-fm-record-editor',
98     templateUrl: './fm-editor.component.html',
99     styleUrls: ['fm-editor.component.css']
100 })
101 export class FmRecordEditorComponent
102     extends DialogComponent implements OnInit {
103
104     // IDL class hint (e.g. "aou")
105     @Input() idlClass: string;
106
107     // Show datetime fields in this particular timezone
108     timezone: string = this.format.wsOrgTimezone;
109
110     // Permissions extracted from the permacrud defs in the IDL
111     // for the current IDL class
112     modePerms: {[mode: string]: string};
113
114     // Collection of FmFieldOptions for specifying non-default
115     // behaviour for each field (by field name).
116     @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
117
118     // This is used to set default values when making a new record
119     @Input() defaultNewRecord: IdlObject;
120
121     // list of fields that should not be displayed
122     @Input() hiddenFieldsList: string[] = [];
123     @Input() hiddenFields: string; // comma-separated string version
124
125     // list of fields that should always be read-only
126     @Input() readonlyFieldsList: string[] = [];
127     @Input() readonlyFields: string; // comma-separated string version
128
129     // list of required fields; this supplements what the IDL considers
130     // required
131     @Input() requiredFieldsList: string[] = [];
132     @Input() requiredFields: string; // comma-separated string version
133
134     // list of timestamp fields that should display with a timepicker
135     @Input() datetimeFieldsList: string[] = [];
136     @Input() datetimeFields: string; // comma-separated string version
137
138     // list of org_unit fields where a default value may be applied by
139     // the org-select if no value is present.
140     @Input() orgDefaultAllowedList: string[] = [];
141     @Input() orgDefaultAllowed: string; // comma-separated string version
142
143     // IDL record display label.  Defaults to the IDL label.
144     @Input() recordLabel: string;
145
146     // When true at the component level, pre-fetch the combobox data
147     // for all combobox fields.  See also FmFieldOptions.
148     @Input() preloadLinkedValues: boolean;
149
150     // Display within a modal dialog window or inline in the page.
151     @Input() displayMode: 'dialog' | 'inline' = 'dialog';
152
153     // Hide the top 'Record Editor: ...' banner.  Primarily useful
154     // for displayMode === 'inline'
155     @Input() hideBanner: boolean;
156
157     // do not close dialog on error saving record
158     @Input() remainOpenOnError: false;
159
160     // if date fields need to be in a specific order (e.g.
161     // start date before end date), specify them in a comma-
162     // separated list here.
163     @Input() dateFieldOrderList: '';
164
165     // Emit the modified object when the save action completes.
166     @Output() recordSaved = new EventEmitter<IdlObject>();
167
168     // Emit the modified object when the save action completes.
169     @Output() recordDeleted = new EventEmitter<IdlObject>();
170
171     // Emit the original object when the save action is canceled.
172     @Output() recordCanceled = new EventEmitter<IdlObject>();
173
174     // Emit an error message when the save action fails.
175     @Output() recordError = new EventEmitter<string>();
176
177     @ViewChild('translator', { static: true }) private translator: TranslateComponent;
178     @ViewChild('successStr', { static: true }) successStr: StringComponent;
179     @ViewChild('failStr', { static: true }) failStr: StringComponent;
180     @ViewChild('confirmDel', { static: true }) confirmDel: ConfirmDialogComponent;
181     @ViewChild('fmEditForm', { static: false}) fmEditForm: NgForm;
182
183     // IDL info for the the selected IDL class
184     idlDef: any;
185
186     // Can we edit the primary key?
187     pkeyIsEditable = false;
188
189     // List of IDL field definitions.  This is a subset of the full
190     // list of fields on the IDL, since some are hidden, virtual, etc.
191     fields: any[];
192
193     // DOM id prefix to prevent id collisions.
194     idPrefix: string;
195
196     // mode: 'create' for creating a new record,
197     //       'update' for editing an existing record
198     //       'view' for viewing an existing record without editing
199     @Input() mode: 'create' | 'update' | 'view' = 'create';
200
201     // custom function for munging the record before it gets saved;
202     // will get passed mode and the record itself
203     @Input() preSave: Function;
204
205     // recordId and record getters and setters.
206     // Note that setting the this.recordId to NULL does not clear the
207     // current value of this.record and vice versa.  Only viable data
208     // is actionable.  This allows the caller to use both @Input()'s
209     // without each clobbering the other.
210
211     // Record ID to view/update.
212     _recordId: any = null;
213     @Input() set recordId(id: any) {
214         if (id) {
215             if (id !== this._recordId) {
216                 this._recordId = id;
217                 this._record = null; // force re-fetch
218                 this.handleRecordChange();
219             }
220         } else {
221             this._recordId = null;
222         }
223     }
224
225     get recordId(): any {
226         return this._recordId;
227     }
228
229     // IDL record we are editing
230     _record: IdlObject = null;
231     @Input() set record(r: IdlObject) {
232         if (r) {
233             if (!this.idl.pkeyMatches(this.record, r)) {
234                 this._record = r;
235                 this._recordId = null; // avoid mismatch
236                 this.handleRecordChange();
237             }
238         } else {
239             this._record = null;
240         }
241     }
242
243     get record(): IdlObject {
244         return this._record;
245     }
246
247     actions: FmRecordEditorActionComponent[] = [];
248
249     initDone: boolean;
250
251     // Comma-separated list of field names defining the order in which
252     // fields should be rendered in the form.  Any fields not represented
253     // will be rendered alphabetically by label after the named fields.
254     @Input() fieldOrder: string;
255
256     // When true, show a delete button and support delete operations.
257     @Input() showDelete: boolean;
258
259     constructor(
260       private modal: NgbModal, // required for passing to parent
261       private idl: IdlService,
262       private toast: ToastService,
263       private format: FormatService,
264       private org: OrgService,
265       private pcrud: PcrudService) {
266         super(modal);
267     }
268
269     // Avoid fetching data on init since that may lead to unnecessary
270     // data retrieval.
271     ngOnInit() {
272
273         // In case the caller sets the value to null / undef.
274         if (!this.fieldOptions) { this.fieldOptions = {}; }
275
276         this.listifyInputs();
277         this.idlDef = this.idl.classes[this.idlClass];
278         this.recordLabel = this.recordLabel || this.idlDef.label;
279
280         // Add some randomness to the generated DOM IDs to ensure against clobbering
281         // eslint-disable-next-line no-magic-numbers
282         this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
283
284         if (this.isDialog()) {
285             this.onOpen$.subscribe(() => this.initRecord());
286         } else {
287             this.initRecord();
288         }
289         this.initDone = true;
290     }
291
292     // If the record ID changes after ngOnInit has been called
293     // and we're using displayMode=inline, force the data to
294     // resync in real time
295     handleRecordChange() {
296         if (this.initDone && !this.isDialog()) {
297             this.initRecord();
298         }
299     }
300
301     open(args?: NgbModalOptions): Observable<any> {
302         if (!args) {
303             args = {};
304         }
305         // ensure we don't hang on to our copy of the record
306         // if the user dismisses the dialog
307         args.beforeDismiss = () => {
308             this.record = undefined;
309             return true;
310         };
311         return super.open(args);
312     }
313
314     isDialog(): boolean {
315         return this.displayMode === 'dialog';
316     }
317
318     isDirty(): boolean {
319         return this.fmEditForm ? this.fmEditForm.dirty : false;
320     }
321
322     // DEPRECATED: This is a duplicate of this.record = abc;
323     setRecord(record: IdlObject) {
324         console.warn('fm-editor:setRecord() is deprecated. ' +
325             'Use editor.record = abc or [record]="abc" instead');
326         this.record = record; // this calls the setter
327     }
328
329     // Translate comma-separated string versions of various inputs
330     // to arrays.
331     private listifyInputs() {
332         if (this.hiddenFields) {
333             this.hiddenFieldsList = this.hiddenFields.split(/,/);
334         }
335         if (this.readonlyFields) {
336             this.readonlyFieldsList = this.readonlyFields.split(/,/);
337         }
338         if (this.requiredFields) {
339             this.requiredFieldsList = this.requiredFields.split(/,/);
340         }
341         if (this.datetimeFields) {
342             this.datetimeFieldsList = this.datetimeFields.split(/,/);
343         }
344         if (this.orgDefaultAllowed) {
345             this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
346         }
347     }
348
349     private initRecord(): Promise<any> {
350
351         const pc = this.idlDef.permacrud || {};
352         this.modePerms = {
353             view:   pc.retrieve ? pc.retrieve.perms : [],
354             create: pc.create ? pc.create.perms : [],
355             update: pc.update ? pc.update.perms : [],
356         };
357
358         this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
359
360         if (this.mode === 'update' || this.mode === 'view') {
361
362             let promise;
363             if (this.record && this.recordId === null) {
364                 promise = Promise.resolve(this.record);
365             } else if (this.recordId) {
366                 promise =
367                     this.pcrud.retrieve(this.idlClass, this.recordId).toPromise();
368             } else {
369                 // Not enough data yet to fetch anything
370                 return Promise.resolve();
371             }
372
373             return promise.then(rec => {
374
375                 if (!rec) {
376                     return Promise.reject(`No '${this.idlClass}'
377                         record found with id ${this.recordId}`);
378                 }
379
380                 // Set this._record (not this.record) to avoid loop in initRecord()
381                 this._record = rec;
382                 this.convertDatatypesToJs();
383                 return this.getFieldList();
384             });
385         }
386
387         // In 'create' mode.
388         //
389         // Create a new record from the stub record provided by the
390         // caller or a new from-scratch record
391         if (!this.record) {
392             // NOTE: Set this._record (not this.record) to avoid
393             // loop in initRecord()
394             if (this.defaultNewRecord) {
395                 // Clone to avoid polluting the stub record
396                 this._record = this.idl.clone(this.defaultNewRecord);
397             } else {
398                 this._record = this.idl.create(this.idlClass);
399             }
400         }
401         this._recordId = null; // avoid future confusion
402
403         return this.getFieldList();
404     }
405
406     // Modifies the FM record in place, replacing IDL-compatible values
407     // with native JS values.
408     private convertDatatypesToJs() {
409         this.idlDef.fields.forEach(field => {
410             if (field.datatype === 'bool') {
411                 if (this.record[field.name]() === 't') {
412                     this.record[field.name](true);
413                 } else if (this.record[field.name]() === 'f') {
414                     this.record[field.name](false);
415                 }
416             }
417         });
418     }
419
420     // Modifies the provided FM record in place, replacing JS values
421     // with IDL-compatible values.
422     convertDatatypesToIdl(rec: IdlObject) {
423         const fields = this.idlDef.fields.filter(f => !f.virtual);
424
425         fields.forEach(field => {
426             if (field.datatype === 'bool') {
427                 if (rec[field.name]() === true) {
428                     rec[field.name]('t');
429                 // } else if (rec[field.name]() === false) {
430                 } else { // TODO: some bools can be NULL
431                     rec[field.name]('f');
432                 }
433             } else if (field.datatype === 'org_unit') {
434                 const org = rec[field.name]();
435                 if (org && typeof org === 'object') {
436                     rec[field.name](org.id());
437                 }
438             }
439         });
440     }
441
442     private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
443         const class_ = field.class;
444         const fieldOptions = this.fieldOptions[field.name] || {};
445         const idField = this.idl.classes[class_].pkey;
446
447         const selector = fieldOptions.linkedSearchField
448             || this.idl.getClassSelector(class_) || idField;
449
450         return list.map(item => {
451             if (item !== undefined) {
452                 return {id: item[idField](), label: this.getFmRecordLabel(field, selector, item)};
453             }
454         });
455     }
456
457     private getFmRecordLabel(field: any, selector: string, fm: IdlObject): string {
458         // for now, need to keep in sync with getFmRecordLabel in combobox
459         // alternatively, have fm-edit not wire-up the combobox's data source for it
460         switch (field.class) {
461             case 'acmc':
462                 return fm.course_number() + ': ' + fm.name();
463             case 'acqf':
464                 return fm.code() + ' (' + fm.year() + ')'
465                        + ' (' + this.getOrgShortname(fm.org()) + ')';
466             case 'acpl':
467                 return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
468             default:
469                 // no equivalent of idlIncludeLibraryInLabel yet
470                 return fm[selector]();
471         }
472     }
473     getOrgShortname(ou: any) {
474         if (typeof ou === 'object') {
475             return ou.shortname();
476         } else {
477             return this.org.get(ou).shortname();
478         }
479     }
480
481     private getFieldList(): Promise<any> {
482
483         const fields = this.idlDef.fields.filter(f =>
484             !f.virtual && !this.hiddenFieldsList.includes(f.name));
485
486         // Wait for all network calls to complete
487         return Promise.all(
488             fields.map(field => this.constructOneField(field))
489
490         ).then(() => {
491             const order = this.fieldOrder ? this.fieldOrder.split(/,/) : [];
492             this.fields = this.idl.sortIdlFields(fields, order);
493         });
494     }
495
496     private constructOneField(field: any): Promise<any> {
497
498         let promise = null;
499         const fieldOptions = this.fieldOptions[field.name] || {};
500
501         if (this.mode === 'view') {
502             field.readOnly = true;
503         } else if (fieldOptions.isReadonlyOverride) {
504             field.readOnly =
505                 !fieldOptions.isReadonlyOverride(field.name, this.record);
506         } else {
507             field.readOnly = fieldOptions.isReadonly === true
508                 || this.readonlyFieldsList.includes(field.name);
509         }
510
511         if (fieldOptions.isRequiredOverride) {
512             field.isRequired = () => {
513                 return fieldOptions.isRequiredOverride(field.name, this.record);
514             };
515         } else {
516             field.isRequired = () => {
517                 return field.required
518                     || fieldOptions.isRequired
519                     || this.requiredFieldsList.includes(field.name);
520             };
521         }
522
523         if (fieldOptions.customTemplate) {
524             field.template = fieldOptions.customTemplate.template;
525             field.context = fieldOptions.customTemplate.context;
526         } else if (fieldOptions.customValues) {
527
528             field.linkedValues = fieldOptions.customValues;
529
530         } else if (field.datatype === 'link' && field.readOnly) {
531
532             // no need to fetch all possible values for read-only fields
533             const idToFetch = this.record[field.name]();
534
535             if (idToFetch) {
536
537                 // If the linked class defines a selector field, fetch the
538                 // linked data so we can display the data within the selector
539                 // field.  Otherwise, avoid the network lookup and let the
540                 // bare value (usually an ID) be displayed.
541                 const selector = fieldOptions.linkedSearchField ||
542                     this.idl.getClassSelector(field.class);
543
544                 if (selector && selector !== field.name) {
545                     promise = this.pcrud.retrieve(field.class, idToFetch)
546                         .toPromise().then(list => {
547                             field.linkedValues =
548                                 this.flattenLinkedValues(field, Array(list));
549                         });
550                 } else {
551                     // No selector, display the raw id/key value.
552                     field.linkedValues = [{id: idToFetch, name: idToFetch}];
553                 }
554             }
555
556         } else if (field.datatype === 'link') {
557
558             if (fieldOptions.linkedSearchConditions) {
559                 field.idlBaseQuery = fieldOptions.linkedSearchConditions;
560             }
561             field.selector = fieldOptions.linkedSearchField ||
562                              this.idl.getClassSelector(field.class);
563
564         } else if (field.datatype === 'timestamp') {
565             field.datetime = this.datetimeFieldsList.includes(field.name);
566         } else if (field.datatype === 'org_unit') {
567             field.orgDefaultAllowed =
568                 this.orgDefaultAllowedList.includes(field.name);
569             if (fieldOptions.persistKey) {
570                 field.persistKey = fieldOptions.persistKey;
571             }
572         }
573
574         if (fieldOptions.helpText) {
575             field.helpText = fieldOptions.helpText;
576             field.helpText.current().then(help => field.helpTextValue = help);
577         }
578
579         if (fieldOptions.min) {
580             field.min = Number(fieldOptions.min);
581         }
582         if (fieldOptions.max) {
583             field.max = Number(fieldOptions.max);
584         }
585
586         return promise || Promise.resolve();
587     }
588
589     // Returns a context object to be inserted into a custom
590     // field template.
591     customTemplateFieldContext(fieldDef: any): CustomFieldContext {
592         return Object.assign(
593             {   record : this.record,
594                 field: fieldDef // from this.fields
595             },  fieldDef.context || {}
596         );
597     }
598
599     save() {
600         const recToSave = this.idl.clone(this.record);
601         if (this.preSave) {
602             this.preSave(this.mode, recToSave);
603         }
604         this.convertDatatypesToIdl(recToSave);
605         this.pcrud[this.mode]([recToSave]).toPromise().then(
606             result => {
607                 this.recordSaved.emit(result);
608                 if (this.fmEditForm) {
609                     this.fmEditForm.form.markAsPristine();
610                 }
611                 this.successStr.current().then(msg => this.toast.success(msg));
612                 if (this.isDialog()) { this.record = undefined; this.close(result); }
613             },
614             error => {
615                 this.recordError.emit(error);
616                 this.failStr.current().then(msg => this.toast.warning(msg));
617                 if (this.isDialog() && !this.remainOpenOnError) { this.error(error); }
618             }
619         );
620     }
621
622     remove() {
623         this.confirmDel.open().subscribe(confirmed => {
624             if (!confirmed) { return; }
625             const recToRemove = this.idl.clone(this.record);
626             this.pcrud.remove(recToRemove).toPromise().then(
627                 result => {
628                     this.recordDeleted.emit(result);
629                     this.successStr.current().then(msg => this.toast.success(msg));
630                     if (this.isDialog()) { this.close(result); }
631                 },
632                 error => {
633                     this.recordError.emit(error);
634                     this.failStr.current().then(msg => this.toast.warning(msg));
635                     if (this.isDialog() && !this.remainOpenOnError) { this.error(error); }
636                 }
637             );
638         });
639     }
640
641     cancel() {
642         this.recordCanceled.emit(this.record);
643         this.record = undefined;
644         this.close();
645     }
646
647     closeEditor() {
648         this.record = undefined;
649         this.close();
650     }
651
652     // Returns a string describing the type of input to display
653     // for a given field.  This helps cut down on the if/else
654     // nesti-ness in the template.  Each field will match
655     // exactly one type.
656     inputType(field: any): string {
657
658         if (field.template) {
659             return 'template';
660         }
661
662         if ( field.datatype === 'timestamp' && field.datetime ) {
663             return 'timestamp-timepicker';
664         }
665
666         if (this.idlDef.pkey === field.name && !this.pkeyIsEditable) {
667             return 'readonly';
668         }
669
670         // Some widgets handle readOnly for us.
671         if (   field.datatype === 'timestamp'
672             || field.datatype === 'org_unit'
673             || field.datatype === 'bool') {
674             return field.datatype;
675         }
676
677         if (field.readOnly) {
678             if (field.datatype === 'money') {
679                 return 'readonly-money';
680             }
681
682             if (field.datatype === 'link' && field.class === 'au') {
683                 return 'readonly-au';
684             }
685
686             if (field.datatype === 'link' || field.linkedValues) {
687                 return 'readonly-list';
688             }
689
690             return 'readonly';
691         }
692
693         if (field.datatype === 'id' && !this.pkeyIsEditable) {
694             return 'readonly';
695         }
696
697         if (   field.datatype === 'int'
698             || field.datatype === 'float'
699             || field.datatype === 'money') {
700             return field.datatype;
701         }
702
703         if (field.datatype === 'link') {
704             return 'link';
705         }
706
707         if (field.linkedValues) {
708             return 'list';
709         }
710
711         // datatype == text / interval / editable-pkey
712         return 'text';
713     }
714
715     openTranslator(field: string) {
716         this.translator.fieldName = field;
717         this.translator.idlObject = this.record;
718
719         this.translator.open().subscribe(
720             newValue => {
721                 if (newValue) {
722                     this.record[field](newValue);
723                 }
724             }
725         );
726     }
727 }
728
729 // https://stackoverflow.com/a/57812865
730 @Directive({
731     selector: 'input[type=number][egMin][formControlName],input[type=number][egMin][formControl],input[type=number][egMin][ngModel]',
732     providers: [{ provide: NG_VALIDATORS, useExisting: MinValidatorDirective, multi: true }]
733 })
734 export class MinValidatorDirective implements Validator {
735     @HostBinding('attr.egMin') @Input() egMin: number;
736
737     constructor() { }
738
739     validate(control: AbstractControl): ValidationErrors | null {
740         const validator = Validators.min(this.egMin);
741         return validator(control);
742     }
743 }
744 @Directive({
745     selector: 'input[type=number][egMax][formControlName],input[type=number][egMax][formControl],input[type=number][egMax][ngModel]',
746     providers: [{ provide: NG_VALIDATORS, useExisting: MaxValidatorDirective, multi: true }]
747 })
748 export class MaxValidatorDirective implements Validator {
749     @HostBinding('attr.egMax') @Input() egMax: number;
750
751     constructor() { }
752
753     validate(control: AbstractControl): ValidationErrors | null {
754         const validator = Validators.max(this.egMax);
755         return validator(control);
756     }
757 }