LP#1775466 combobox gets dynamic data
authorBill Erickson <berickxx@gmail.com>
Tue, 3 Jul 2018 16:12:14 +0000 (12:12 -0400)
committerBill Erickson <berickxx@gmail.com>
Wed, 5 Sep 2018 14:05:23 +0000 (10:05 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts

index 470ef0d..47fedf9 100644 (file)
@@ -6,6 +6,10 @@
 import {Component, OnInit, Input, Output, ViewChild, EventEmitter, ElementRef} from '@angular/core';
 import {Observable} from 'rxjs/Observable';
 import {map} from 'rxjs/operators/map';
+import {tap} from 'rxjs/operators/tap';
+import {reduce} from 'rxjs/operators/reduce';
+import 'rxjs/add/observable/of';
+import {mergeMap} from 'rxjs/operators/mergeMap';
 import {mapTo} from 'rxjs/operators/mapTo';
 import {debounceTime} from 'rxjs/operators/debounceTime';
 import {distinctUntilChanged} from 'rxjs/operators/distinctUntilChanged';
@@ -43,16 +47,17 @@ export class ComboboxComponent implements OnInit {
 
     @Input() persistKey: string; // TODO
 
-    // Display all entries when the user clicks in the text filter
-    // box regardless of any text that already exists there.
-    @Input() clickShowsAll = true;
-
     @Input() allowFreeText = false;
 
     // Entry ID of the default entry to select (optional)
     // onChange() is NOT fired when applying the default value
     @Input() startId: any;
 
+    @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
+
+    // Useful for efficiently preventing duplicate async entries
+    asyncIds: {[idx: string]: boolean};
+
     // True if a default selection has been made.
     defaultSelectionApplied: boolean;
 
@@ -73,6 +78,7 @@ export class ComboboxComponent implements OnInit {
       private store: StoreService,
     ) {
         this.entrylist = [];
+        this.asyncIds = {};
         this.click$ = new Subject<string>();
         this.onChange = new EventEmitter<ComboboxEntry>();
         this.freeTextId = -1;
@@ -144,29 +150,58 @@ export class ComboboxComponent implements OnInit {
         this.onChange.emit(selEvent.item);
     }
 
+    // Adds matching async entries to the entry list
+    // and propagates the search term for pipelining.
+    addAsyncEntries(term: string): Observable<string> {
+
+        if (!term || !this.asyncDataSource) {
+            return Observable.of(term);
+        }
+
+        return new Observable(observer => {
+            this.asyncDataSource(term).subscribe(
+                (entry: ComboboxEntry) => { 
+                    if (!this.asyncIds[''+entry.id]) {
+                        this.asyncIds[''+entry.id] = true;
+                        this.addEntry(entry);
+                    }
+                },
+                err => {},
+                ()  => {
+                    observer.next(term);
+                    observer.complete();
+                }
+            )
+        });
+    }
+
     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
         return text$.pipe(
             debounceTime(200),
             distinctUntilChanged(),
 
+            // Merge click actions in with the stream of text entry
             merge(
                 // Inject a specifier indicating the source of the 
                 // action is a user click instead of a text entry.
-                this.click$
-                .pipe(filter(() => !this.instance.isPopupOpen()))
-                .pipe(map(nothing => {
-                    if (this.clickShowsAll) {
-                        return '_CLICK_';
-                    } else {
-                        return nothing;
-                    }
-                }))
+                // This tells the filter to show all values in sync mode.
+                this.click$.pipe(filter(() =>
+                    !this.instance.isPopupOpen() && !this.asyncDataSource
+                )).pipe(mapTo('_CLICK_'))
             ),
 
-            map(term => {
+            // mergeMap coalesces an observable into our stream.
+            mergeMap(term => this.addAsyncEntries(term)),
+            map((term: string) => {
+
                 if (term === '' || term === '_CLICK_') {
-                    // Click events display all visible entrylist
-                    return this.entrylist;
+                    if (this.asyncDataSource) {
+                        return [];
+                    } else {
+                        // In sync mode, a post-focus empty search or
+                        // click event displays the whole list.
+                        return this.entrylist;
+                    }
                 }
 
                 // Filter entrylist whose labels substring-match the
index 0c0a665..dbcd306 100644 (file)
     </eg-progress-dialog>
     <button class="btn btn-light" (click)="showProgress()">Test Progress Dialog</button>
   </div>
-  <div class="col-lg-4">
-   <button class="btn btn-info" (click)="testToast()">Test Toast Message</button>
+  <div class="col-lg-3">
+    <eg-combobox [allowFreeText]="true" 
+      placeholder="Combobox with static data"
+      [entries]="cbEntries"></eg-combobox>
   </div>
-  <div class="col-lg-4">
+  <div class="col-lg-3">
     <eg-combobox [allowFreeText]="true" 
-      [entries]="taEntries" placeholder="Combobox..."></eg-combobox>
+      placeholder="Combobox with dynamic data"
+      [asyncDataSource]="cbAsyncSource"></eg-combobox>
+  </div>
+</div>
+<div class="row mb-3">
+  <div class="col-lg-4">
+   <button class="btn btn-info" (click)="testToast()">Test Toast Message</button>
   </div>
 </div>
 <!-- /Progress Dialog Experiments ----------------------------- -->
index 9151b1f..c415f8f 100644 (file)
@@ -4,6 +4,7 @@ import {ToastService} from '@eg/share/toast/toast.service';
 import {StringService} from '@eg/share/string/string.service';
 import {Observable} from 'rxjs/Observable';
 import 'rxjs/add/observable/timer';
+import 'rxjs/add/observable/of';
 import {map} from 'rxjs/operators/map';
 import {take} from 'rxjs/operators/take';
 import {GridDataSource, GridColumn, GridRowFlairEntry} from '@eg/share/grid/grid';
@@ -33,7 +34,9 @@ export class SandboxComponent implements OnInit {
 
     gridDataSource: GridDataSource = new GridDataSource();
 
-    taEntries: ComboboxEntry[];
+    cbEntries: ComboboxEntry[];
+    // supplier of async combobox data
+    cbAsyncSource: (term: string) => Observable<ComboboxEntry>;
 
     btSource: GridDataSource = new GridDataSource();
     world = 'world'; // for local template version
@@ -70,10 +73,20 @@ export class SandboxComponent implements OnInit {
 
         this.pcrud.retrieveAll('cmrcfld', {order_by:{cmrcfld: 'name'}})
         .subscribe(format => {
-            if (!this.taEntries) { this.taEntries = []; }
-            this.taEntries.push({id: format.id(), label: format.name()})
+            if (!this.cbEntries) { this.cbEntries = []; }
+            this.cbEntries.push({id: format.id(), label: format.name()})
         });
 
+        this.cbAsyncSource = term => {
+            return this.pcrud.search(
+                'cmrcfld', 
+                {name: {'ilike': `%${term}%`}}, // could -or search on label
+                {order_by: {cmrcfld: 'name'}}
+            ).pipe(map(marcField => {
+                return {id: marcField.id(), label: marcField.name()};
+            }));
+        }
+
         this.btSource.getRows = (pager: Pager, sort: any[]) => {
 
             const orderBy: any = {cbt: 'name'};