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