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