]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
a574e5491da83560bc16eb44b17e88ecbbccb0a1
[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} 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
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 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     // 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     // Display within a modal dialog window or inline in the page.
124     @Input() displayMode: 'dialog' | 'inline' = 'dialog';
125
126     // Emit the modified object when the save action completes.
127     @Output() onSave$ = new EventEmitter<IdlObject>();
128
129     // Emit the original object when the save action is canceled.
130     @Output() onCancel$ = new EventEmitter<IdlObject>();
131
132     // Emit an error message when the save action fails.
133     @Output() onError$ = new EventEmitter<string>();
134
135     @ViewChild('translator') private translator: TranslateComponent;
136     @ViewChild('successStr') successStr: StringComponent;
137     @ViewChild('failStr') failStr: StringComponent;
138
139     // IDL info for the the selected IDL class
140     idlDef: any;
141
142     // Can we edit the primary key?
143     pkeyIsEditable = false;
144
145     // List of IDL field definitions.  This is a subset of the full
146     // list of fields on the IDL, since some are hidden, virtual, etc.
147     fields: any[];
148
149     // DOM id prefix to prevent id collisions.
150     idPrefix: string;
151
152     // mode: 'create' for creating a new record,
153     //       'update' for editing an existing record
154     //       'view' for viewing an existing record without editing
155     @Input() mode: 'create' | 'update' | 'view' = 'create';
156
157     // Record ID to view/update.  Value is dynamic.  Records are not
158     // fetched until .open() is called.
159     @Input() set recordId(id: any) {
160         if (id) { this.recId = id; }
161     }
162
163     constructor(
164       private modal: NgbModal, // required for passing to parent
165       private idl: IdlService,
166       private auth: AuthService,
167       private toast: ToastService,
168       private pcrud: PcrudService) {
169       super(modal);
170     }
171
172     // Avoid fetching data on init since that may lead to unnecessary
173     // data retrieval.
174     ngOnInit() {
175         this.listifyInputs();
176         this.idlDef = this.idl.classes[this.idlClass];
177         this.recordLabel = this.idlDef.label;
178
179         // Add some randomness to the generated DOM IDs to ensure against clobbering
180         this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
181
182         if (this.isDialog()) {
183             this.onOpen$.subscribe(() => this.initRecord());
184         } else {
185             this.initRecord();
186         }
187     }
188
189     isDialog(): boolean {
190         return this.displayMode === 'dialog';
191     }
192
193     // Set the record value and clear the recId value to
194     // indicate the record is our current source of data.
195     setRecord(record: IdlObject) {
196         this.record = record;
197         this.recId = null;
198     }
199
200     // Translate comma-separated string versions of various inputs
201     // to arrays.
202     private listifyInputs() {
203         if (this.hiddenFields) {
204             this.hiddenFieldsList = this.hiddenFields.split(/,/);
205         }
206         if (this.readonlyFields) {
207             this.readonlyFieldsList = this.readonlyFields.split(/,/);
208         }
209         if (this.requiredFields) {
210             this.requiredFieldsList = this.requiredFields.split(/,/);
211         }
212         if (this.orgDefaultAllowed) {
213             this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
214         }
215     }
216
217     private initRecord(): Promise<any> {
218
219         const pc = this.idlDef.permacrud || {};
220         this.modePerms = {
221             view:   pc.retrieve ? pc.retrieve.perms : [],
222             create: pc.create ? pc.create.perms : [],
223             update: pc.update ? pc.update.perms : [],
224         };
225
226         this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
227
228         if (this.mode === 'update' || this.mode === 'view') {
229
230             let promise;
231             if (this.record && this.recId === null) {
232                 promise = Promise.resolve(this.record);
233             } else {
234                 promise =
235                     this.pcrud.retrieve(this.idlClass, this.recId).toPromise();
236             }
237
238             return promise.then(rec => {
239
240                 if (!rec) {
241                     return Promise.reject(`No '${this.idlClass}'
242                         record found with id ${this.recId}`);
243                 }
244
245                 this.record = rec;
246                 this.convertDatatypesToJs();
247                 return this.getFieldList();
248             });
249         }
250
251         // In 'create' mode.
252         //
253         // Create a new record from the stub record provided by the
254         // caller or a new from-scratch record
255         this.setRecord(this.record || this.idl.create(this.idlClass));
256
257         return this.getFieldList();
258     }
259
260     // Modifies the FM record in place, replacing IDL-compatible values
261     // with native JS values.
262     private convertDatatypesToJs() {
263         this.idlDef.fields.forEach(field => {
264             if (field.datatype === 'bool') {
265                 if (this.record[field.name]() === 't') {
266                     this.record[field.name](true);
267                 } else if (this.record[field.name]() === 'f') {
268                     this.record[field.name](false);
269                 }
270             }
271         });
272     }
273
274     // Modifies the provided FM record in place, replacing JS values
275     // with IDL-compatible values.
276     convertDatatypesToIdl(rec: IdlObject) {
277         const fields = this.idlDef.fields;
278         fields.forEach(field => {
279             if (field.datatype === 'bool') {
280                 if (rec[field.name]() === true) {
281                     rec[field.name]('t');
282                 // } else if (rec[field.name]() === false) {
283                 } else { // TODO: some bools can be NULL
284                     rec[field.name]('f');
285                 }
286             } else if (field.datatype === 'org_unit') {
287                 const org = rec[field.name]();
288                 if (org && typeof org === 'object') {
289                     rec[field.name](org.id());
290                 }
291             }
292         });
293     }
294
295     // Returns the name of the field on a class (typically via a linked
296     // field) that acts as the selector value for display / search.
297     getClassSelector(class_: string): string {
298         if (class_) {
299             const linkedClass = this.idl.classes[class_];
300             return linkedClass.pkey ?
301                 linkedClass.field_map[linkedClass.pkey].selector : null;
302         }
303         return null;
304     }
305
306     private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
307         const class_ = field.class;
308         const fieldOptions = this.fieldOptions[field.name] || {};
309         const idField = this.idl.classes[class_].pkey;
310
311         const selector = fieldOptions.linkedSearchField
312             || this.getClassSelector(class_) || idField;
313
314         return list.map(item => {
315             return {id: item[idField](), label: item[selector]()};
316         });
317     }
318
319     private getFieldList(): Promise<any> {
320
321         this.fields = this.idlDef.fields.filter(f =>
322             !f.virtual && !this.hiddenFieldsList.includes(f.name)
323         );
324
325         // Wait for all network calls to complete
326         return Promise.all(
327             this.fields.map(field => this.constructOneField(field)));
328     }
329
330     private constructOneField(field: any): Promise<any> {
331
332         let promise = null;
333         const fieldOptions = this.fieldOptions[field.name] || {};
334
335         field.readOnly = this.mode === 'view'
336             || fieldOptions.isReadonly === true
337             || this.readonlyFieldsList.includes(field.name);
338
339         if (fieldOptions.isRequiredOverride) {
340             field.isRequired = () => {
341                 return fieldOptions.isRequiredOverride(field.name, this.record);
342             };
343         } else {
344             field.isRequired = () => {
345                 return field.required
346                     || fieldOptions.isRequired
347                     || this.requiredFieldsList.includes(field.name);
348             };
349         }
350
351         if (fieldOptions.customValues) {
352
353             field.linkedValues = fieldOptions.customValues;
354
355         } else if (field.datatype === 'link' && field.readOnly) {
356
357             // no need to fetch all possible values for read-only fields
358             const idToFetch = this.record[field.name]();
359
360             if (idToFetch) {
361
362                 // If the linked class defines a selector field, fetch the
363                 // linked data so we can display the data within the selector
364                 // field.  Otherwise, avoid the network lookup and let the
365                 // bare value (usually an ID) be displayed.
366                 const selector = fieldOptions.linkedSearchField ||
367                     this.getClassSelector(field.class);
368
369                 if (selector && selector !== field.name) {
370                     promise = this.pcrud.retrieve(field.class, idToFetch)
371                         .toPromise().then(list => {
372                             field.linkedValues =
373                                 this.flattenLinkedValues(field, Array(list));
374                         });
375                 } else {
376                     // No selector, display the raw id/key value.
377                     field.linkedValues = [{id: idToFetch, name: idToFetch}];
378                 }
379             }
380
381         } else if (field.datatype === 'link') {
382
383             promise = this.wireUpCombobox(field);
384
385         } else if (field.datatype === 'org_unit') {
386             field.orgDefaultAllowed =
387                 this.orgDefaultAllowedList.includes(field.name);
388         }
389
390         if (fieldOptions.customTemplate) {
391             field.template = fieldOptions.customTemplate.template;
392             field.context = fieldOptions.customTemplate.context;
393         }
394
395         return promise || Promise.resolve();
396     }
397
398     wireUpCombobox(field: any): Promise<any> {
399
400         const fieldOptions = this.fieldOptions[field.name] || {};
401
402         // globally preloading unless a field-specific value is set.
403         if (this.preloadLinkedValues) {
404             if (!('preloadLinkedValues' in fieldOptions)) {
405                 fieldOptions.preloadLinkedValues = true;
406             }
407         }
408
409         const selector = fieldOptions.linkedSearchField ||
410             this.getClassSelector(field.class);
411
412         if (!selector && !fieldOptions.preloadLinkedValues) {
413             // User probably expects an async data source, but we can't
414             // provide one without a selector.  Warn the user.
415             console.warn(`Class ${field.class} has no selector.
416                 Pre-fetching all rows for combobox`);
417         }
418
419         if (fieldOptions.preloadLinkedValues || !selector) {
420             return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
421             .toPromise().then(list => {
422                 field.linkedValues =
423                     this.flattenLinkedValues(field, list);
424             });
425         }
426
427         // If we have a selector, wire up for async data retrieval
428         field.linkedValuesSource =
429             (term: string): Observable<ComboboxEntry> => {
430
431             const search = {};
432             const orderBy = {order_by: {}};
433             const idField = this.idl.classes[field.class].pkey || 'id';
434
435             search[selector] = {'ilike': `%${term}%`};
436             orderBy.order_by[field.class] = selector;
437
438             return this.pcrud.search(field.class, search, orderBy)
439             .pipe(map(idlThing =>
440                 // Map each object into a ComboboxEntry upon arrival
441                 this.flattenLinkedValues(field, [idlThing])[0]
442             ));
443         };
444
445         // Using an async data source, but a value is already set
446         // on the field.  Fetch the linked object and add it to the
447         // combobox entry list so it will be avilable for display
448         // at dialog load time.
449         const linkVal = this.record[field.name]();
450         if (linkVal !== null && linkVal !== undefined) {
451             return this.pcrud.retrieve(field.class, linkVal).toPromise()
452             .then(idlThing => {
453                 field.linkedValues =
454                     this.flattenLinkedValues(field, Array(idlThing));
455             });
456         }
457
458         // No linked value applied, nothing to pre-fetch.
459         return Promise.resolve();
460     }
461
462     // Returns a context object to be inserted into a custom
463     // field template.
464     customTemplateFieldContext(fieldDef: any): CustomFieldContext {
465         return Object.assign(
466             {   record : this.record,
467                 field: fieldDef // from this.fields
468             },  fieldDef.context || {}
469         );
470     }
471
472     save() {
473         const recToSave = this.idl.clone(this.record);
474         this.convertDatatypesToIdl(recToSave);
475         this.pcrud[this.mode]([recToSave]).toPromise().then(
476             result => {
477                 this.onSave$.emit(result);
478                 this.successStr.current().then(msg => this.toast.success(msg));
479                 if (this.isDialog()) { this.close(result); }
480             },
481             error => {
482                 this.onError$.emit(error);
483                 this.failStr.current().then(msg => this.toast.warning(msg));
484                 if (this.isDialog()) { this.error(error); }
485             }
486         );
487     }
488
489     cancel() {
490         this.onCancel$.emit(this.record);
491         this.close();
492     }
493
494     // Returns a string describing the type of input to display
495     // for a given field.  This helps cut down on the if/else
496     // nesti-ness in the template.  Each field will match
497     // exactly one type.
498     inputType(field: any): string {
499
500         if (field.template) {
501             return 'template';
502         }
503
504         // Some widgets handle readOnly for us.
505         if (   field.datatype === 'timestamp'
506             || field.datatype === 'org_unit'
507             || field.datatype === 'bool') {
508             return field.datatype;
509         }
510
511         if (field.readOnly) {
512             if (field.datatype === 'money') {
513                 return 'readonly-money';
514             }
515
516             if (field.datatype === 'link' || field.linkedValues) {
517                 return 'readonly-list';
518             }
519
520             return 'readonly';
521         }
522
523         if (field.datatype === 'id' && !this.pkeyIsEditable) {
524             return 'readonly';
525         }
526
527         if (   field.datatype === 'int'
528             || field.datatype === 'float'
529             || field.datatype === 'money') {
530             return field.datatype;
531         }
532
533         if (field.datatype === 'link' || field.linkedValues) {
534             return 'list';
535         }
536
537         // datatype == text / interval / editable-pkey
538         return 'text';
539     }
540
541     openTranslator(field: string) {
542         this.translator.fieldName = field;
543         this.translator.idlObject = this.record;
544
545         this.translator.open().subscribe(
546             newValue => {
547                 if (newValue) {
548                     this.record[field](newValue);
549                 }
550             }
551         );
552     }
553 }
554
555