1 import {Component, Input, Output, OnInit, AfterViewInit,
2 EventEmitter, ViewChild} from '@angular/core';
3 import {ActivatedRoute, ParamMap} from '@angular/router';
4 import {Observable, of, from} from 'rxjs';
5 import {map, concatMap} from 'rxjs/operators';
6 import {IdlObject} from '@eg/core/idl.service';
7 import {NetService} from '@eg/core/net.service';
8 import {AuthService} from '@eg/core/auth.service';
9 import {OrgService} from '@eg/core/org.service';
10 import {ServerStoreService} from '@eg/core/server-store.service';
11 import {GridComponent} from '@eg/share/grid/grid.component';
12 import {GridDataSource} from '@eg/share/grid/grid';
13 import {Pager} from '@eg/share/util/pager';
14 import {BucketDialogComponent} from '@eg/staff/share/buckets/bucket-dialog.component';
15 import {PatronMergeDialogComponent} from './merge-dialog.component';
17 const DEFAULT_SORT = [
19 'first_given_name ASC',
20 'second_given_name ASC',
24 const DEFAULT_FLESH = [
25 'card', 'settings', 'standing_penalties', 'addresses', 'billing_address',
26 'mailing_address', 'stat_cat_entries', 'waiver_entries', 'usr_activity',
30 const EXPAND_FORM = 'eg.circ.patron.search.show_extras';
31 const INCLUDE_INACTIVE = 'eg.circ.patron.search.include_inactive';
33 export interface PatronSearchField {
38 export interface PatronSearchFieldSet {
39 [field: string]: PatronSearchField;
42 export interface PatronSearch {
43 search: PatronSearchFieldSet;
48 selector: 'eg-patron-search',
49 templateUrl: './search.component.html'
52 export class PatronSearchComponent implements OnInit, AfterViewInit {
54 @ViewChild('searchGrid') searchGrid: GridComponent;
55 @ViewChild('addToBucket') addToBucket: BucketDialogComponent;
56 @ViewChild('mergeDialog') mergeDialog: PatronMergeDialogComponent;
58 startWithFired = false;
59 @Input() startWithSearch: PatronSearch;
61 // If set, load a batch of patrons by ID.
62 @Input() patronIds: number[];
64 // Fires on dbl-click or Enter while one or more search result
66 @Output() patronsActivated: EventEmitter<any>;
68 // Fires when the selection of search result rows changes.
69 // Emits an array of patron IDs
70 @Output() selectionChange: EventEmitter<number[]>;
72 // Fired with each search that is run, except for
73 // any searches run as a result of @Input() startWithSearch.
74 @Output() searchFired: EventEmitter<PatronSearch>;
76 // Fired when the search form is cleared via the Clear Form button.
77 @Output() formCleared: EventEmitter<void> = new EventEmitter<void>();
82 dataSource: GridDataSource;
83 profileGroups: IdlObject[] = [];
86 private route: ActivatedRoute,
87 private net: NetService,
88 public org: OrgService,
89 private auth: AuthService,
90 private store: ServerStoreService
92 this.patronsActivated = new EventEmitter<any>();
93 this.selectionChange = new EventEmitter<number[]>();
94 this.selectionChange = new EventEmitter<number[]>();
95 this.searchFired = new EventEmitter<PatronSearch>();
97 this.dataSource = new GridDataSource();
98 this.dataSource.getRows = (pager: Pager, sort: any[]) => {
99 return this.getRows(pager, sort);
105 this.route.queryParamMap.subscribe((params: ParamMap) => {
106 const search = params.get('search');
109 this.startWithSearch = {search: JSON.parse(search)};
111 console.error('Invalid JSON search value', search, E);
116 this.searchOrg = this.org.root();
117 this.store.getItemBatch([EXPAND_FORM, INCLUDE_INACTIVE])
119 this.expandForm = settings[EXPAND_FORM];
120 this.search.inactive = settings[INCLUDE_INACTIVE];
125 const node = document.getElementById('focus-this-input');
126 if (node) { node.focus(); }
130 this.expandForm = !this.expandForm;
131 if (this.expandForm) {
132 this.store.setItem(EXPAND_FORM, true);
134 this.store.removeItem(EXPAND_FORM);
138 toggleIncludeInactive() {
139 if (this.search.inactive) { // value set by ngModel
140 this.store.setItem(INCLUDE_INACTIVE, true);
142 this.store.removeItem(INCLUDE_INACTIVE);
146 gridSelectionChange(keys: string[]) {
147 this.selectionChange.emit(keys.map(k => Number(k)));
150 rowsActivated(rows: IdlObject | IdlObject[]) {
151 this.patronsActivated.emit([].concat(rows));
154 getSelected(): IdlObject[] {
155 return this.searchGrid ?
156 this.searchGrid.context.getSelectedRows() : [];
160 this.searchGrid.reload();
164 this.search = {profile: null};
165 this.searchGrid.reload();
166 this.formCleared.emit();
169 getRows(pager: Pager, sort: any[]): Observable<any> {
171 let observable: Observable<IdlObject>;
173 if (this.patronIds && !this.startWithFired) {
174 observable = this.searchById(this.patronIds);
175 this.startWithFired = true;
176 } else if (this.search.id) {
177 observable = this.searchById([this.search.id]);
179 observable = this.searchByForm(pager, sort);
182 return observable.pipe(map(user => this.localFleshUser(user)));
185 localFleshUser(user: IdlObject): IdlObject {
186 user.home_ou(this.org.get(user.home_ou()));
190 // Absorb a patron search object into the search form.
191 absorbPatronSearch(pSearch: PatronSearch) {
194 this.searchOrg = this.org.get(pSearch.orgId);
197 Object.keys(pSearch.search).forEach(field => {
198 this.search[field] = pSearch.search[field].value;
202 searchByForm(pager: Pager, sort: any[]): Observable<IdlObject> {
204 if (this.startWithSearch && !this.startWithFired) {
205 this.absorbPatronSearch(this.startWithSearch);
208 // Never fire a "start with" search after any search has fired
209 this.startWithFired = true;
211 const search = this.compileSearch();
212 if (!search) { return of(); }
214 const sorter = this.compileSort(sort);
216 const pSearch: PatronSearch = {
218 orgId: this.searchOrg.id()
221 this.searchFired.emit(pSearch);
223 return this.net.request(
225 'open-ils.actor.patron.search.advanced.fleshed',
230 this.search.inactive,
237 searchById(patronIds: number[]): Observable<IdlObject> {
238 return from(patronIds).pipe(concatMap(id => {
239 return this.net.request(
241 'open-ils.actor.user.fleshed.retrieve',
242 this.auth.token(), id, DEFAULT_FLESH
247 compileSort(sort: any[]): string[] {
248 if (!sort || sort.length === 0) { return DEFAULT_SORT; }
249 return sort.map(def => `${def.name} ${def.dir}`);
252 compileSearch(): PatronSearchFieldSet {
254 let hasSearch = false;
255 const search: PatronSearchFieldSet = {};
257 Object.keys(this.search).forEach(field => {
258 if (field === 'inactive') { return; }
259 search[field] = this.mapSearchField(field);
260 if (search[field] !== null) {
263 delete search[field];
267 return hasSearch ? search : null;
270 isValue(val: any): boolean {
271 return (val !== null && val !== undefined && val !== '');
274 mapSearchField(field: string): PatronSearchField {
276 const value = this.search[field];
277 if (!this.isValue(value)) { return null; }
279 const chunk: PatronSearchField = {value: value, group: 0};
283 case 'name': // name keywords
306 chunk.value = chunk.value.id(); // pgt object
313 chunk.value = chunk.value.replace(/\D/g, '');
315 if (!field.match(/year/)) {
316 // force day/month to be 2 digits
317 chunk.value = ('0' + value).slice(-2);
322 // Confirm the value wasn't scrubbed away above
323 if (!this.isValue(chunk.value)) { return null; }
328 addSelectedToBucket(rows: IdlObject[]) {
329 this.addToBucket.itemIds = rows.map(r => r.id());
330 this.addToBucket.open().subscribe();
333 mergePatrons(rows: IdlObject[]) {
334 this.mergeDialog.patronIds = [rows[0].id(), rows[1].id()];
335 this.mergeDialog.open({size: 'lg'}).subscribe(changes => {
336 if (changes) { this.searchGrid.reload(); }