From 3629e47a5a578728121f416294b1612c003088c5 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Thu, 9 Jan 2020 17:20:42 -0500 Subject: [PATCH] LP1859241 Angular holds patron search dialog 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 --- .../src/app/share/combobox/combobox.component.ts | 21 +- Open-ILS/src/eg2/src/app/share/grid/grid.ts | 4 +- .../eg2/src/app/staff/booking/booking.module.ts | 6 +- .../eg2/src/app/staff/booking/pickup.component.ts | 2 +- .../eg2/src/app/staff/booking/return.component.ts | 2 +- .../eg2/src/app/staff/catalog/catalog.module.ts | 2 + .../src/app/staff/catalog/hold/hold.component.html | 6 +- .../src/app/staff/catalog/hold/hold.component.ts | 21 ++ .../app/staff/catalog/result/results.component.ts | 8 +- .../src/app/staff/share/patron/patron.module.ts | 30 +++ .../app/staff/share/{ => patron}/patron.service.ts | 0 .../share/patron/profile-select.component.html | 6 + .../staff/share/patron/profile-select.component.ts | 178 +++++++++++++++ .../share/patron/search-dialog.component.html | 23 ++ .../staff/share/patron/search-dialog.component.ts | 36 ++++ .../app/staff/share/patron/search.component.html | 233 ++++++++++++++++++++ .../src/app/staff/share/patron/search.component.ts | 239 +++++++++++++++++++++ Open-ILS/src/eg2/src/styles.css | 5 + 18 files changed, 803 insertions(+), 19 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts rename Open-ILS/src/eg2/src/app/staff/share/{ => patron}/patron.service.ts (100%) create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts index 3d9860471f..316cd9a1f9 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts @@ -73,16 +73,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; } } diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts index 87dfc2bd9a..e662fd9bba 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts @@ -229,9 +229,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; } } } diff --git a/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts index 9b14137243..dbcfb03b8f 100644 --- a/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts @@ -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, diff --git a/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts index 028f7cf89f..076c4132ca 100644 --- a/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts @@ -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'; diff --git a/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts index d7a42f491f..f37e10e8a3 100644 --- a/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts @@ -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'; diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts index 810b9507fd..fe82873505 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts @@ -6,6 +6,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 {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module'; HoldsModule, HoldingsModule, BookingModule, + PatronModule, MarcEditModule ], providers: [ diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html index fa04d86ede..dca200dace 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html @@ -1,3 +1,7 @@ + + + +

Place Hold @@ -7,7 +11,7 @@

- +
+ + + + 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 index 0000000000..98e1c22d72 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts @@ -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 + * + * + * + */ + +@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 index 0000000000..f2e363217e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html @@ -0,0 +1,233 @@ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ + +
+
+ + + +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 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 index 0000000000..43f4fe18db --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts @@ -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; + + 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(); + 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 { + + let observable: Observable; + + 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 { + + 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 { + 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; + } +} + diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index ef97e2a93d..f80282fb80 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -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 */ } -- 2.11.0