LP1809288 Angular fm-editor read-only additions
[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 {AuthService} from '@eg/core/auth.service';
5 import {PcrudService} from '@eg/core/pcrud.service';
6 import {DialogComponent} from '@eg/share/dialog/dialog.component';
7 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
8
9 interface CustomFieldTemplate {
10     template: TemplateRef<any>;
11
12     // Allow the caller to pass in a free-form context blob to
13     // be addedto the caller's custom template context, along
14     // with our stock context.
15     context?: {[fields: string]: any};
16 }
17
18 interface CustomFieldContext {
19     // Current create/edit/view record
20     record: IdlObject;
21
22     // IDL field definition blob
23     field: any;
24
25     // additional context values passed via CustomFieldTemplate
26     [fields: string]: any;
27 }
28
29 @Component({
30   selector: 'eg-fm-record-editor',
31   templateUrl: './fm-editor.component.html'
32 })
33 export class FmRecordEditorComponent
34     extends DialogComponent implements OnInit {
35
36     // IDL class hint (e.g. "aou")
37     @Input() idlClass: string;
38
39     // mode: 'create' for creating a new record,
40     //       'update' for editing an existing record
41     //       'view' for viewing an existing record without editing
42     mode: 'create' | 'update' | 'view' = 'create';
43     recId: any;
44     // IDL record we are editing
45     // TODO: allow this to be update in real time by the caller?
46     record: IdlObject;
47
48     // Permissions extracted from the permacrud defs in the IDL
49     // for the current IDL class
50     modePerms: {[mode: string]: string};
51
52     @Input() customFieldTemplates:
53         {[fieldName: string]: CustomFieldTemplate} = {};
54
55     // list of fields that should not be displayed
56     @Input() hiddenFieldsList: string[] = [];
57     @Input() hiddenFields: string; // comma-separated string version
58
59     // list of fields that should always be read-only
60     @Input() readonlyFieldsList: string[] = [];
61     @Input() readonlyFields: string; // comma-separated string version
62
63     // list of required fields; this supplements what the IDL considers
64     // required
65     @Input() requiredFieldsList: string[] = [];
66     @Input() requiredFields: string; // comma-separated string version
67
68     // list of org_unit fields where a default value may be applied by
69     // the org-select if no value is present.
70     @Input() orgDefaultAllowedList: string[] = [];
71     @Input() orgDefaultAllowed: string; // comma-separated string version
72
73     // hash, keyed by field name, of functions to invoke to check
74     // whether a field is required.  Each callback is passed the field
75     // name and the record and should return a boolean value. This
76     // supports cases where whether a field is required or not depends
77     // on the current value of another field.
78     @Input() isRequiredOverride:
79         {[field: string]: (field: string, record: IdlObject) => boolean};
80
81     // IDL record display label.  Defaults to the IDL label.
82     @Input() recordLabel: string;
83
84     // Emit the modified object when the save action completes.
85     @Output() onSave$ = new EventEmitter<IdlObject>();
86
87     // Emit the original object when the save action is canceled.
88     @Output() onCancel$ = new EventEmitter<IdlObject>();
89
90     // Emit an error message when the save action fails.
91     @Output() onError$ = new EventEmitter<string>();
92
93     // IDL info for the the selected IDL class
94     idlDef: any;
95
96     // Can we edit the primary key?
97     pkeyIsEditable = false;
98
99     // List of IDL field definitions.  This is a subset of the full
100     // list of fields on the IDL, since some are hidden, virtual, etc.
101     fields: any[];
102
103     // DOM id prefix to prevent id collisions.
104     idPrefix: string;
105
106     @Input() editMode(mode: 'create' | 'update' | 'view') {
107         this.mode = mode;
108     }
109
110     // Record ID to view/update.  Value is dynamic.  Records are not
111     // fetched until .open() is called.
112     @Input() set recordId(id: any) {
113         if (id) { this.recId = id; }
114     }
115
116     constructor(
117       private modal: NgbModal, // required for passing to parent
118       private idl: IdlService,
119       private auth: AuthService,
120       private pcrud: PcrudService) {
121       super(modal);
122     }
123
124     // Avoid fetching data on init since that may lead to unnecessary
125     // data retrieval.
126     ngOnInit() {
127         this.listifyInputs();
128         this.idlDef = this.idl.classes[this.idlClass];
129         this.recordLabel = this.idlDef.label;
130
131         // Add some randomness to the generated DOM IDs to ensure against clobbering
132         this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
133     }
134
135     // Opening dialog, fetch data.
136     open(options?: NgbModalOptions): Promise<any> {
137         return this.initRecord().then(
138             ok => super.open(options),
139             err => console.warn(`Error fetching FM data: ${err}`)
140         );
141     }
142
143     // Translate comma-separated string versions of various inputs
144     // to arrays.
145     private listifyInputs() {
146         if (this.hiddenFields) {
147             this.hiddenFieldsList = this.hiddenFields.split(/,/);
148         }
149         if (this.readonlyFields) {
150             this.readonlyFieldsList = this.readonlyFields.split(/,/);
151         }
152         if (this.requiredFields) {
153             this.requiredFieldsList = this.requiredFields.split(/,/);
154         }
155         if (this.orgDefaultAllowed) {
156             this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
157         }
158     }
159
160     private initRecord(): Promise<any> {
161
162         const pc = this.idlDef.permacrud || {};
163         this.modePerms = {
164             view:   pc.retrieve ? pc.retrieve.perms : [],
165             create: pc.create ? pc.create.perms : [],
166             update: pc.update ? pc.update.perms : [],
167         };
168
169         if (this.mode === 'update' || this.mode === 'view') {
170             return this.pcrud.retrieve(this.idlClass, this.recId)
171             .toPromise().then(rec => {
172
173                 if (!rec) {
174                     return Promise.reject(`No '${this.idlClass}'
175                         record found with id ${this.recId}`);
176                 }
177
178                 this.record = rec;
179                 this.convertDatatypesToJs();
180                 return this.getFieldList();
181             });
182         }
183
184         // create a new record from scratch
185         this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
186         this.record = this.idl.create(this.idlClass);
187         return this.getFieldList();
188     }
189
190     // Modifies the FM record in place, replacing IDL-compatible values
191     // with native JS values.
192     private convertDatatypesToJs() {
193         this.idlDef.fields.forEach(field => {
194             if (field.datatype === 'bool') {
195                 if (this.record[field.name]() === 't') {
196                     this.record[field.name](true);
197                 } else if (this.record[field.name]() === 'f') {
198                     this.record[field.name](false);
199                 }
200             }
201         });
202     }
203
204     // Modifies the provided FM record in place, replacing JS values
205     // with IDL-compatible values.
206     convertDatatypesToIdl(rec: IdlObject) {
207         const fields = this.idlDef.fields;
208         fields.forEach(field => {
209             if (field.datatype === 'bool') {
210                 if (rec[field.name]() === true) {
211                     rec[field.name]('t');
212                 // } else if (rec[field.name]() === false) {
213                 } else { // TODO: some bools can be NULL
214                     rec[field.name]('f');
215                 }
216             } else if (field.datatype === 'org_unit') {
217                 const org = rec[field.name]();
218                 if (org && typeof org === 'object') {
219                     rec[field.name](org.id());
220                 }
221             }
222         });
223     }
224
225
226     private flattenLinkedValues(cls: string, list: IdlObject[]): any[] {
227         const idField = this.idl.classes[cls].pkey;
228         const selector =
229             this.idl.classes[cls].field_map[idField].selector || idField;
230
231         return list.map(item => {
232             return {id: item[idField](), name: item[selector]()};
233         });
234     }
235
236     private getFieldList(): Promise<any> {
237
238         this.fields = this.idlDef.fields.filter(f =>
239             !f.virtual && !this.hiddenFieldsList.includes(f.name)
240         );
241
242         const promises = [];
243
244         this.fields.forEach(field => {
245             field.readOnly = this.mode === 'view'
246                 || this.readonlyFieldsList.includes(field.name);
247
248             if (this.isRequiredOverride &&
249                 field.name in this.isRequiredOverride) {
250                 field.isRequired = () => {
251                     return this.isRequiredOverride[field.name](field.name, this.record);
252                 };
253             } else {
254                 field.isRequired = () => {
255                     return field.required ||
256                         this.requiredFieldsList.includes(field.name);
257                 };
258             }
259
260             if (field.datatype === 'link' && field.readOnly) {
261
262                 // no need to fetch all possible values for read-only fields
263                 const idToFetch = this.record[field.name]();
264
265                 if (idToFetch) {
266
267                     // If the linked class defines a selector field, fetch the
268                     // linked data so we can display the data within the selector
269                     // field.  Otherwise, avoid the network lookup and let the
270                     // bare value (usually an ID) be displayed.
271                     const idField = this.idl.classes[field.class].pkey;
272                     const selector =
273                         this.idl.classes[field.class].field_map[idField].selector;
274
275                     if (selector && selector !== field.name) {
276                         promises.push(
277                             this.pcrud.retrieve(field.class, this.record[field.name]())
278                             .toPromise().then(list => {
279                                 field.linkedValues =
280                                     this.flattenLinkedValues(field.class, Array(list));
281                             })
282                         );
283                     } else {
284                         // No selector, display the raw id/key value.
285                         field.linkedValues = [{id: idToFetch, name: idToFetch}];
286                     }
287                 }
288             } else if (field.datatype === 'link') {
289                 promises.push(
290                     this.pcrud.retrieveAll(field.class, {}, {atomic : true})
291                     .toPromise().then(list => {
292                         field.linkedValues =
293                             this.flattenLinkedValues(field.class, list);
294                     })
295                 );
296             } else if (field.datatype === 'org_unit') {
297                 field.orgDefaultAllowed =
298                     this.orgDefaultAllowedList.includes(field.name);
299             }
300
301             if (this.customFieldTemplates[field.name]) {
302                 field.template = this.customFieldTemplates[field.name].template;
303                 field.context = this.customFieldTemplates[field.name].context;
304             }
305
306         });
307
308         // Wait for all network calls to complete
309         return Promise.all(promises);
310     }
311
312     // Returns a context object to be inserted into a custom
313     // field template.
314     customTemplateFieldContext(fieldDef: any): CustomFieldContext {
315         return Object.assign(
316             {   record : this.record,
317                 field: fieldDef // from this.fields
318             },  fieldDef.context || {}
319         );
320     }
321
322     save() {
323         const recToSave = this.idl.clone(this.record);
324         this.convertDatatypesToIdl(recToSave);
325         this.pcrud[this.mode]([recToSave]).toPromise().then(
326             result => this.close(result),
327             error  => this.dismiss(error)
328         );
329     }
330
331     cancel() {
332         this.dismiss('canceled');
333     }
334 }
335
336