From: Bill Erickson Date: Fri, 10 Jan 2020 18:02:48 +0000 (-0500) Subject: LPXXX Angular patron search component X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=8e225192a891d155e0d199c5b6dc907f19a934fe;p=working%2FEvergreen.git LPXXX Angular patron search component Signed-off-by: Bill Erickson --- 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 index 55aa368a2e..5406b129b7 100644 --- 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 @@ -1,19 +1,22 @@ 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 {PatronService} from './patron.service'; import {PatronSearchComponent} from './search.component'; +import {ProfileSelectComponent} from './profile-select.component'; @NgModule({ declarations: [ - PatronSearchComponent + PatronSearchComponent, + ProfileSelectComponent ], imports: [ StaffCommonModule, GridModule ], exports: [ - PatronSearchComponent + PatronSearchComponent, + ProfileSelectComponent ], providers: [ PatronService 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 index 0000000000..d5a36663a9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html @@ -0,0 +1,6 @@ + + + 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 index 0000000000..9e810260c7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts @@ -0,0 +1,170 @@ +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. + * + * + * + */ + +// 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; + + @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(); + } + + ngOnInit() { + this.collectGroups().then(grps => this.sortGroups(grps)); + } + + collectGroups(): Promise { + + 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 { + 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()) + .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.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html index 18d64c957b..040c420f57 100644 --- 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 @@ -1,15 +1,12 @@ -
-
- +
-
-
-
+ +
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ + + +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+
+
- + - - - - - - - - - - - - - - - - + + + + + + + + + - - - - - - + + + + + + - - - - - - - + + + + + + +
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 index dba36c3274..481d8b34fc 100644 --- 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 @@ -8,6 +8,7 @@ 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'; @@ -16,18 +17,21 @@ 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" + '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" + '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' @@ -39,13 +43,17 @@ export class PatronSearchComponent implements OnInit, AfterViewInit { @Output() patronsSelected: EventEmitter; search: any = {}; + searchOrg: number; + expandForm: boolean; dataSource: GridDataSource; + profileGroups: IdlObject[] = []; constructor( private renderer: Renderer2, private net: NetService, private org: OrgService, - private auth: AuthService + private auth: AuthService, + private store: ServerStoreService ) { this.patronsSelected = new EventEmitter(); this.dataSource = new GridDataSource(); @@ -55,22 +63,72 @@ export class PatronSearchComponent implements OnInit, AfterViewInit { } ngOnInit() { + this.searchOrg = this.org.root().id(); + 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(); } - rowsSelected(rows: any) { - this.patronsSelected.emit(rows); + 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)); } go() { this.searchGrid.reload(); } + clear() { + this.search = {}; + this.searchOrg = this.org.root().id(); + } + + homeOrgChange(orgId: number) { + this.searchOrg = orgId; + } + 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(); } @@ -84,15 +142,18 @@ export class PatronSearchComponent implements OnInit, AfterViewInit { pager.limit, sorter, null, // ? - this.auth.user().ws_ou(), + this.searchOrg, DEFAULT_FLESH, pager.offset - ).pipe(map(user => this.localFleshUser(user))); + ); } - localFleshUser(user: IdlObject): IdlObject { - user.home_ou(this.org.get(user.home_ou())); - return user; + 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[] { @@ -106,13 +167,8 @@ export class PatronSearchComponent implements OnInit, AfterViewInit { const search: Object = {}; Object.keys(this.search).forEach(field => { - const val = this.search[field]; - - if (this.isValue(val)) { - hasSearch = true; - search[field] = this.mapSearchField(field, val); - } - + search[field] = this.mapSearchField(field); + if (search[field]) { hasSearch = true; } }); return hasSearch ? search : null; @@ -122,18 +178,18 @@ export class PatronSearchComponent implements OnInit, AfterViewInit { return (val !== null && val !== undefined && val !== ''); } - mapSearchField(field: string, value: any): any { + 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': - delete chunk.group; - break; - case 'phone': // thunk - case 'ident': - chunk.group = 2; + case 'name': // name keywords + case 'inactive': + delete chunk.group; break; case 'street1': @@ -144,11 +200,36 @@ export class PatronSearchComponent implements OnInit, AfterViewInit { 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 */ }