LP1904036 Patron search Clear Form clears visible results/summary
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / staff / share / patron / search.component.ts
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';
16
17 const DEFAULT_SORT = [
18    'family_name ASC',
19     'first_given_name ASC',
20     'second_given_name ASC',
21     'dob DESC'
22 ];
23
24 const DEFAULT_FLESH = [
25     'card', 'settings', 'standing_penalties', 'addresses', 'billing_address',
26     'mailing_address', 'stat_cat_entries', 'waiver_entries', 'usr_activity',
27     'notes', 'profile'
28 ];
29
30 const EXPAND_FORM = 'eg.circ.patron.search.show_extras';
31 const INCLUDE_INACTIVE = 'eg.circ.patron.search.include_inactive';
32
33 export interface PatronSearchField {
34     value: any;
35     group?: number;
36 }
37
38 export interface PatronSearchFieldSet {
39     [field: string]: PatronSearchField;
40 }
41
42 export interface PatronSearch {
43     search: PatronSearchFieldSet;
44     orgId?: number;
45 }
46
47 @Component({
48   selector: 'eg-patron-search',
49   templateUrl: './search.component.html'
50 })
51
52 export class PatronSearchComponent implements OnInit, AfterViewInit {
53
54     @ViewChild('searchGrid') searchGrid: GridComponent;
55     @ViewChild('addToBucket') addToBucket: BucketDialogComponent;
56     @ViewChild('mergeDialog') mergeDialog: PatronMergeDialogComponent;
57
58     startWithFired = false;
59     @Input() startWithSearch: PatronSearch;
60
61     // If set, load a batch of patrons by ID.
62     @Input() patronIds: number[];
63
64     // Fires on dbl-click or Enter while one or more search result
65     // rows are selected.
66     @Output() patronsActivated: EventEmitter<any>;
67
68     // Fires when the selection of search result rows changes.
69     // Emits an array of patron IDs
70     @Output() selectionChange: EventEmitter<number[]>;
71
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>;
75
76     // Fired when the search form is cleared via the Clear Form button.
77     @Output() formCleared: EventEmitter<void> = new EventEmitter<void>();
78
79     search: any = {};
80     searchOrg: IdlObject;
81     expandForm: boolean;
82     dataSource: GridDataSource;
83     profileGroups: IdlObject[] = [];
84
85     constructor(
86         private route: ActivatedRoute,
87         private net: NetService,
88         public org: OrgService,
89         private auth: AuthService,
90         private store: ServerStoreService
91     ) {
92         this.patronsActivated = new EventEmitter<any>();
93         this.selectionChange = new EventEmitter<number[]>();
94         this.selectionChange = new EventEmitter<number[]>();
95         this.searchFired = new EventEmitter<PatronSearch>();
96
97         this.dataSource = new GridDataSource();
98         this.dataSource.getRows = (pager: Pager, sort: any[]) => {
99             return this.getRows(pager, sort);
100         };
101     }
102
103     ngOnInit() {
104
105         this.route.queryParamMap.subscribe((params: ParamMap) => {
106             const search = params.get('search');
107             if (search) {
108                 try {
109                     this.startWithSearch = {search: JSON.parse(search)};
110                 } catch (E) {
111                     console.error('Invalid JSON search value', search, E);
112                 }
113             }
114         });
115
116         this.searchOrg = this.org.root();
117         this.store.getItemBatch([EXPAND_FORM, INCLUDE_INACTIVE])
118             .then(settings => {
119                 this.expandForm = settings[EXPAND_FORM];
120                 this.search.inactive = settings[INCLUDE_INACTIVE];
121             });
122     }
123
124     ngAfterViewInit() {
125         const node = document.getElementById('focus-this-input');
126         if (node) { node.focus(); }
127     }
128
129     toggleExpandForm() {
130         this.expandForm = !this.expandForm;
131         if (this.expandForm) {
132             this.store.setItem(EXPAND_FORM, true);
133         } else {
134             this.store.removeItem(EXPAND_FORM);
135         }
136     }
137
138     toggleIncludeInactive() {
139         if (this.search.inactive) { // value set by ngModel
140             this.store.setItem(INCLUDE_INACTIVE, true);
141         } else {
142             this.store.removeItem(INCLUDE_INACTIVE);
143         }
144     }
145
146     gridSelectionChange(keys: string[]) {
147         this.selectionChange.emit(keys.map(k => Number(k)));
148     }
149
150     rowsActivated(rows: IdlObject | IdlObject[]) {
151         this.patronsActivated.emit([].concat(rows));
152     }
153
154     getSelected(): IdlObject[] {
155         return this.searchGrid ?
156             this.searchGrid.context.getSelectedRows() : [];
157     }
158
159     go() {
160         this.searchGrid.reload();
161     }
162
163     clear() {
164         this.search = {profile: null};
165         this.searchGrid.reload();
166         this.formCleared.emit();
167     }
168
169     getRows(pager: Pager, sort: any[]): Observable<any> {
170
171         let observable: Observable<IdlObject>;
172
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]);
178         } else {
179             observable = this.searchByForm(pager, sort);
180         }
181
182         return observable.pipe(map(user => this.localFleshUser(user)));
183     }
184
185     localFleshUser(user: IdlObject): IdlObject {
186         user.home_ou(this.org.get(user.home_ou()));
187         return user;
188     }
189
190     // Absorb a patron search object into the search form.
191     absorbPatronSearch(pSearch: PatronSearch) {
192
193         if (pSearch.orgId) {
194             this.searchOrg = this.org.get(pSearch.orgId);
195         }
196
197         Object.keys(pSearch.search).forEach(field => {
198             this.search[field] = pSearch.search[field].value;
199         });
200     }
201
202     searchByForm(pager: Pager, sort: any[]): Observable<IdlObject> {
203
204         if (this.startWithSearch && !this.startWithFired) {
205             this.absorbPatronSearch(this.startWithSearch);
206         }
207
208         // Never fire a "start with" search after any search has fired
209         this.startWithFired = true;
210
211         const search = this.compileSearch();
212         if (!search) { return of(); }
213
214         const sorter = this.compileSort(sort);
215
216         const pSearch: PatronSearch = {
217             search: search,
218             orgId: this.searchOrg.id()
219         };
220
221         this.searchFired.emit(pSearch);
222
223         return this.net.request(
224             'open-ils.actor',
225             'open-ils.actor.patron.search.advanced.fleshed',
226             this.auth.token(),
227             pSearch.search,
228             pager.limit,
229             sorter,
230             this.search.inactive,
231             pSearch.orgId,
232             DEFAULT_FLESH,
233             pager.offset
234         );
235     }
236
237     searchById(patronIds: number[]): Observable<IdlObject> {
238         return from(patronIds).pipe(concatMap(id => {
239             return this.net.request(
240                 'open-ils.actor',
241                 'open-ils.actor.user.fleshed.retrieve',
242                 this.auth.token(), id, DEFAULT_FLESH
243             );
244         }));
245     }
246
247     compileSort(sort: any[]): string[] {
248         if (!sort || sort.length === 0) { return DEFAULT_SORT; }
249         return sort.map(def => `${def.name} ${def.dir}`);
250     }
251
252     compileSearch(): PatronSearchFieldSet {
253
254         let hasSearch = false;
255         const search: PatronSearchFieldSet = {};
256
257         Object.keys(this.search).forEach(field => {
258             if (field === 'inactive') { return; }
259             search[field] = this.mapSearchField(field);
260             if (search[field] !== null) {
261                 hasSearch = true;
262             } else {
263                 delete search[field];
264             }
265         });
266
267         return hasSearch ? search : null;
268     }
269
270     isValue(val: any): boolean {
271         return (val !== null && val !== undefined && val !== '');
272     }
273
274     mapSearchField(field: string): PatronSearchField {
275
276         const value = this.search[field];
277         if (!this.isValue(value)) { return null; }
278
279         const chunk: PatronSearchField = {value: value, group: 0};
280
281         switch (field) {
282
283             case 'name': // name keywords
284                 delete chunk.group;
285                 break;
286
287             case 'street1':
288             case 'street2':
289             case 'city':
290             case 'state':
291             case 'post_code':
292                 chunk.group = 1;
293                 break;
294
295             case 'phone':
296             case 'ident':
297                 chunk.group = 2;
298                 break;
299
300             case 'card':
301                 chunk.group = 3;
302                 break;
303
304             case 'profile':
305                 chunk.group = 5;
306                 chunk.value = chunk.value.id(); // pgt object
307                 break;
308
309             case 'dob_day':
310             case 'dob_month':
311             case 'dob_year':
312                 chunk.group = 4;
313                 chunk.value = chunk.value.replace(/\D/g, '');
314
315                 if (!field.match(/year/)) {
316                     // force day/month to be 2 digits
317                     chunk.value = ('0' + value).slice(-2);
318                 }
319                 break;
320         }
321
322         // Confirm the value wasn't scrubbed away above
323         if (!this.isValue(chunk.value)) { return null; }
324
325         return chunk;
326     }
327
328     addSelectedToBucket(rows: IdlObject[]) {
329         this.addToBucket.itemIds = rows.map(r => r.id());
330         this.addToBucket.open().subscribe();
331     }
332
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(); }
337         });
338     }
339 }
340