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