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