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