LP1859241 Angular holds patron search dialog
authorBill Erickson <berickxx@gmail.com>
Thu, 9 Jan 2020 22:20:42 +0000 (17:20 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 21 Feb 2020 16:44:38 +0000 (11:44 -0500)
Implements a patron search dialog which may be instantiated directly
from the staff catalog holds placement interface.

Includes:

1. New patron module (which absorbs the existing PatronService)
2. New patron search component
3. Patron search component dialog wrapper.
4. Patron profile selector component which understands custom group
   display trees.
4. Fixes an issue with the grid where the 'datatype' was not always
   propagated to IDL fields.
5. Modifies the combobox to allow the caller to clear the value by
   passing a null value for the selectedId.

To Test:

[1] Navigate to the Angular staff catalog
[2] Perform a bib search
[3] Click 'Place Hold' next to a title.
[4] Click the 'Patron Search' button.
[5] Search for patrons and either double-click a search result row or
    single click then chose the 'Select' button.
[6] Confirm the selected patron is now chosen for holds placement.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Ruth Frasur <rfrasur@gmail.com>

18 files changed:
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts
Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts
Open-ILS/src/eg2/src/app/staff/booking/return.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/patron.service.ts [moved from Open-ILS/src/eg2/src/app/staff/share/patron.service.ts with 100% similarity]
Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/styles.css

index 0283957..41d2b30 100644 (file)
@@ -75,16 +75,19 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
     // Allow the selected entry ID to be passed via the template
     // This does NOT not emit onChange events.
     @Input() set selectedId(id: any) {
-        if (id) {
-            if (this.entrylist.length) {
-                this.selected = this.entrylist.filter(e => e.id === id)[0];
-            }
+        if (id === undefined) { return; }
 
-            if (!this.selected) {
-                // It's possible the selected ID lives in a set of entries
-                // that are yet to be provided.
-                this.startId = id;
-            }
+        // clear on explicit null
+        if (id === null) { this.selected = null; }
+
+        if (this.entrylist.length) {
+            this.selected = this.entrylist.filter(e => e.id === id)[0];
+        }
+
+        if (!this.selected) {
+            // It's possible the selected ID lives in a set of entries
+            // that are yet to be provided.
+            this.startId = id;
         }
     }
 
index e4f6715..e885fb7 100644 (file)
@@ -230,9 +230,11 @@ export class GridColumnSet {
             if (idlInfo) {
                 col.idlFieldDef = idlInfo.idlField;
                 col.idlClass = idlInfo.idlClass.name;
+                if (!col.datatype) {
+                    col.datatype = col.idlFieldDef.datatype;
+                }
                 if (!col.label) {
                     col.label = col.idlFieldDef.label || col.idlFieldDef.name;
-                    col.datatype = col.idlFieldDef.datatype;
                 }
             }
         }
index 9b14137..dbcfb03 100644 (file)
@@ -11,7 +11,7 @@ import {PickupComponent} from './pickup.component';
 import {PullListComponent} from './pull-list.component';
 import {ReturnComponent} from './return.component';
 import {NoTimezoneSetComponent} from './no-timezone-set.component';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
 import {BookingResourceBarcodeValidatorDirective} from './booking_resource_validator.directive';
 import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
 import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
@@ -23,9 +23,9 @@ import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-sele
         BookingRoutingModule,
         ReactiveFormsModule,
         FmRecordEditorModule,
-        OrgFamilySelectModule
+        OrgFamilySelectModule,
+        PatronModule
     ],
-    providers: [PatronService],
     declarations: [
         CancelReservationDialogComponent,
         CreateReservationComponent,
index 028f7cf..076c413 100644 (file)
@@ -2,7 +2,7 @@ import {Component, OnInit, ViewChild, OnDestroy} from '@angular/core';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
 import {Subscription, of} from 'rxjs';
 import {single, filter, switchMap, debounceTime, tap} from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {ReservationsGridComponent} from './reservations-grid.component';
index d7a42f4..f37e10e 100644 (file)
@@ -4,7 +4,7 @@ import {FormGroup, FormControl, Validators} from '@angular/forms';
 import {NgbTabChangeEvent, NgbTabset} from '@ng-bootstrap/ng-bootstrap';
 import {Observable, from, of, Subscription} from 'rxjs';
 import { single, switchMap, tap, debounceTime } from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {ReservationsGridComponent} from './reservations-grid.component';
index 3ad00a9..9b7d57a 100644 (file)
@@ -5,6 +5,7 @@ import {CatalogRoutingModule} from './routing.module';
 import {HoldsModule} from '@eg/staff/share/holds/holds.module';
 import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
 import {BookingModule} from '@eg/staff/share/booking/booking.module';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
 import {CatalogComponent} from './catalog.component';
 import {SearchFormComponent} from './search-form.component';
 import {ResultsComponent} from './result/results.component';
@@ -64,6 +65,7 @@ import {PreferencesComponent} from './prefs.component';
     HoldsModule,
     HoldingsModule,
     BookingModule,
+    PatronModule,
     MarcEditModule
   ],
   providers: [
index fa04d86..dca200d 100644 (file)
@@ -1,3 +1,7 @@
+
+<eg-patron-search-dialog #patronSearch>
+</eg-patron-search-dialog>
+
 <div class="row">
   <div class="col-lg-4">
     <h3 i18n>Place Hold 
@@ -7,7 +11,7 @@
     </h3>
   </div>
   <div class="col-lg-2 text-right">
-    <button class="btn btn-outline-dark btn-sm" [disabled]="true">
+    <button class="btn btn-outline-dark btn-sm" (click)="searchPatrons()">
       <span class="material-icons mat-icon-in-button align-middle" 
         i18n-title title="Search for Patron">search</span>
       <span class="align-middle" i18n>Search for Patron</span>
index 539c434..c1640b0 100644 (file)
@@ -13,6 +13,8 @@ import {StaffCatalogService} from '../catalog.service';
 import {HoldsService, HoldRequest,
     HoldRequestTarget} from '@eg/staff/share/holds/holds.service';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {PatronSearchDialogComponent
+  } from '@eg/staff/share/patron/search-dialog.component';
 
 class HoldContext {
     holdMeta: HoldRequestTarget;
@@ -63,6 +65,9 @@ export class HoldComponent implements OnInit {
     smsEnabled: boolean;
     placeHoldsClicked: boolean;
 
+    @ViewChild('patronSearch', {static: false})
+      patronSearch: PatronSearchDialogComponent;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
@@ -398,6 +403,22 @@ export class HoldComponent implements OnInit {
             )
         );
     }
+
+    searchPatrons() {
+        this.patronSearch.open({size: 'xl'}).toPromise().then(
+            patrons => {
+                if (!patrons || patrons.length === 0) { return; }
+
+                const user = patrons[0];
+
+                this.user = user;
+                this.userBarcode =
+                    this.currentUserBarcode = user.card().barcode();
+                user.home_ou(this.org.get(user.home_ou()).id()); // de-flesh
+                this.applyUserSettings();
+            }
+        );
+    }
 }
 
 
index 869eff2..50ed791 100644 (file)
@@ -71,9 +71,11 @@ export class ResultsComponent implements OnInit, OnDestroy {
     }
 
     ngOnDestroy() {
-        this.routeSub.unsubscribe();
-        this.searchSub.unsubscribe();
-        this.basketSub.unsubscribe();
+        if (this.routeSub) {
+            this.routeSub.unsubscribe();
+            this.searchSub.unsubscribe();
+            this.basketSub.unsubscribe();
+        }
     }
 
     // Apply the select-all checkbox when all visible records
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts
new file mode 100644 (file)
index 0000000..ac6e9b3
--- /dev/null
@@ -0,0 +1,30 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {GridModule} from '@eg/share/grid/grid.module';
+import {PatronService} from './patron.service';
+import {PatronSearchComponent} from './search.component';
+import {PatronSearchDialogComponent} from './search-dialog.component';
+import {ProfileSelectComponent} from './profile-select.component';
+
+@NgModule({
+    declarations: [
+        PatronSearchComponent,
+        PatronSearchDialogComponent,
+        ProfileSelectComponent
+    ],
+    imports: [
+        StaffCommonModule,
+        GridModule
+    ],
+    exports: [
+        PatronSearchComponent,
+        PatronSearchDialogComponent,
+        ProfileSelectComponent
+    ],
+    providers: [
+        PatronService
+    ]
+})
+
+export class PatronModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html
new file mode 100644 (file)
index 0000000..d5a3666
--- /dev/null
@@ -0,0 +1,6 @@
+
+<eg-combobox #combobox 
+  [startId]="initialValue" [entries]="cboxEntries"
+  (onChange)="propagateCboxChange($event)"
+  i18n-placeholder placeholder="Profile Group">
+</eg-combobox>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts
new file mode 100644 (file)
index 0000000..56ff560
--- /dev/null
@@ -0,0 +1,178 @@
+import {Component, Input, Output, OnInit,
+    EventEmitter, ViewChild, forwardRef} from '@angular/core';
+import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
+import {Observable, of} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxEntry, ComboboxComponent
+    } from '@eg/share/combobox/combobox.component';
+
+/* User permission group select comoboxbox.
+ *
+ * <eg-profile-select
+ *  [(ngModel)]="pgtObject" [useDisplayEntries]="true">
+ * </eg-profile-select>
+ */
+
+// Use a unicode char for spacing instead of ASCII=32 so the browser
+// won't collapse the nested display entries down to a single space.
+const PAD_SPACE = ' '; // U+2007
+
+@Component({
+  selector: 'eg-profile-select',
+  templateUrl: './profile-select.component.html',
+  providers: [{
+    provide: NG_VALUE_ACCESSOR,
+    useExisting: forwardRef(() => ProfileSelectComponent),
+    multi: true
+  }]
+})
+export class ProfileSelectComponent implements ControlValueAccessor, OnInit {
+
+    // If true, attempt to build the selector from
+    // permission.grp_tree_display_entry's for the current org unit.
+    // If false OR if no permission.grp_tree_display_entry's exist
+    // build the selector from the full permission.grp_tree
+    @Input() useDisplayEntries: boolean;
+
+    // Emits the selected 'pgt' object or null if the selector is cleared.
+    @Output() profileChange: EventEmitter<IdlObject>;
+
+    @ViewChild('combobox', {static: false}) cbox: ComboboxComponent;
+
+    initialValue: number;
+    cboxEntries: ComboboxEntry[] = [];
+    profiles: {[id: number]: IdlObject} = {};
+
+    // Stub functions required by ControlValueAccessor
+    propagateChange = (_: any) => {};
+    propagateTouch = () => {};
+
+    constructor(
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService) {
+        this.profileChange = new EventEmitter<IdlObject>();
+    }
+
+    ngOnInit() {
+        this.collectGroups().then(grps => this.sortGroups(grps));
+    }
+
+    collectGroups(): Promise<IdlObject[]> {
+
+        if (!this.useDisplayEntries) {
+            return this.fetchPgt();
+        }
+
+        return this.pcrud.search('pgtde',
+            {org: this.org.ancestors(this.auth.user().ws_ou(), true)},
+            {flesh: 1, flesh_fields: {'pgtde': ['grp']}},
+            {atomic: true}
+
+        ).toPromise().then(groups => {
+
+            if (groups.length === 0) { return this.fetchPgt(); }
+
+            // In the query above, we fetch display entries for our org
+            // unit plus ancestors.  However, we only want to use one
+            // collection of display entries, those owned at our org
+            // unit or our closest ancestor.
+            let closestOrg = this.org.get(groups[0].org());
+            groups.forEach(g => {
+                const org = this.org.get(g.org());
+                if (closestOrg.ou_type().depth() < org.ou_type().depth()) {
+                    closestOrg = org;
+                }
+            });
+            groups = groups.filter(g => g.org() === closestOrg.id());
+
+            // Link the display entry to its pgt.
+            const pgtList = [];
+            groups.forEach(display => {
+                const pgt = display.grp();
+                pgt._display = display;
+                pgtList.push(pgt);
+            });
+
+            return pgtList;
+        });
+    }
+
+    fetchPgt(): Promise<IdlObject[]> {
+        return this.pcrud.retrieveAll('pgt', {}, {atomic: true}).toPromise();
+    }
+
+    grpLabel(groups: IdlObject[], grp: IdlObject): string {
+        let tmp = grp;
+        let depth = 0;
+
+        do {
+            const pid = tmp._display ? tmp._display.parent() : tmp.parent();
+            if (!pid) { break; } // top of the tree
+
+            // Should always produce a value unless a perm group
+            // display tree is poorly structured.
+            tmp = groups.filter(g => g.id() === pid)[0];
+
+            depth++;
+
+        } while (tmp);
+
+        return PAD_SPACE.repeat(depth) + grp.name();
+    }
+
+    sortGroups(groups: IdlObject[], grp?: IdlObject) {
+        if (!grp) {
+            grp = groups.filter(g => g.parent() === null)[0];
+        }
+
+        this.profiles[grp.id()] = grp;
+        this.cboxEntries.push(
+            {id: grp.id(), label: this.grpLabel(groups, grp)});
+
+        groups
+            .filter(g => g.parent() === grp.id())
+            .sort((a, b) => {
+                if (a._display) {
+                    return a._display.position() < b._display.position() ? -1 : 1;
+                } else {
+                    return a.name() < b.name() ? -1 : 1;
+                }
+            })
+            .forEach(child => this.sortGroups(groups, child));
+    }
+
+    writeValue(pgt: IdlObject) {
+        const id = pgt ? pgt.id() : null;
+        if (this.cbox) {
+            this.cbox.selectedId = id;
+        } else {
+            // Will propagate to cbox after its instantiated.
+            this.initialValue = id;
+        }
+    }
+
+    registerOnChange(fn) {
+        this.propagateChange = fn;
+    }
+
+    registerOnTouched(fn) {
+        this.propagateTouch = fn;
+    }
+
+    propagateCboxChange(entry: ComboboxEntry) {
+        if (entry) {
+            const grp = this.profiles[entry.id];
+            this.propagateChange(grp);
+            this.profileChange.emit(grp);
+        } else {
+            this.profileChange.emit(null);
+            this.propagateChange(null);
+        }
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html
new file mode 100644 (file)
index 0000000..1006a63
--- /dev/null
@@ -0,0 +1,23 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title"><span i18n>Patron Search</span></h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <eg-patron-search #searchForm (patronsSelected)="patronsSelected($event)">
+    </eg-patron-search>
+  </div>
+  <div class="modal-footer">
+    <ng-container>
+      <button type="button" class="btn btn-warning" 
+        (click)="close()" i18n>Cancel</button>
+      <button type="button" class="btn btn-success" 
+        [disabled]="searchForm ? searchForm.getSelected().length === 0 : true"
+        (click)="close(searchForm.getSelected())" i18n>Select</button>
+    </ng-container>
+  </div>
+</ng-template>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts
new file mode 100644 (file)
index 0000000..98e1c22
--- /dev/null
@@ -0,0 +1,36 @@
+import {Component, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {PatronSearchComponent} from './search.component';
+
+/**
+ * Dialog container for patron search component
+ *
+ * <eg-patron-search-dialog (patronsSelected)="process($event)">
+ * </eg-patron-search-dialog>
+ */
+
+@Component({
+  selector: 'eg-patron-search-dialog',
+  templateUrl: 'search-dialog.component.html'
+})
+
+export class PatronSearchDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @ViewChild('searchForm', {static: false})
+        searchForm: PatronSearchComponent;
+
+    constructor(private modal: NgbModal) { super(modal); }
+
+    ngOnInit() {}
+
+    // Fired when a row in the search grid is dbl-clicked / activated
+    patronsSelected(patrons: IdlObject[]) {
+        this.close(patrons);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html
new file mode 100644 (file)
index 0000000..f2e3632
--- /dev/null
@@ -0,0 +1,233 @@
+
+<div class="patron-search-form">
+  <div class="row mb-2">
+    <div class="col-lg-2">
+      <input class="form-control" type="text" id='focus-this-input'
+        i18n-aria-label aria-label="Last Name" (keyup.enter)="go()"
+        i18n-placeholder placeholder="Last Name"
+        [(ngModel)]="search.family_name"/>
+    </div>
+    <div class="col-lg-2">
+      <input class="form-control" type="text" (keyup.enter)="go()"
+        i18n-aria-label aria-label="First Name"
+        i18n-placeholder placeholder="First Name"
+        [(ngModel)]="search.first_given_name"/>
+    </div>
+    <div class="col-lg-2">
+      <input class="form-control" type="text" (keyup.enter)="go()"
+        i18n-aria-label aria-label="Middle Name"
+        i18n-placeholder placeholder="Middle Name"
+        [(ngModel)]="search.second_given_name"/>
+    </div>
+    <div class="col-lg-2">
+      <input class="form-control" type="text" (keyup.enter)="go()"
+        i18n-aria-label aria-label="Name Keywords"
+        i18n-placeholder placeholder="Name Keywords"
+        [(ngModel)]="search.name"/>
+    </div>
+    <div class="col-lg-2">
+      <button class="btn btn-success" (click)="go()" i18n>Search</button>
+      <button (click)="toggleExpandForm()"
+        class="btn btn-outline-dark ml-2 label-with-material-icon"
+        i18n-title title="Toggle Expanded Form Display">
+        <span *ngIf="!expandForm" class="material-icons">arrow_drop_down</span>
+        <span *ngIf="expandForm"  class="material-icons">arrow_drop_up</span>
+      </button>
+    </div>
+    <div class="col-lg-2">
+    </div>
+  </div>
+
+  <ng-container *ngIf="expandForm">
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Barcode"
+          i18n-placeholder placeholder="Barcode"
+          [(ngModel)]="search.barcode"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Alias"
+          i18n-placeholder placeholder="Alias"
+          [(ngModel)]="search.alias"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Username"
+          i18n-placeholder placeholder="Username"
+          [(ngModel)]="search.usrname"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Email"
+          i18n-placeholder placeholder="Email"
+          [(ngModel)]="search.email"/>
+      </div>
+      <div class="col-lg-2">
+        <button class="btn btn-warning" (click)="clear()" i18n>Clear Form</button>
+      </div>
+      <div class="col-lg-2">
+      </div>
+    </div>
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Identification"
+          i18n-placeholder placeholder="Identification"
+          [(ngModel)]="search.ident"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Phone"
+          i18n-placeholder placeholder="Phone"
+          [(ngModel)]="search.phone"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Street 1"
+          i18n-placeholder placeholder="Street 1"
+          [(ngModel)]="search.street1"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Street 2"
+          i18n-placeholder placeholder="Street 2"
+          [(ngModel)]="search.street2"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="City"
+          i18n-placeholder placeholder="City"
+          [(ngModel)]="search.city"/>
+      </div>
+      <div class="col-lg-2"></div>
+    </div>
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="State"
+          i18n-placeholder placeholder="State"
+          [(ngModel)]="search.state"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Post Code"
+          i18n-placeholder placeholder="Post Code"
+          [(ngModel)]="search.post_code"/>
+      </div>
+      <div class="col-lg-2">
+        <eg-profile-select [useDisplayEntries]="true" 
+          [(ngModel)]="search.profile">
+        </eg-profile-select>
+      </div>
+      <div class="col-lg-2">
+        <eg-org-select (onChange)="searchOrg = $event"
+          [applyOrgId]="searchOrg ? searchOrg.id() : null"
+          i18n-placeholder placeholder="Home Library">
+        </eg-org-select>
+        <!-- home org -->
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Guardian"
+          i18n-placeholder placeholder="Guardian"
+          [(ngModel)]="search.guardian"/>
+      </div>
+      <div class="col-lg-2"></div>
+    </div>
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="DOB Year"
+          i18n-placeholder placeholder="DOB Year"
+          [(ngModel)]="search.dob_year"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="DOB Month"
+          i18n-placeholder placeholder="DOB Month"
+          [(ngModel)]="search.dob_month"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="DOB Day"
+          i18n-placeholder placeholder="DOB Day"
+          [(ngModel)]="search.dob_day"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Database ID"
+          i18n-placeholder placeholder="Database ID"
+          [(ngModel)]="search.id"/>
+      </div>
+      <div class="col-lg-2">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox" 
+            (change)="toggleIncludeInactive()"
+            id="include-inactive" [(ngModel)]="search.inactive">
+          <label class="form-check-label" for="include-inactive" i18n>
+            Include Inactive
+          ?</label>
+        </div>
+      </div>
+      <div class="col-lg-2"></div>
+    </div>
+  </ng-container><!-- expand form -->
+</div>
+
+<div class="patron-search-grid">
+  <eg-grid #searchGrid idlClass="au" 
+    persistKey="circ.patron.search"
+    (onRowActivate)="rowsSelected($event)"
+    [dataSource]="dataSource" 
+    [showDeclaredFieldsOnly]="true"> 
+
+    <eg-grid-column path='id' 
+      i18n-label label="ID"></eg-grid-column>      
+    <eg-grid-column path='card.barcode' 
+      i18n-label label="Card"></eg-grid-column>
+    <eg-grid-column path='profile.name' 
+      i18n-label label="Profile"></eg-grid-column>
+    <eg-grid-column path='family_name' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='first_given_name' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='second_given_name' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='dob' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='home_ou.shortname' 
+      i18n-label label="Home Library"></eg-grid-column>
+    <eg-grid-column path='create_date' i18n-label label="Created On" 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+
+    <eg-grid-column i18n-label label="Mailing:Street 1"
+      path='mailing_address.street1' visible></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:Street 2"
+      path='mailing_address.street2'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:City"
+      path='mailing_address.city'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:County"
+      path='mailing_address.county'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:State"
+      path='mailing_address.state'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:Zip"
+      path='mailing_address.post_code'></eg-grid-column>
+                                                                                 
+    <eg-grid-column i18n-label label="Billing:Street 1"
+      path='billing_address.street1'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:Street 2"
+      path='billing_address.street2'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:City"
+      path='billing_address.city'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:County"
+      path='billing_address.county'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:State"
+      path='billing_address.state'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:Zip"
+      path='billing_address.post_code'></eg-grid-column>
+  </eg-grid>
+
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts
new file mode 100644 (file)
index 0000000..43f4fe1
--- /dev/null
@@ -0,0 +1,239 @@
+import {Component, Input, Output, OnInit, AfterViewInit,
+    EventEmitter, ViewChild, Renderer2} from '@angular/core';
+import {Observable, of} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+
+const DEFAULT_SORT = [
+   'family_name ASC',
+    'first_given_name ASC',
+    'second_given_name ASC',
+    'dob DESC'
+];
+
+const DEFAULT_FLESH = [
+    'card', 'settings', 'standing_penalties', 'addresses', 'billing_address',
+    'mailing_address', 'stat_cat_entries', 'waiver_entries', 'usr_activity',
+    'notes', 'profile'
+];
+
+const EXPAND_FORM = 'eg.circ.patron.search.show_extras';
+const INCLUDE_INACTIVE = 'eg.circ.patron.search.include_inactive';
+
+@Component({
+  selector: 'eg-patron-search',
+  templateUrl: './search.component.html'
+})
+
+export class PatronSearchComponent implements OnInit, AfterViewInit {
+
+    @ViewChild('searchGrid', {static: false}) searchGrid: GridComponent;
+
+    // Fired on dbl-click of a search result row.
+    @Output() patronsSelected: EventEmitter<any>;
+
+    search: any = {};
+    searchOrg: IdlObject;
+    expandForm: boolean;
+    dataSource: GridDataSource;
+    profileGroups: IdlObject[] = [];
+
+    constructor(
+        private renderer: Renderer2,
+        private net: NetService,
+        private org: OrgService,
+        private auth: AuthService,
+        private store: ServerStoreService
+    ) {
+        this.patronsSelected = new EventEmitter<any>();
+        this.dataSource = new GridDataSource();
+        this.dataSource.getRows = (pager: Pager, sort: any[]) => {
+            return this.getRows(pager, sort);
+        };
+    }
+
+    ngOnInit() {
+        this.searchOrg = this.org.root();
+        this.store.getItemBatch([EXPAND_FORM, INCLUDE_INACTIVE])
+            .then(settings => {
+                this.expandForm = settings[EXPAND_FORM];
+                this.search.inactive = settings[INCLUDE_INACTIVE];
+            });
+    }
+
+    ngAfterViewInit() {
+        this.renderer.selectRootElement('#focus-this-input').focus();
+    }
+
+    toggleExpandForm() {
+        this.expandForm = !this.expandForm;
+        if (this.expandForm) {
+            this.store.setItem(EXPAND_FORM, true);
+        } else {
+            this.store.removeItem(EXPAND_FORM);
+        }
+    }
+
+    toggleIncludeInactive() {
+        if (this.search.inactive) { // value set by ngModel
+            this.store.setItem(INCLUDE_INACTIVE, true);
+        } else {
+            this.store.removeItem(INCLUDE_INACTIVE);
+        }
+    }
+
+    rowsSelected(rows: IdlObject | IdlObject[]) {
+        this.patronsSelected.emit([].concat(rows));
+    }
+
+    getSelected(): IdlObject[] {
+        return this.searchGrid ?
+            this.searchGrid.context.getSelectedRows() : [];
+    }
+
+    go() {
+        this.searchGrid.reload();
+    }
+
+    clear() {
+        this.search = {profile: null};
+        this.searchOrg = this.org.root();
+    }
+
+    getRows(pager: Pager, sort: any[]): Observable<any> {
+
+        let observable: Observable<IdlObject>;
+
+        if (this.search.id) {
+            observable = this.searchById();
+        } else {
+            observable = this.searchByForm(pager, sort);
+        }
+
+        return observable.pipe(map(user => this.localFleshUser(user)));
+    }
+
+    localFleshUser(user: IdlObject): IdlObject {
+        user.home_ou(this.org.get(user.home_ou()));
+        return user;
+    }
+
+    searchByForm(pager: Pager, sort: any[]): Observable<IdlObject> {
+
+        const search = this.compileSearch();
+        if (!search) { return of(); }
+
+        const sorter = this.compileSort(sort);
+
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.search.advanced.fleshed',
+            this.auth.token(),
+            this.compileSearch(),
+            pager.limit,
+            sorter,
+            null, // ?
+            this.searchOrg.id(),
+            DEFAULT_FLESH,
+            pager.offset
+        );
+    }
+
+    searchById(): Observable<IdlObject> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.fleshed.retrieve',
+            this.auth.token(), this.search.id, DEFAULT_FLESH
+        );
+    }
+
+    compileSort(sort: any[]): string[] {
+        if (!sort || sort.length === 0) { return DEFAULT_SORT; }
+        return sort.map(def => `${def.name} ${def.dir}`);
+    }
+
+    compileSearch(): any {
+
+        let hasSearch = false;
+        const search: Object = {};
+
+        Object.keys(this.search).forEach(field => {
+            search[field] = this.mapSearchField(field);
+            if (search[field]) { hasSearch = true; }
+        });
+
+        return hasSearch ? search : null;
+    }
+
+    isValue(val: any): boolean {
+        return (val !== null && val !== undefined && val !== '');
+    }
+
+    mapSearchField(field: string): any {
+
+        const value = this.search[field];
+        if (!this.isValue(value)) { return null; }
+
+        const chunk = {value: value, group: 0};
+
+        switch (field) {
+
+            case 'name': // name keywords
+            case 'inactive':
+                delete chunk.group;
+                break;
+
+            case 'street1':
+            case 'street2':
+            case 'city':
+            case 'state':
+            case 'post_code':
+                chunk.group = 1;
+                break;
+
+            case 'phone':
+            case 'ident':
+                chunk.group = 2;
+                break;
+
+            case 'card':
+                chunk.group = 3;
+                break;
+
+            case 'profile':
+                chunk.group = 5;
+                chunk.value = chunk.value.id(); // pgt object
+                break;
+
+            case 'dob_day':
+            case 'dob_month':
+            case 'dob_year':
+                chunk.group = 4;
+                chunk.value = chunk.value.replace(/\D/g, '');
+
+                if (!field.match(/year/)) {
+                    // force day/month to be 2 digits
+                    chunk[field].value = ('0' + value).slice(-2);
+                }
+                break;
+        }
+
+        // Confirm the value wasn't scrubbed away above
+        if (!this.isValue(chunk.value)) { return null; }
+
+        return chunk;
+    }
+}
+
index 169cf63..d1144fd 100644 (file)
@@ -89,6 +89,11 @@ h5 {font-size: .95rem}
     line-height: inherit;
 }
 
+.mat-icon-shrunk-in-button {
+    line-height: inherit;
+    font-size: 18px;
+}
+
 .input-group .mat-icon-in-button {
     font-size: .88rem !important; /* useful for buttons that cuddle up with inputs */
 }