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';
22 interface CustomFieldTemplate {
23 template: TemplateRef<any>;
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};
31 export interface CustomFieldContext {
32 // Current create/edit/view record
35 // IDL field definition blob
38 // additional context values passed via CustomFieldTemplate
39 [fields: string]: any;
42 // Collection of extra options that may be applied to fields
43 // for controling non-default behaviour.
44 export interface FmFieldOptions {
46 // Render the field as a combobox using these values, regardless
47 // of the field's datatype.
48 customValues?: ComboboxEntry[];
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;
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;
61 // Additional search conditions to include when constructing
62 // the query for a linked field's combobox
63 linkedSearchConditions?: {[field: string]: string};
65 // Directly override the required state of the field.
66 // This only has an affect if the value is true.
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;
74 // Directly apply the readonly status of the field.
75 // This only has an affect if the value is true.
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;
83 // Render the field using this custom template instead of chosing
84 // from the default set of form inputs.
85 customTemplate?: CustomFieldTemplate;
87 // Use this persistKey if the field is an org field
88 persistKey?: StringComponent;
90 // help text to display via a popover
91 helpText?: StringComponent;
93 // minimum and maximum permitted values for int fields
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;}']
104 export class FmRecordEditorComponent
105 extends DialogComponent implements OnInit {
107 // IDL class hint (e.g. "aou")
108 @Input() idlClass: string;
110 // Show datetime fields in this particular timezone
111 timezone: string = this.format.wsOrgTimezone;
113 // Permissions extracted from the permacrud defs in the IDL
114 // for the current IDL class
115 modePerms: {[mode: string]: string};
117 // Collection of FmFieldOptions for specifying non-default
118 // behaviour for each field (by field name).
119 @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
121 // This is used to set default values when making a new record
122 @Input() defaultNewRecord: IdlObject;
124 // list of fields that should not be displayed
125 @Input() hiddenFieldsList: string[] = [];
126 @Input() hiddenFields: string; // comma-separated string version
128 // list of fields that should always be read-only
129 @Input() readonlyFieldsList: string[] = [];
130 @Input() readonlyFields: string; // comma-separated string version
132 // list of required fields; this supplements what the IDL considers
134 @Input() requiredFieldsList: string[] = [];
135 @Input() requiredFields: string; // comma-separated string version
137 // list of timestamp fields that should display with a timepicker
138 @Input() datetimeFieldsList: string[] = [];
139 @Input() datetimeFields: string; // comma-separated string version
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
146 // IDL record display label. Defaults to the IDL label.
147 @Input() recordLabel: string;
149 // When true at the component level, pre-fetch the combobox data
150 // for all combobox fields. See also FmFieldOptions.
151 @Input() preloadLinkedValues: boolean;
153 // Display within a modal dialog window or inline in the page.
154 @Input() displayMode: 'dialog' | 'inline' = 'dialog';
156 // Hide the top 'Record Editor: ...' banner. Primarily useful
157 // for displayMode === 'inline'
158 @Input() hideBanner: boolean;
160 // do not close dialog on error saving record
161 @Input() remainOpenOnError: false;
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: '';
168 // Emit the modified object when the save action completes.
169 @Output() recordSaved = new EventEmitter<IdlObject>();
171 // Emit the modified object when the save action completes.
172 @Output() recordDeleted = new EventEmitter<IdlObject>();
174 // Emit the original object when the save action is canceled.
175 @Output() recordCanceled = new EventEmitter<IdlObject>();
177 // Emit an error message when the save action fails.
178 @Output() recordError = new EventEmitter<string>();
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;
186 // IDL info for the the selected IDL class
189 // Can we edit the primary key?
190 pkeyIsEditable = false;
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.
196 // DOM id prefix to prevent id collisions.
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';
204 // custom function for munging the record before it gets saved;
205 // will get passed mode and the record itself
206 @Input() preSave: Function;
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.
214 // Record ID to view/update.
215 _recordId: any = null;
216 @Input() set recordId(id: any) {
218 if (id !== this._recordId) {
220 this._record = null; // force re-fetch
221 this.handleRecordChange();
224 this._recordId = null;
228 get recordId(): any {
229 return this._recordId;
232 // IDL record we are editing
233 _record: IdlObject = null;
234 @Input() set record(r: IdlObject) {
236 if (!this.idl.pkeyMatches(this.record, r)) {
238 this._recordId = null; // avoid mismatch
239 this.handleRecordChange();
246 get record(): IdlObject {
250 actions: FmRecordEditorActionComponent[] = [];
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;
259 // When true, show a delete button and support delete operations.
260 @Input() showDelete: boolean;
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) {
273 // Avoid fetching data on init since that may lead to unnecessary
277 // In case the caller sets the value to null / undef.
278 if (!this.fieldOptions) { this.fieldOptions = {}; }
280 this.listifyInputs();
281 this.idlDef = this.idl.classes[this.idlClass];
282 this.recordLabel = this.recordLabel || this.idlDef.label;
284 // Add some randomness to the generated DOM IDs to ensure against clobbering
285 this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
287 if (this.isDialog()) {
288 this.onOpen$.subscribe(() => this.initRecord());
292 this.initDone = true;
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()) {
304 open(args?: NgbModalOptions): Observable<any> {
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;
314 return super.open(args);
317 isDialog(): boolean {
318 return this.displayMode === 'dialog';
322 return this.fmEditForm ? this.fmEditForm.dirty : false;
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
332 // Translate comma-separated string versions of various inputs
334 private listifyInputs() {
335 if (this.hiddenFields) {
336 this.hiddenFieldsList = this.hiddenFields.split(/,/);
338 if (this.readonlyFields) {
339 this.readonlyFieldsList = this.readonlyFields.split(/,/);
341 if (this.requiredFields) {
342 this.requiredFieldsList = this.requiredFields.split(/,/);
344 if (this.datetimeFields) {
345 this.datetimeFieldsList = this.datetimeFields.split(/,/);
347 if (this.orgDefaultAllowed) {
348 this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
352 private initRecord(): Promise<any> {
354 const pc = this.idlDef.permacrud || {};
356 view: pc.retrieve ? pc.retrieve.perms : [],
357 create: pc.create ? pc.create.perms : [],
358 update: pc.update ? pc.update.perms : [],
361 this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
363 if (this.mode === 'update' || this.mode === 'view') {
366 if (this.record && this.recordId === null) {
367 promise = Promise.resolve(this.record);
368 } else if (this.recordId) {
370 this.pcrud.retrieve(this.idlClass, this.recordId).toPromise();
372 // Not enough data yet to fetch anything
373 return Promise.resolve();
376 return promise.then(rec => {
379 return Promise.reject(`No '${this.idlClass}'
380 record found with id ${this.recordId}`);
383 // Set this._record (not this.record) to avoid loop in initRecord()
385 this.convertDatatypesToJs();
386 return this.getFieldList();
392 // Create a new record from the stub record provided by the
393 // caller or a new from-scratch 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);
401 this._record = this.idl.create(this.idlClass);
404 this._recordId = null; // avoid future confusion
406 return this.getFieldList();
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);
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);
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');
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());
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;
450 const selector = fieldOptions.linkedSearchField
451 || this.idl.getClassSelector(class_) || idField;
453 return list.map(item => {
454 if (item !== undefined) {
455 return {id: item[idField](), label: this.getFmRecordLabel(field, selector, item)};
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) {
465 return fm.course_number() + ': ' + fm.name();
468 return fm.code() + ' (' + fm.year() + ')'
469 + ' (' + this.getOrgShortname(fm.org()) + ')';
472 return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
475 // no equivalent of idlIncludeLibraryInLabel yet
476 return fm[selector]();
479 getOrgShortname(ou: any) {
480 if (typeof ou === 'object') {
481 return ou.shortname();
483 return this.org.get(ou).shortname();
487 private getFieldList(): Promise<any> {
489 const fields = this.idlDef.fields.filter(f =>
490 !f.virtual && !this.hiddenFieldsList.includes(f.name));
492 // Wait for all network calls to complete
494 fields.map(field => this.constructOneField(field))
497 const order = this.fieldOrder ? this.fieldOrder.split(/,/) : [];
498 this.fields = this.idl.sortIdlFields(fields, order);
502 private constructOneField(field: any): Promise<any> {
505 const fieldOptions = this.fieldOptions[field.name] || {};
507 if (this.mode === 'view') {
508 field.readOnly = true;
509 } else if (fieldOptions.isReadonlyOverride) {
511 !fieldOptions.isReadonlyOverride(field.name, this.record);
513 field.readOnly = fieldOptions.isReadonly === true
514 || this.readonlyFieldsList.includes(field.name);
517 if (fieldOptions.isRequiredOverride) {
518 field.isRequired = () => {
519 return fieldOptions.isRequiredOverride(field.name, this.record);
522 field.isRequired = () => {
523 return field.required
524 || fieldOptions.isRequired
525 || this.requiredFieldsList.includes(field.name);
529 if (fieldOptions.customTemplate) {
530 field.template = fieldOptions.customTemplate.template;
531 field.context = fieldOptions.customTemplate.context;
532 } else if (fieldOptions.customValues) {
534 field.linkedValues = fieldOptions.customValues;
536 } else if (field.datatype === 'link' && field.readOnly) {
538 // no need to fetch all possible values for read-only fields
539 const idToFetch = this.record[field.name]();
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);
550 if (selector && selector !== field.name) {
551 promise = this.pcrud.retrieve(field.class, idToFetch)
552 .toPromise().then(list => {
554 this.flattenLinkedValues(field, Array(list));
557 // No selector, display the raw id/key value.
558 field.linkedValues = [{id: idToFetch, name: idToFetch}];
562 } else if (field.datatype === 'link') {
564 if (fieldOptions.linkedSearchConditions) {
565 field.idlBaseQuery = fieldOptions.linkedSearchConditions;
567 field.selector = fieldOptions.linkedSearchField ||
568 this.idl.getClassSelector(field.class);
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;
580 if (fieldOptions.helpText) {
581 field.helpText = fieldOptions.helpText;
582 field.helpText.current().then(help => field.helpTextValue = help);
585 if (fieldOptions.min) {
586 field.min = Number(fieldOptions.min);
588 if (fieldOptions.max) {
589 field.max = Number(fieldOptions.max);
592 return promise || Promise.resolve();
595 // Returns a context object to be inserted into a custom
597 customTemplateFieldContext(fieldDef: any): CustomFieldContext {
598 return Object.assign(
599 { record : this.record,
600 field: fieldDef // from this.fields
601 }, fieldDef.context || {}
606 const recToSave = this.idl.clone(this.record);
608 this.preSave(this.mode, recToSave);
610 this.convertDatatypesToIdl(recToSave);
611 this.pcrud[this.mode]([recToSave]).toPromise().then(
613 this.recordSaved.emit(result);
614 if (this.fmEditForm) {
615 this.fmEditForm.form.markAsPristine();
617 this.successStr.current().then(msg => this.toast.success(msg));
618 if (this.isDialog()) { this.record = undefined; this.close(result); }
621 this.recordError.emit(error);
622 this.failStr.current().then(msg => this.toast.warning(msg));
623 if (this.isDialog() && !this.remainOpenOnError) { this.error(error); }
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(
634 this.recordDeleted.emit(result);
635 this.successStr.current().then(msg => this.toast.success(msg));
636 if (this.isDialog()) { this.close(result); }
639 this.recordError.emit(error);
640 this.failStr.current().then(msg => this.toast.warning(msg));
641 if (this.isDialog() && !this.remainOpenOnError) { this.error(error); }
648 this.recordCanceled.emit(this.record);
649 this.record = undefined;
654 this.record = undefined;
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
662 inputType(field: any): string {
664 if (field.template) {
668 if ( field.datatype === 'timestamp' && field.datetime ) {
669 return 'timestamp-timepicker';
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;
679 if (field.readOnly) {
680 if (field.datatype === 'money') {
681 return 'readonly-money';
684 if (field.datatype === 'link' && field.class === 'au') {
685 return 'readonly-au';
688 if (field.datatype === 'link' || field.linkedValues) {
689 return 'readonly-list';
695 if (field.datatype === 'id' && !this.pkeyIsEditable) {
699 if ( field.datatype === 'int'
700 || field.datatype === 'float'
701 || field.datatype === 'money') {
702 return field.datatype;
705 if (field.datatype === 'link') {
709 if (field.linkedValues) {
713 // datatype == text / interval / editable-pkey
717 openTranslator(field: string) {
718 this.translator.fieldName = field;
719 this.translator.idlObject = this.record;
721 this.translator.open().subscribe(
724 this.record[field](newValue);
731 // https://stackoverflow.com/a/57812865
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 }]
736 export class MinValidatorDirective implements Validator {
737 @HostBinding('attr.egMin') @Input() egMin: number;
741 validate(control: AbstractControl): ValidationErrors | null {
742 const validator = Validators.min(this.egMin);
743 return validator(control);
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 }]
750 export class MaxValidatorDirective implements Validator {
751 @HostBinding('attr.egMax') @Input() egMax: number;
755 validate(control: AbstractControl): ValidationErrors | null {
756 const validator = Validators.max(this.egMax);
757 return validator(control);