LP1816475: Booking module refresh
[working/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 {IdlService, IdlObject} from '@eg/core/idl.service';
4 import {Observable} from 'rxjs';
5 import {map} from 'rxjs/operators';
6 import {AuthService} from '@eg/core/auth.service';
7 import {PcrudService} from '@eg/core/pcrud.service';
8 import {DialogComponent} from '@eg/share/dialog/dialog.component';
9 import {ToastService} from '@eg/share/toast/toast.service';
10 import {StringComponent} from '@eg/share/string/string.component';
11 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
12 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
13 import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
14 import {FormatService} from '@eg/core/format.service';
15
16 interface CustomFieldTemplate {
17     template: TemplateRef<any>;
18
19     // Allow the caller to pass in a free-form context blob to
20     // be addedto the caller's custom template context, along
21     // with our stock context.
22     context?: {[fields: string]: any};
23 }
24
25 export interface CustomFieldContext {
26     // Current create/edit/view record
27     record: IdlObject;
28
29     // IDL field definition blob
30     field: any;
31
32     // additional context values passed via CustomFieldTemplate
33     [fields: string]: any;
34 }
35
36 // Collection of extra options that may be applied to fields
37 // for controling non-default behaviour.
38 export interface FmFieldOptions {
39
40     // Render the field as a combobox using these values, regardless
41     // of the field's datatype.
42     customValues?: {[field: string]: ComboboxEntry[]};
43
44     // Provide / override the "selector" value for the linked class.
45     // This is the field the combobox will search for typeahead.  If no
46     // field is defined, the "selector" field is used.  If no "selector"
47     // field exists, the combobox will pre-load all linked values so
48     // the user can click to navigate.
49     linkedSearchField?: string;
50
51     // When true for combobox fields, pre-fetch the combobox data
52     // so the user can click or type to find values.
53     preloadLinkedValues?: boolean;
54
55     // Directly override the required state of the field.
56     // This only has an affect if the value is true.
57     isRequired?: boolean;
58
59     // If this function is defined, the function will be called
60     // at render time to see if the field should be marked are required.
61     // This supersedes all other isRequired specifiers.
62     isRequiredOverride?: (field: string, record: IdlObject) => boolean;
63
64     // Directly apply the readonly status of the field.
65     // This only has an affect if the value is true.
66     isReadonly?: boolean;
67
68     // Render the field using this custom template instead of chosing
69     // from the default set of form inputs.
70     customTemplate?: CustomFieldTemplate;
71 }
72
73 @Component({
74   selector: 'eg-fm-record-editor',
75   templateUrl: './fm-editor.component.html',
76   /* align checkboxes when not using class="form-check" */
77   styles: ['input[type="checkbox"] {margin-left: 0px;}']
78 })
79 export class FmRecordEditorComponent
80     extends DialogComponent implements OnInit {
81
82     // IDL class hint (e.g. "aou")
83     @Input() idlClass: string;
84
85     recId: any;
86
87     // Show datetime fields in this particular timezone
88     timezone: string = this.format.wsOrgTimezone;
89
90     // IDL record we are editing
91     record: IdlObject;
92
93     // Permissions extracted from the permacrud defs in the IDL
94     // for the current IDL class
95     modePerms: {[mode: string]: string};
96
97     // Collection of FmFieldOptions for specifying non-default
98     // behaviour for each field (by field name).
99     @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
100
101     // list of fields that should not be displayed
102     @Input() hiddenFieldsList: string[] = [];
103     @Input() hiddenFields: string; // comma-separated string version
104
105     // list of fields that should always be read-only
106     @Input() readonlyFieldsList: string[] = [];
107     @Input() readonlyFields: string; // comma-separated string version
108
109     // list of required fields; this supplements what the IDL considers
110     // required
111     @Input() requiredFieldsList: string[] = [];
112     @Input() requiredFields: string; // comma-separated string version
113
114     // list of timestamp fields that should display with a timepicker
115     @Input() datetimeFieldsList: string[] = [];
116     @Input() datetimeFields: string; // comma-separated string version
117
118     // list of org_unit fields where a default value may be applied by
119     // the org-select if no value is present.
120     @Input() orgDefaultAllowedList: string[] = [];
121     @Input() orgDefaultAllowed: string; // comma-separated string version
122
123     // IDL record display label.  Defaults to the IDL label.
124     @Input() recordLabel: string;
125
126     // When true at the component level, pre-fetch the combobox data
127     // for all combobox fields.  See also FmFieldOptions.
128     @Input() preloadLinkedValues: boolean;
129
130     // Display within a modal dialog window or inline in the page.
131     @Input() displayMode: 'dialog' | 'inline' = 'dialog';
132
133     // Emit the modified object when the save action completes.
134     @Output() onSave$ = new EventEmitter<IdlObject>();
135
136     // Emit the original object when the save action is canceled.
137     @Output() onCancel$ = new EventEmitter<IdlObject>();
138
139     // Emit an error message when the save action fails.
140     @Output() onError$ = new EventEmitter<string>();
141
142     @ViewChild('translator') private translator: TranslateComponent;
143     @ViewChild('successStr') successStr: StringComponent;
144     @ViewChild('failStr') failStr: StringComponent;
145
146     // IDL info for the the selected IDL class
147     idlDef: any;
148
149     // Can we edit the primary key?
150     pkeyIsEditable = false;
151
152     // List of IDL field definitions.  This is a subset of the full
153     // list of fields on the IDL, since some are hidden, virtual, etc.
154     fields: any[];
155
156     // DOM id prefix to prevent id collisions.
157     idPrefix: string;
158
159     // mode: 'create' for creating a new record,
160     //       'update' for editing an existing record
161     //       'view' for viewing an existing record without editing
162     @Input() mode: 'create' | 'update' | 'view' = 'create';
163
164     // Record ID to view/update.  Value is dynamic.  Records are not
165     // fetched until .open() is called.
166     @Input() set recordId(id: any) {
167         if (id) { this.recId = id; }
168     }
169
170     // custom function for munging the record before it gets saved;
171     // will get passed mode and the record itself
172     @Input() preSave: Function;
173
174     constructor(
175       private modal: NgbModal, // required for passing to parent
176       private idl: IdlService,
177       private auth: AuthService,
178       private toast: ToastService,
179       private format: FormatService,
180       private pcrud: PcrudService) {
181       super(modal);
182     }
183
184     // Avoid fetching data on init since that may lead to unnecessary
185     // data retrieval.
186     ngOnInit() {
187
188         // In case the caller sets the value to null / undef.
189         if (!this.fieldOptions) { this.fieldOptions = {}; }
190
191         this.listifyInputs();
192         this.idlDef = this.idl.classes[this.idlClass];
193         this.recordLabel = this.idlDef.label;
194
195         // Add some randomness to the generated DOM IDs to ensure against clobbering
196         this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
197
198         if (this.isDialog()) {
199             this.onOpen$.subscribe(() => this.initRecord());
200         } else {
201             this.initRecord();
202         }
203     }
204
205     open(args?: NgbModalOptions): Observable<any> {
206         if (!args) {
207             args = {};
208         }
209         // ensure we don't hang on to our copy of the record
210         // if the user dismisses the dialog
211         args.beforeDismiss = () => {
212             this.record = undefined;
213             return true;
214         };
215         return super.open(args);
216     }
217
218     isDialog(): boolean {
219         return this.displayMode === 'dialog';
220     }
221
222     // Set the record value and clear the recId value to
223     // indicate the record is our current source of data.
224     setRecord(record: IdlObject) {
225         this.record = record;
226         this.recId = null;
227     }
228
229     // Translate comma-separated string versions of various inputs
230     // to arrays.
231     private listifyInputs() {
232         if (this.hiddenFields) {
233             this.hiddenFieldsList = this.hiddenFields.split(/,/);
234         }
235         if (this.readonlyFields) {
236             this.readonlyFieldsList = this.readonlyFields.split(/,/);
237         }
238         if (this.requiredFields) {
239             this.requiredFieldsList = this.requiredFields.split(/,/);
240         }
241         if (this.datetimeFields) {
242             this.datetimeFieldsList = this.datetimeFields.split(/,/);
243         }
244         if (this.orgDefaultAllowed) {
245             this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
246         }
247     }
248
249     private initRecord(): Promise<any> {
250
251         const pc = this.idlDef.permacrud || {};
252         this.modePerms = {
253             view:   pc.retrieve ? pc.retrieve.perms : [],
254             create: pc.create ? pc.create.perms : [],
255             update: pc.update ? pc.update.perms : [],
256         };
257
258         this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
259
260         if (this.mode === 'update' || this.mode === 'view') {
261
262             let promise;
263             if (this.record && this.recId === null) {
264                 promise = Promise.resolve(this.record);
265             } else {
266                 promise =
267                     this.pcrud.retrieve(this.idlClass, this.recId).toPromise();
268             }
269
270             return promise.then(rec => {
271
272                 if (!rec) {
273                     return Promise.reject(`No '${this.idlClass}'
274                         record found with id ${this.recId}`);
275                 }
276
277                 this.record = rec;
278                 this.convertDatatypesToJs();
279                 return this.getFieldList();
280             });
281         }
282
283         // In 'create' mode.
284         //
285         // Create a new record from the stub record provided by the
286         // caller or a new from-scratch record
287         this.setRecord(this.record || this.idl.create(this.idlClass));
288
289         return this.getFieldList();
290     }
291
292     // Modifies the FM record in place, replacing IDL-compatible values
293     // with native JS values.
294     private convertDatatypesToJs() {
295         this.idlDef.fields.forEach(field => {
296             if (field.datatype === 'bool') {
297                 if (this.record[field.name]() === 't') {
298                     this.record[field.name](true);
299                 } else if (this.record[field.name]() === 'f') {
300                     this.record[field.name](false);
301                 }
302             }
303         });
304     }
305
306     // Modifies the provided FM record in place, replacing JS values
307     // with IDL-compatible values.
308     convertDatatypesToIdl(rec: IdlObject) {
309         const fields = this.idlDef.fields;
310         fields.forEach(field => {
311             if (field.datatype === 'bool') {
312                 if (rec[field.name]() === true) {
313                     rec[field.name]('t');
314                 // } else if (rec[field.name]() === false) {
315                 } else { // TODO: some bools can be NULL
316                     rec[field.name]('f');
317                 }
318             } else if (field.datatype === 'org_unit') {
319                 const org = rec[field.name]();
320                 if (org && typeof org === 'object') {
321                     rec[field.name](org.id());
322                 }
323             }
324         });
325     }
326
327     // Returns the name of the field on a class (typically via a linked
328     // field) that acts as the selector value for display / search.
329     getClassSelector(class_: string): string {
330         if (class_) {
331             const linkedClass = this.idl.classes[class_];
332             return linkedClass.pkey ?
333                 linkedClass.field_map[linkedClass.pkey].selector : null;
334         }
335         return null;
336     }
337
338     private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
339         const class_ = field.class;
340         const fieldOptions = this.fieldOptions[field.name] || {};
341         const idField = this.idl.classes[class_].pkey;
342
343         const selector = fieldOptions.linkedSearchField
344             || this.getClassSelector(class_) || idField;
345
346         return list.map(item => {
347             return {id: item[idField](), label: item[selector]()};
348         });
349     }
350
351     private getFieldList(): Promise<any> {
352
353         this.fields = this.idlDef.fields.filter(f =>
354             !f.virtual && !this.hiddenFieldsList.includes(f.name)
355         );
356
357         // Wait for all network calls to complete
358         return Promise.all(
359             this.fields.map(field => this.constructOneField(field)));
360     }
361
362     private constructOneField(field: any): Promise<any> {
363
364         let promise = null;
365         const fieldOptions = this.fieldOptions[field.name] || {};
366
367         field.readOnly = this.mode === 'view'
368             || fieldOptions.isReadonly === true
369             || this.readonlyFieldsList.includes(field.name);
370
371         if (fieldOptions.isRequiredOverride) {
372             field.isRequired = () => {
373                 return fieldOptions.isRequiredOverride(field.name, this.record);
374             };
375         } else {
376             field.isRequired = () => {
377                 return field.required
378                     || fieldOptions.isRequired
379                     || this.requiredFieldsList.includes(field.name);
380             };
381         }
382
383         if (fieldOptions.customValues) {
384
385             field.linkedValues = fieldOptions.customValues;
386
387         } else if (field.datatype === 'link' && field.readOnly) {
388
389             // no need to fetch all possible values for read-only fields
390             const idToFetch = this.record[field.name]();
391
392             if (idToFetch) {
393
394                 // If the linked class defines a selector field, fetch the
395                 // linked data so we can display the data within the selector
396                 // field.  Otherwise, avoid the network lookup and let the
397                 // bare value (usually an ID) be displayed.
398                 const selector = fieldOptions.linkedSearchField ||
399                     this.getClassSelector(field.class);
400
401                 if (selector && selector !== field.name) {
402                     promise = this.pcrud.retrieve(field.class, idToFetch)
403                         .toPromise().then(list => {
404                             field.linkedValues =
405                                 this.flattenLinkedValues(field, Array(list));
406                         });
407                 } else {
408                     // No selector, display the raw id/key value.
409                     field.linkedValues = [{id: idToFetch, name: idToFetch}];
410                 }
411             }
412
413         } else if (field.datatype === 'link') {
414
415             promise = this.wireUpCombobox(field);
416
417         } else if (field.datatype === 'timestamp') {
418             field.datetime = this.datetimeFieldsList.includes(field.name);
419         } else if (field.datatype === 'org_unit') {
420             field.orgDefaultAllowed =
421                 this.orgDefaultAllowedList.includes(field.name);
422         }
423
424         if (fieldOptions.customTemplate) {
425             field.template = fieldOptions.customTemplate.template;
426             field.context = fieldOptions.customTemplate.context;
427         }
428
429         return promise || Promise.resolve();
430     }
431
432     wireUpCombobox(field: any): Promise<any> {
433
434         const fieldOptions = this.fieldOptions[field.name] || {};
435
436         // globally preloading unless a field-specific value is set.
437         if (this.preloadLinkedValues) {
438             if (!('preloadLinkedValues' in fieldOptions)) {
439                 fieldOptions.preloadLinkedValues = true;
440             }
441         }
442
443         const selector = fieldOptions.linkedSearchField ||
444             this.getClassSelector(field.class);
445
446         if (!selector && !fieldOptions.preloadLinkedValues) {
447             // User probably expects an async data source, but we can't
448             // provide one without a selector.  Warn the user.
449             console.warn(`Class ${field.class} has no selector.
450                 Pre-fetching all rows for combobox`);
451         }
452
453         if (fieldOptions.preloadLinkedValues || !selector) {
454             return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
455             .toPromise().then(list => {
456                 field.linkedValues =
457                     this.flattenLinkedValues(field, list);
458             });
459         }
460
461         // If we have a selector, wire up for async data retrieval
462         field.linkedValuesSource =
463             (term: string): Observable<ComboboxEntry> => {
464
465             const search = {};
466             const orderBy = {order_by: {}};
467             const idField = this.idl.classes[field.class].pkey || 'id';
468
469             search[selector] = {'ilike': `%${term}%`};
470             orderBy.order_by[field.class] = selector;
471
472             return this.pcrud.search(field.class, search, orderBy)
473             .pipe(map(idlThing =>
474                 // Map each object into a ComboboxEntry upon arrival
475                 this.flattenLinkedValues(field, [idlThing])[0]
476             ));
477         };
478
479         // Using an async data source, but a value is already set
480         // on the field.  Fetch the linked object and add it to the
481         // combobox entry list so it will be avilable for display
482         // at dialog load time.
483         const linkVal = this.record[field.name]();
484         if (linkVal !== null && linkVal !== undefined) {
485             return this.pcrud.retrieve(field.class, linkVal).toPromise()
486             .then(idlThing => {
487                 field.linkedValues =
488                     this.flattenLinkedValues(field, Array(idlThing));
489             });
490         }
491
492         // No linked value applied, nothing to pre-fetch.
493         return Promise.resolve();
494     }
495
496     // Returns a context object to be inserted into a custom
497     // field template.
498     customTemplateFieldContext(fieldDef: any): CustomFieldContext {
499         return Object.assign(
500             {   record : this.record,
501                 field: fieldDef // from this.fields
502             },  fieldDef.context || {}
503         );
504     }
505
506     save() {
507         const recToSave = this.idl.clone(this.record);
508         if (this.preSave) {
509             this.preSave(this.mode, recToSave);
510         }
511         this.convertDatatypesToIdl(recToSave);
512         this.pcrud[this.mode]([recToSave]).toPromise().then(
513             result => {
514                 this.onSave$.emit(result);
515                 this.successStr.current().then(msg => this.toast.success(msg));
516                 if (this.isDialog()) { this.record = undefined; this.close(result); }
517             },
518             error => {
519                 this.onError$.emit(error);
520                 this.failStr.current().then(msg => this.toast.warning(msg));
521                 if (this.isDialog()) { this.error(error); }
522             }
523         );
524     }
525
526     cancel() {
527         this.onCancel$.emit(this.record);
528         this.record = undefined;
529         this.close();
530     }
531
532     closeEditor() {
533         this.record = undefined;
534         this.close();
535     }
536
537     // Returns a string describing the type of input to display
538     // for a given field.  This helps cut down on the if/else
539     // nesti-ness in the template.  Each field will match
540     // exactly one type.
541     inputType(field: any): string {
542
543         if (field.template) {
544             return 'template';
545         }
546
547         if ( field.datatype === 'timestamp' && field.datetime ) {
548             return 'timestamp-timepicker';
549         }
550
551         // Some widgets handle readOnly for us.
552         if (   field.datatype === 'timestamp'
553             || field.datatype === 'org_unit'
554             || field.datatype === 'bool') {
555             return field.datatype;
556         }
557
558         if (field.readOnly) {
559             if (field.datatype === 'money') {
560                 return 'readonly-money';
561             }
562
563             if (field.datatype === 'link' && field.class === 'au') {
564                 return 'readonly-au';
565             }
566
567             if (field.datatype === 'link' || field.linkedValues) {
568                 return 'readonly-list';
569             }
570
571             return 'readonly';
572         }
573
574         if (field.datatype === 'id' && !this.pkeyIsEditable) {
575             return 'readonly';
576         }
577
578         if (   field.datatype === 'int'
579             || field.datatype === 'float'
580             || field.datatype === 'money') {
581             return field.datatype;
582         }
583
584         if (field.datatype === 'link' || field.linkedValues) {
585             return 'list';
586         }
587
588         // datatype == text / interval / editable-pkey
589         return 'text';
590     }
591
592     openTranslator(field: string) {
593         this.translator.fieldName = field;
594         this.translator.idlObject = this.record;
595
596         this.translator.open().subscribe(
597             newValue => {
598                 if (newValue) {
599                     this.record[field](newValue);
600                 }
601             }
602         );
603     }
604 }
605