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