LP1811288 Angular fm-editor uses combobox
authorBill Erickson <berickxx@gmail.com>
Thu, 10 Jan 2019 23:04:59 +0000 (18:04 -0500)
committerDan Wells <dbw2@calvin.edu>
Mon, 25 Mar 2019 20:58:55 +0000 (16:58 -0400)
* Linked field options traditionally rendered via <select> are now
  rendered with an eg-combobox.
* Caller has option to force a combobox to preload values or rely solely
  on typehead.
* Caller has option to provide a canned list of combobox values.
* Caller has option to set / override which field on the linked class
  should searched by the typeahead
* General improvements to fm-editor form building and field options
  management.
* Includes Angular7 rxjs import repairs

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Signed-off-by: Dan Wells <dbw2@calvin.edu>

Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html

index 0d7fe1e..aad65d1 100644 (file)
         </div>
         <div class="col-lg-7">
 
-          <span *ngIf="field.template">
-            <ng-container
-              *ngTemplateOutlet="field.template; context:customTemplateFieldContext(field)">
+          <ng-container [ngSwitch]="inputType(field)">
+
+            <ng-container *ngSwitchCase="'template'">
+              <ng-container
+                *ngTemplateOutlet="field.template; context:customTemplateFieldContext(field)">
+              </ng-container> 
             </ng-container> 
-          </span>
 
-          <span *ngIf="!field.template">
+            <ng-container *ngSwitchCase="'readonly'">
+              <span>{{record[field.name]()}}</span>
+            </ng-container>
 
-            <span *ngIf="field.datatype == 'id' && !pkeyIsEditable">
-              {{record[field.name]()}}
-            </span>
-  
-            <ng-container *ngIf="field.datatype == 'id' && pkeyIsEditable">
-              <ng-container *ngIf="field.readOnly">
-                <span>{{record[field.name]()}}</span>
-              </ng-container>
-              <ng-container *ngIf="!field.readOnly">
-                <input
-                  class="form-control"
-                  name="{{field.name}}"
-                  id="{{idPrefix}}-{{field.name}}"
-                  placeholder="{{field.label}}..."
-                  i18n-placeholder
-                  [required]="field.isRequired()"
-                  [ngModel]="record[field.name]()"
-                  (ngModelChange)="record[field.name]($event)"/>
-              </ng-container>
+            <ng-container *ngSwitchCase="'readonly-money'">
+              <span>{{record[field.name]() | currency}}</span>
             </ng-container>
-  
-            <ng-container 
-              *ngIf="field.datatype == 'text' || field.datatype == 'interval'">
-              <ng-container *ngIf="field.readOnly">
-                <span>{{record[field.name]()}}</span>
-              </ng-container>
-              <ng-container *ngIf="!field.readOnly">
-                <input
-                  class="form-control"
-                  name="{{field.name}}"
-                  id="{{idPrefix}}-{{field.name}}"
-                  placeholder="{{field.label}}..."
-                  i18n-placeholder
-                  [required]="field.isRequired()"
-                  [ngModel]="record[field.name]()"
-                  (ngModelChange)="record[field.name]($event)"/>
+
+            <ng-container *ngSwitchCase="'readonly-list'">
+              <ng-container *ngIf="field.linkedValues">
+                <span>{{field.linkedValues[0].label}}</span>
               </ng-container>
             </ng-container>
 
-            <!-- TODO: add support to eg-date-select for read-only view -->
-            <span *ngIf="field.datatype == 'timestamp'">
+            <ng-container *ngSwitchCase="'timestamp'">
               <eg-date-select
                 domId="{{idPrefix}}-{{field.name}}"
                 (onChangeAsIso)="record[field.name]($event)"
                 initialIso="{{record[field.name]()}}">
               </eg-date-select>
-            </span>
+            </ng-container>
 
-            <ng-container *ngIf="field.datatype == 'int'">
-              <ng-container *ngIf="field.readOnly">
-                <span>{{record[field.name]()}}</span>
-              </ng-container>
-              <ng-container *ngIf="!field.readOnly">
+            <ng-container *ngSwitchCase="'org_unit'">
+              <eg-org-select
+                placeholder="{{field.label}}..."
+                i18n-placeholder
+                domId="{{idPrefix}}-{{field.name}}"
+                [limitPerms]="modePerms[mode]"
+                [readOnly]="field.readOnly"
+                [applyDefault]="field.orgDefaultAllowed"
+                [initialOrgId]="record[field.name]()"
+                (onChange)="record[field.name]($event)">
+              </eg-org-select>
+            </ng-container>
+          
+            <ng-container *ngSwitchCase="'money'">
+              <input
+                class="form-control"
+                type="number" step="0.1"
+                name="{{field.name}}"
+                id="{{idPrefix}}-{{field.name}}"
+                placeholder="{{field.label}}..."
+                i18n-placeholder
+                [readonly]="field.readOnly"
+                [required]="field.isRequired()"
+                [ngModel]="record[field.name]()"
+                (ngModelChange)="record[field.name]($event)"/>
+            </ng-container>
 
+            <ng-container *ngSwitchCase="'int'">
               <input
                 class="form-control"
                 type="number"
                 [required]="field.isRequired()"
                 [ngModel]="record[field.name]()"
                 (ngModelChange)="record[field.name]($event)"/>
-              </ng-container>
             </ng-container>
 
-            <ng-container *ngIf="field.datatype == 'float'">
-              <ng-container *ngIf="field.readOnly">
-                <span>{{record[field.name]()}}</span>
-              </ng-container>
-              <ng-container *ngIf="!field.readOnly">
-                <input
-                  class="form-control"
-                  type="number" step="0.1"
-                  name="{{field.name}}"
-                  id="{{idPrefix}}-{{field.name}}"
-                  placeholder="{{field.label}}..."
-                  i18n-placeholder
-                  [required]="field.isRequired()"
-                  [ngModel]="record[field.name]()"
-                  (ngModelChange)="record[field.name]($event)"/>
-              </ng-container>
+            <ng-container *ngSwitchCase="'float'">
+              <input
+                class="form-control"
+                type="number" step="0.1"
+                name="{{field.name}}"
+                id="{{idPrefix}}-{{field.name}}"
+                placeholder="{{field.label}}..."
+                i18n-placeholder
+                [required]="field.isRequired()"
+                [ngModel]="record[field.name]()"
+                (ngModelChange)="record[field.name]($event)"/>
             </ng-container>
-      
-            <ng-container *ngIf="field.datatype == 'money'">
-              <ng-container *ngIf="field.readOnly">
-                <span>{{record[field.name]() | currency}}</span>
-              </ng-container>
-              <ng-container *ngIf="!field.readOnly">
-                <input
-                  class="form-control"
-                  type="number" step="0.1"
-                  name="{{field.name}}"
-                  id="{{idPrefix}}-{{field.name}}"
-                  placeholder="{{field.label}}..."
-                  i18n-placeholder
-                  [readonly]="field.readOnly"
-                  [required]="field.isRequired()"
-                  [ngModel]="record[field.name]()"
-                  (ngModelChange)="record[field.name]($event)"/>
-              </ng-container>
+
+            <ng-container *ngSwitchCase="'text'">
+              <input
+                class="form-control"
+                id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
+                type="text"
+                placeholder="{{field.label}}..." i18n-placeholder
+                [required]="field.isRequired()"
+                [ngModel]="record[field.name]()"
+                (ngModelChange)="record[field.name]($event)"/>
             </ng-container>
-  
-            <input *ngIf="field.datatype == 'bool'"
-              class="form-check-input"
-              type="checkbox"
-              name="{{field.name}}"
-              id="{{idPrefix}}-{{field.name}}"
-              [disabled]="field.readOnly"
-              [ngModel]="record[field.name]()"
-              (ngModelChange)="record[field.name]($event)"/>
-  
-            <ng-container *ngIf="field.datatype == 'link'">
-              <ng-container *ngIf="field.readOnly">
-                <!-- in readOnly mode, if a value is present, it will
-                    live as the only item in the linkedValues array -->
-                <ng-container *ngIf="(field.linkedValues != null) && (field.linkedValues.length)">
-                  <span>{{field.linkedValues[0].name}}</span>
-                </ng-container>
-              </ng-container>
-              <ng-container *ngIf="!field.readOnly">
-                <span [ngClass]="{nullable : !field.isRequired()}">
-                  <select
-                    class="form-control"
-                    name="{{field.name}}"
-                    id="{{idPrefix}}-{{field.name}}"
-                    [required]="field.isRequired()"
-                    [ngModel]="record[field.name]()"
-                    (ngModelChange)="record[field.name]($event)">
-                    <option *ngFor="let item of field.linkedValues" 
-                      [value]="item.id">{{item.name}}</option>
-                  </select>
-                </span>
-              </ng-container>
+
+            <ng-container *ngSwitchCase="'bool'">
+              <input
+                class="form-check-input"
+                type="checkbox"
+                name="{{field.name}}"
+                id="{{idPrefix}}-{{field.name}}"
+                [disabled]="field.readOnly"
+                [ngModel]="record[field.name]()"
+                (ngModelChange)="record[field.name]($event)"/>
             </ng-container>
   
-            <eg-org-select *ngIf="field.datatype == 'org_unit'"
-              placeholder="{{field.label}}..."
-              i18n-placeholder
-              domId="{{idPrefix}}-{{field.name}}"
-              [limitPerms]="modePerms[mode]"
-              [readOnly]="field.readOnly"
-              [applyDefault]="field.orgDefaultAllowed"
-              [initialOrgId]="record[field.name]()"
-              (onChange)="record[field.name]($event)">
-            </eg-org-select>
-
-          </span>
+            <ng-container *ngSwitchCase="'list'">
+              <eg-combobox
+                id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
+                placeholder="{{field.label}}..." i18n-placeholder 
+                [required]="field.isRequired()"
+                [entries]="field.linkedValues"
+                [asyncDataSource]="field.linkedValuesSource"
+                [startId]="record[field.name]()"
+                (onChange)="record[field.name]($event ? $event.id : null)">
+              </eg-combobox>
+            </ng-container>
+          </ng-container> <!-- switch -->
         </div>
       </div>
     </form>
index 45dd167..ec8d0b1 100644 (file)
@@ -1,10 +1,13 @@
 import {Component, OnInit, Input,
     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 {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 
 interface CustomFieldTemplate {
     template: TemplateRef<any>;
@@ -26,6 +29,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',
@@ -43,6 +83,7 @@ export class FmRecordEditorComponent
     //       '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;
@@ -51,8 +92,9 @@ export class FmRecordEditorComponent
     // 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,14 +114,6 @@ 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;
 
@@ -227,14 +261,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(cls: string, list: IdlObject[]): any[] {
-        const idField = this.idl.classes[cls].pkey;
-        const selector =
-            this.idl.classes[cls].field_map[idField].selector || idField;
+    private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
+        const class_ = field.class;
+        const fieldOptions = this.fieldOptions[field.name] || {};
+        const idField = this.idl.classes[class_].pkey;
+
+        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 +291,134 @@ export class FmRecordEditorComponent
             !f.virtual && !this.hiddenFieldsList.includes(f.name)
         );
 
-        const promises = [];
-
-        this.fields.forEach(field => {
-            field.readOnly = this.mode === 'view'
-                || this.readonlyFieldsList.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);
-                };
-            }
+        // Wait for all network calls to complete
+        return Promise.all(
+            this.fields.map(field => this.constructOneField(field)));
+    }
+
+    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 (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 (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 (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 (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);
 
-        // Wait for all network calls to complete
-        return Promise.all(promises);
+        } 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] || {};
+
+        const selector = fieldOptions.linkedSearchField ||
+            this.getClassSelector(field.class);
+
+        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
@@ -335,6 +443,53 @@ export class FmRecordEditorComponent
     cancel() {
         this.dismiss('canceled');
     }
+
+    // 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';
+    }
 }
 
 
index 5f1f1ad..f112aed 100644 (file)
       (ngModelChange)="record[field.name]($event)">
     </textarea>
   </ng-template>
+  <!-- note: fieldOptions would be best defined in the .ts file, but
+      want to demostrate it can be set in the template as well -->
   <eg-fm-record-editor #fmRecordEditor 
       idlClass="cmrcfld" mode="create" 
-      [customFieldTemplates]="{description:{template:descriptionTemplate,context:{'hello':'goodbye'}}}"
+      [fieldOptions]="{marc_record_type:{customValues:[{id:'biblio'},{id:'serial'},{id:'authority'}]},description:{customTemplate:{template:descriptionTemplate,context:{'hello':'goodbye'}}}}"
       recordId="1" orgDefaultAllowed="owner">
   </eg-fm-record-editor>
   <button class="btn btn-dark" (click)="fmRecordEditor.open({size:'lg'})">