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