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