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