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