LP1825851 Server managed/processed print templates
[working/Evergreen.git] / Open-ILS / src / eg2 / src / app / share / fm-editor / fm-editor.component.ts
index 45dd167..6c079b8 100644 (file)
@@ -1,10 +1,17 @@
-import {Component, OnInit, Input,
+import {Component, OnInit, Input, ViewChild,
     Output, EventEmitter, TemplateRef} from '@angular/core';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
 import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {DialogComponent} from '@eg/share/dialog/dialog.component';
-import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
+
 
 interface CustomFieldTemplate {
     template: TemplateRef<any>;
@@ -15,7 +22,7 @@ interface CustomFieldTemplate {
     context?: {[fields: string]: any};
 }
 
-interface CustomFieldContext {
+export interface CustomFieldContext {
     // Current create/edit/view record
     record: IdlObject;
 
@@ -26,6 +33,43 @@ interface CustomFieldContext {
     [fields: string]: any;
 }
 
+// Collection of extra options that may be applied to fields
+// for controling non-default behaviour.
+export interface FmFieldOptions {
+
+    // Render the field as a combobox using these values, regardless
+    // of the field's datatype.
+    customValues?: {[field: string]: ComboboxEntry[]};
+
+    // Provide / override the "selector" value for the linked class.
+    // This is the field the combobox will search for typeahead.  If no
+    // field is defined, the "selector" field is used.  If no "selector"
+    // field exists, the combobox will pre-load all linked values so
+    // the user can click to navigate.
+    linkedSearchField?: string;
+
+    // When true for combobox fields, pre-fetch the combobox data
+    // so the user can click or type to find values.
+    preloadLinkedValues?: boolean;
+
+    // Directly override the required state of the field.
+    // This only has an affect if the value is true.
+    isRequired?: boolean;
+
+    // If this function is defined, the function will be called
+    // at render time to see if the field should be marked are required.
+    // This supersedes all other isRequired specifiers.
+    isRequiredOverride?: (field: string, record: IdlObject) => boolean;
+
+    // Directly apply the readonly status of the field.
+    // This only has an affect if the value is true.
+    isReadonly?: boolean;
+
+    // Render the field using this custom template instead of chosing
+    // from the default set of form inputs.
+    customTemplate?: CustomFieldTemplate;
+}
+
 @Component({
   selector: 'eg-fm-record-editor',
   templateUrl: './fm-editor.component.html',
@@ -38,21 +82,18 @@ export class FmRecordEditorComponent
     // IDL class hint (e.g. "aou")
     @Input() idlClass: string;
 
-    // mode: 'create' for creating a new record,
-    //       'update' for editing an existing record
-    //       'view' for viewing an existing record without editing
-    mode: 'create' | 'update' | 'view' = 'create';
     recId: any;
+
     // IDL record we are editing
-    // TODO: allow this to be update in real time by the caller?
     record: IdlObject;
 
     // Permissions extracted from the permacrud defs in the IDL
     // for the current IDL class
     modePerms: {[mode: string]: string};
 
-    @Input() customFieldTemplates:
-        {[fieldName: string]: CustomFieldTemplate} = {};
+    // Collection of FmFieldOptions for specifying non-default
+    // behaviour for each field (by field name).
+    @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
 
     // list of fields that should not be displayed
     @Input() hiddenFieldsList: string[] = [];
@@ -72,17 +113,16 @@ export class FmRecordEditorComponent
     @Input() orgDefaultAllowedList: string[] = [];
     @Input() orgDefaultAllowed: string; // comma-separated string version
 
-    // hash, keyed by field name, of functions to invoke to check
-    // whether a field is required.  Each callback is passed the field
-    // name and the record and should return a boolean value. This
-    // supports cases where whether a field is required or not depends
-    // on the current value of another field.
-    @Input() isRequiredOverride:
-        {[field: string]: (field: string, record: IdlObject) => boolean};
-
     // IDL record display label.  Defaults to the IDL label.
     @Input() recordLabel: string;
 
+    // When true at the component level, pre-fetch the combobox data
+    // for all combobox fields.  See also FmFieldOptions.
+    @Input() preloadLinkedValues: boolean;
+
+    // Display within a modal dialog window or inline in the page.
+    @Input() displayMode: 'dialog' | 'inline' = 'dialog';
+
     // Emit the modified object when the save action completes.
     @Output() onSave$ = new EventEmitter<IdlObject>();
 
@@ -92,6 +132,10 @@ export class FmRecordEditorComponent
     // Emit an error message when the save action fails.
     @Output() onError$ = new EventEmitter<string>();
 
+    @ViewChild('translator') private translator: TranslateComponent;
+    @ViewChild('successStr') successStr: StringComponent;
+    @ViewChild('failStr') failStr: StringComponent;
+
     // IDL info for the the selected IDL class
     idlDef: any;
 
@@ -105,9 +149,10 @@ export class FmRecordEditorComponent
     // DOM id prefix to prevent id collisions.
     idPrefix: string;
 
-    @Input() editMode(mode: 'create' | 'update' | 'view') {
-        this.mode = mode;
-    }
+    // mode: 'create' for creating a new record,
+    //       'update' for editing an existing record
+    //       'view' for viewing an existing record without editing
+    @Input() mode: 'create' | 'update' | 'view' = 'create';
 
     // Record ID to view/update.  Value is dynamic.  Records are not
     // fetched until .open() is called.
@@ -119,6 +164,7 @@ export class FmRecordEditorComponent
       private modal: NgbModal, // required for passing to parent
       private idl: IdlService,
       private auth: AuthService,
+      private toast: ToastService,
       private pcrud: PcrudService) {
       super(modal);
     }
@@ -132,14 +178,23 @@ export class FmRecordEditorComponent
 
         // Add some randomness to the generated DOM IDs to ensure against clobbering
         this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
+
+        if (this.isDialog()) {
+            this.onOpen$.subscribe(() => this.initRecord());
+        } else {
+            this.initRecord();
+        }
     }
 
-    // Opening dialog, fetch data.
-    open(options?: NgbModalOptions): Promise<any> {
-        return this.initRecord().then(
-            ok => super.open(options),
-            err => console.warn(`Error fetching FM data: ${err}`)
-        );
+    isDialog(): boolean {
+        return this.displayMode === 'dialog';
+    }
+
+    // Set the record value and clear the recId value to
+    // indicate the record is our current source of data.
+    setRecord(record: IdlObject) {
+        this.record = record;
+        this.recId = null;
     }
 
     // Translate comma-separated string versions of various inputs
@@ -168,9 +223,19 @@ export class FmRecordEditorComponent
             update: pc.update ? pc.update.perms : [],
         };
 
+        this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
+
         if (this.mode === 'update' || this.mode === 'view') {
-            return this.pcrud.retrieve(this.idlClass, this.recId)
-            .toPromise().then(rec => {
+
+            let promise;
+            if (this.record && this.recId === null) {
+                promise = Promise.resolve(this.record);
+            } else {
+                promise =
+                    this.pcrud.retrieve(this.idlClass, this.recId).toPromise();
+            }
+
+            return promise.then(rec => {
 
                 if (!rec) {
                     return Promise.reject(`No '${this.idlClass}'
@@ -183,12 +248,12 @@ export class FmRecordEditorComponent
             });
         }
 
-        // create a new record from scratch or from a stub record
-        // provided by the caller.
-        this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
-        if (!this.record) {
-            this.record = this.idl.create(this.idlClass);
-        }
+        // In 'create' mode.
+        //
+        // Create a new record from the stub record provided by the
+        // caller or a new from-scratch record
+        this.setRecord(this.record || this.idl.create(this.idlClass));
+
         return this.getFieldList();
     }
 
@@ -227,14 +292,27 @@ export class FmRecordEditorComponent
         });
     }
 
+    // Returns the name of the field on a class (typically via a linked
+    // field) that acts as the selector value for display / search.
+    getClassSelector(class_: string): string {
+        if (class_) {
+            const linkedClass = this.idl.classes[class_];
+            return linkedClass.pkey ?
+                linkedClass.field_map[linkedClass.pkey].selector : null;
+        }
+        return null;
+    }
+
+    private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
+        const class_ = field.class;
+        const fieldOptions = this.fieldOptions[field.name] || {};
+        const idField = this.idl.classes[class_].pkey;
 
-    private flattenLinkedValues(cls: string, list: IdlObject[]): any[] {
-        const idField = this.idl.classes[cls].pkey;
-        const selector =
-            this.idl.classes[cls].field_map[idField].selector || idField;
+        const selector = fieldOptions.linkedSearchField
+            || this.getClassSelector(class_) || idField;
 
         return list.map(item => {
-            return {id: item[idField](), name: item[selector]()};
+            return {id: item[idField](), label: item[selector]()};
         });
     }
 
@@ -244,73 +322,141 @@ export class FmRecordEditorComponent
             !f.virtual && !this.hiddenFieldsList.includes(f.name)
         );
 
-        const promises = [];
+        // Wait for all network calls to complete
+        return Promise.all(
+            this.fields.map(field => this.constructOneField(field)));
+    }
 
-        this.fields.forEach(field => {
-            field.readOnly = this.mode === 'view'
-                || this.readonlyFieldsList.includes(field.name);
+    private constructOneField(field: any): Promise<any> {
+
+        let promise = null;
+        const fieldOptions = this.fieldOptions[field.name] || {};
+
+        field.readOnly = this.mode === 'view'
+            || fieldOptions.isReadonly === true
+            || this.readonlyFieldsList.includes(field.name);
+
+        if (fieldOptions.isRequiredOverride) {
+            field.isRequired = () => {
+                return fieldOptions.isRequiredOverride(field.name, this.record);
+            };
+        } else {
+            field.isRequired = () => {
+                return field.required
+                    || fieldOptions.isRequired
+                    || this.requiredFieldsList.includes(field.name);
+            };
+        }
 
-            if (this.isRequiredOverride &&
-                field.name in this.isRequiredOverride) {
-                field.isRequired = () => {
-                    return this.isRequiredOverride[field.name](field.name, this.record);
-                };
-            } else {
-                field.isRequired = () => {
-                    return field.required ||
-                        this.requiredFieldsList.includes(field.name);
-                };
-            }
+        if (fieldOptions.customValues) {
+
+            field.linkedValues = fieldOptions.customValues;
+
+        } else if (field.datatype === 'link' && field.readOnly) {
+
+            // no need to fetch all possible values for read-only fields
+            const idToFetch = this.record[field.name]();
+
+            if (idToFetch) {
+
+                // If the linked class defines a selector field, fetch the
+                // linked data so we can display the data within the selector
+                // field.  Otherwise, avoid the network lookup and let the
+                // bare value (usually an ID) be displayed.
+                const selector = fieldOptions.linkedSearchField ||
+                    this.getClassSelector(field.class);
 
-            if (field.datatype === 'link' && field.readOnly) {
-
-                // no need to fetch all possible values for read-only fields
-                const idToFetch = this.record[field.name]();
-
-                if (idToFetch) {
-
-                    // If the linked class defines a selector field, fetch the
-                    // linked data so we can display the data within the selector
-                    // field.  Otherwise, avoid the network lookup and let the
-                    // bare value (usually an ID) be displayed.
-                    const selector =
-                        this.idl.getLinkSelector(this.idlClass, field.name);
-
-                    if (selector && selector !== field.name) {
-                        promises.push(
-                            this.pcrud.retrieve(field.class, this.record[field.name]())
-                            .toPromise().then(list => {
-                                field.linkedValues =
-                                    this.flattenLinkedValues(field.class, Array(list));
-                            })
-                        );
-                    } else {
-                        // No selector, display the raw id/key value.
-                        field.linkedValues = [{id: idToFetch, name: idToFetch}];
-                    }
+                if (selector && selector !== field.name) {
+                    promise = this.pcrud.retrieve(field.class, idToFetch)
+                        .toPromise().then(list => {
+                            field.linkedValues =
+                                this.flattenLinkedValues(field, Array(list));
+                        });
+                } else {
+                    // No selector, display the raw id/key value.
+                    field.linkedValues = [{id: idToFetch, name: idToFetch}];
                 }
-            } else if (field.datatype === 'link') {
-                promises.push(
-                    this.pcrud.retrieveAll(field.class, {}, {atomic : true})
-                    .toPromise().then(list => {
-                        field.linkedValues =
-                            this.flattenLinkedValues(field.class, list);
-                    })
-                );
-            } else if (field.datatype === 'org_unit') {
-                field.orgDefaultAllowed =
-                    this.orgDefaultAllowedList.includes(field.name);
             }
 
-            if (this.customFieldTemplates[field.name]) {
-                field.template = this.customFieldTemplates[field.name].template;
-                field.context = this.customFieldTemplates[field.name].context;
+        } else if (field.datatype === 'link') {
+
+            promise = this.wireUpCombobox(field);
+
+        } else if (field.datatype === 'org_unit') {
+            field.orgDefaultAllowed =
+                this.orgDefaultAllowedList.includes(field.name);
+        }
+
+        if (fieldOptions.customTemplate) {
+            field.template = fieldOptions.customTemplate.template;
+            field.context = fieldOptions.customTemplate.context;
+        }
+
+        return promise || Promise.resolve();
+    }
+
+    wireUpCombobox(field: any): Promise<any> {
+
+        const fieldOptions = this.fieldOptions[field.name] || {};
+
+        // globally preloading unless a field-specific value is set.
+        if (this.preloadLinkedValues) {
+            if (!('preloadLinkedValues' in fieldOptions)) {
+                fieldOptions.preloadLinkedValues = true;
             }
+        }
 
-        });
+        const selector = fieldOptions.linkedSearchField ||
+            this.getClassSelector(field.class);
 
-        // Wait for all network calls to complete
-        return Promise.all(promises);
+        if (!selector && !fieldOptions.preloadLinkedValues) {
+            // User probably expects an async data source, but we can't
+            // provide one without a selector.  Warn the user.
+            console.warn(`Class ${field.class} has no selector.
+                Pre-fetching all rows for combobox`);
+        }
+
+        if (fieldOptions.preloadLinkedValues || !selector) {
+            return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
+            .toPromise().then(list => {
+                field.linkedValues =
+                    this.flattenLinkedValues(field, list);
+            });
+        }
+
+        // If we have a selector, wire up for async data retrieval
+        field.linkedValuesSource =
+            (term: string): Observable<ComboboxEntry> => {
+
+            const search = {};
+            const orderBy = {order_by: {}};
+            const idField = this.idl.classes[field.class].pkey || 'id';
+
+            search[selector] = {'ilike': `%${term}%`};
+            orderBy.order_by[field.class] = selector;
+
+            return this.pcrud.search(field.class, search, orderBy)
+            .pipe(map(idlThing =>
+                // Map each object into a ComboboxEntry upon arrival
+                this.flattenLinkedValues(field, [idlThing])[0]
+            ));
+        };
+
+        // Using an async data source, but a value is already set
+        // on the field.  Fetch the linked object and add it to the
+        // combobox entry list so it will be avilable for display
+        // at dialog load time.
+        const linkVal = this.record[field.name]();
+        if (linkVal !== null && linkVal !== undefined) {
+            return this.pcrud.retrieve(field.class, linkVal).toPromise()
+            .then(idlThing => {
+                field.linkedValues =
+                    this.flattenLinkedValues(field, Array(idlThing));
+            });
+        }
+
+        // No linked value applied, nothing to pre-fetch.
+        return Promise.resolve();
     }
 
     // Returns a context object to be inserted into a custom
@@ -327,13 +473,82 @@ export class FmRecordEditorComponent
         const recToSave = this.idl.clone(this.record);
         this.convertDatatypesToIdl(recToSave);
         this.pcrud[this.mode]([recToSave]).toPromise().then(
-            result => this.close(result),
-            error  => this.dismiss(error)
+            result => {
+                this.onSave$.emit(result);
+                this.successStr.current().then(msg => this.toast.success(msg));
+                if (this.isDialog()) { this.close(result); }
+            },
+            error => {
+                this.onError$.emit(error);
+                this.failStr.current().then(msg => this.toast.warning(msg));
+                if (this.isDialog()) { this.error(error); }
+            }
         );
     }
 
     cancel() {
-        this.dismiss('canceled');
+        this.onCancel$.emit(this.record);
+        this.close();
+    }
+
+    // Returns a string describing the type of input to display
+    // for a given field.  This helps cut down on the if/else
+    // nesti-ness in the template.  Each field will match
+    // exactly one type.
+    inputType(field: any): string {
+
+        if (field.template) {
+            return 'template';
+        }
+
+        // Some widgets handle readOnly for us.
+        if (   field.datatype === 'timestamp'
+            || field.datatype === 'org_unit'
+            || field.datatype === 'bool') {
+            return field.datatype;
+        }
+
+        if (field.readOnly) {
+            if (field.datatype === 'money') {
+                return 'readonly-money';
+            }
+
+            if (field.datatype === 'link' || field.linkedValues) {
+                return 'readonly-list';
+            }
+
+            return 'readonly';
+        }
+
+        if (field.datatype === 'id' && !this.pkeyIsEditable) {
+            return 'readonly';
+        }
+
+        if (   field.datatype === 'int'
+            || field.datatype === 'float'
+            || field.datatype === 'money') {
+            return field.datatype;
+        }
+
+        if (field.datatype === 'link' || field.linkedValues) {
+            return 'list';
+        }
+
+        // datatype == text / interval / editable-pkey
+        return 'text';
+    }
+
+    openTranslator(field: string) {
+        this.translator.fieldName = field;
+        this.translator.idlObject = this.record;
+
+        this.translator.open().subscribe(
+            newValue => {
+                if (newValue) {
+                    this.record[field](newValue);
+                }
+            }
+        );
     }
 }