1a114355585cbce384e3c7f1ee62e681661e944b
[Evergreen.git] / Open-ILS / src / eg2 / src / app / share / org-select / org-select.component.ts
1 /** TODO PORT ME TO <eg-combobox> */
2 import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core';
3 import {Observable, Subject} from 'rxjs';
4 import {map, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
5 import {AuthService} from '@eg/core/auth.service';
6 import {StoreService} from '@eg/core/store.service';
7 import {ServerStoreService} from '@eg/core/server-store.service';
8 import {OrgService} from '@eg/core/org.service';
9 import {IdlObject} from '@eg/core/idl.service';
10 import {PermService} from '@eg/core/perm.service';
11 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
12
13 /** Org unit selector
14  *
15  * The following precedence is used when applying a load-time value
16  *
17  * 1. initialOrg / initialOrgId
18  * 2. Value from server setting specificed with persistKey (fires onload).
19  * 3. Value from fallbackOrg / fallbackOrgId (fires onload).
20  * 4. Default applyed when applyDefault is set (fires onload).
21  *
22  * Users can detect when the component has completed its load-time
23  * machinations by subscribing to the componentLoaded Output which
24  * fires exactly once when loading is completed.
25  */
26
27 // Use a unicode char for spacing instead of ASCII=32 so the browser
28 // won't collapse the nested display entries down to a single space.
29 const PAD_SPACE = ' '; // U+2007
30
31 interface OrgDisplay {
32   id: number;
33   label: string;
34   disabled: boolean;
35 }
36
37 @Component({
38   selector: 'eg-org-select',
39   templateUrl: './org-select.component.html'
40 })
41 export class OrgSelectComponent implements OnInit {
42
43     selected: OrgDisplay;
44     click$ = new Subject<string>();
45     valueFromSetting: number = null;
46     sortedOrgs: IdlObject[] = [];
47
48     // Disable the entire input
49     @Input() disabled: boolean;
50
51     @ViewChild('instance', { static: false }) instance: NgbTypeahead;
52
53     // Placeholder text for selector input
54     @Input() placeholder = '';
55
56     // ID to display in the DOM for this selector
57     @Input() domId = '';
58
59     // Org unit field displayed in the selector
60     @Input() displayField = 'shortname';
61
62     // if no initialOrg is provided, none could be found via persist
63     // setting, and no fallbackoOrg is provided, apply a sane default.
64     // First tries workstation org unit, then user home org unit.
65     // An onChange event WILL be generated when a default is applied.
66     @Input() applyDefault = false;
67
68     @Input() readOnly = false;
69
70     // List of org unit IDs to exclude from the selector
71     hidden: number[] = [];
72     @Input() set hideOrgs(ids: number[]) {
73         if (ids) { this.hidden = ids; }
74     }
75
76     // List of org unit IDs to disable in the selector
77     _disabledOrgs: number[] = [];
78     @Input() set disableOrgs(ids: number[]) {
79         if (ids) { this._disabledOrgs = ids; }
80     }
81
82     // Apply an org unit value at load time.
83     // These will NOT result in an onChange event.
84     @Input() initialOrg: IdlObject;
85     @Input() initialOrgId: number;
86
87     // Value is persisted via server setting with this key.
88     // Key is prepended with 'eg.orgselect.'
89     @Input() persistKey: string;
90
91     // If no initialOrg is provided and no value could be found
92     // from a persist setting, fall back to one of these values.
93     // These WILL result in an onChange event
94     @Input() fallbackOrg: IdlObject;
95     @Input() fallbackOrgId: number;
96
97     // Modify the selected org unit via data binding.
98     // This WILL NOT result in an onChange event firing.
99     @Input() set applyOrg(org: IdlObject) {
100         this.selected = org ? this.formatForDisplay(org) : null;
101     }
102
103     // Modify the selected org unit by ID via data binding.
104     // This WILL NOT result in an onChange event firing.
105     @Input() set applyOrgId(id: number) {
106         this.selected = id ? this.formatForDisplay(this.org.get(id)) : null;
107     }
108
109     // Limit org unit display to those where the logged in user
110     // has the following permissions.
111     permLimitOrgs: number[];
112     @Input() set limitPerms(perms: string[]) {
113         this.applyPermLimitOrgs(perms);
114     }
115
116     // Emitted when the org unit value is changed via the selector.
117     // Does not fire on initialOrg
118     @Output() onChange = new EventEmitter<IdlObject>();
119
120     // Emitted once when the component is done fetching settings
121     // and applying its initial value.  For apps that use the value
122     // of this selector to load data, this event can be used to reliably
123     // detect when the selector is done with all of its automated
124     // underground shuffling and landed on a value.
125     @Output() componentLoaded: EventEmitter<void> = new EventEmitter<void>();
126
127     // convenience method to get an IdlObject representing the current
128     // selected org unit. One way of invoking this is via a template
129     // reference variable.
130     selectedOrg(): IdlObject {
131         if (this.selected == null) {
132             return null;
133         }
134         return this.org.get(this.selected.id);
135     }
136
137     constructor(
138       private auth: AuthService,
139       private store: StoreService,
140       private serverStore: ServerStoreService,
141       private org: OrgService,
142       private perm: PermService
143     ) { }
144
145     ngOnInit() {
146
147         // Sort the tree and reabsorb to propagate the sorted nodes to
148         // the org.list() used by this component.  Maintain our own
149         // copy of the org list in case the org service is sorted in a
150         // different manner by other parts of the code.
151         this.org.sortTree(this.displayField);
152         this.org.absorbTree();
153         this.sortedOrgs = this.org.list();
154
155         if (this.initialOrg || this.initialOrgId) {
156             this.selected = this.formatForDisplay(
157                 this.initialOrg || this.org.get(this.initialOrgId)
158             );
159
160             this.markAsLoaded();
161             return;
162         }
163
164         const promise = this.persistKey ?
165             this.getFromSetting() : Promise.resolve(null);
166
167         promise.then((startupOrgId: number) => {
168
169             if (!startupOrgId) {
170
171                 if (this.fallbackOrgId) {
172                     startupOrgId = this.fallbackOrgId;
173
174                 } else if (this.fallbackOrg) {
175                     startupOrgId = this.org.get(this.fallbackOrg).id();
176
177                 } else if (this.applyDefault && this.auth.user()) {
178                     startupOrgId = this.auth.user().ws_ou();
179                 }
180             }
181
182             let startupOrg;
183             if (startupOrgId) {
184                 startupOrg = this.org.get(startupOrgId);
185                 this.selected = this.formatForDisplay(startupOrg);
186             }
187
188             this.markAsLoaded(startupOrg);
189         });
190     }
191
192     getFromSetting(): Promise<number> {
193
194         const key = `eg.orgselect.${this.persistKey}`;
195
196         return this.serverStore.getItem(key).then(
197             value => this.valueFromSetting = value
198         );
199     }
200
201     // Indicate all load-time shuffling has completed.
202     markAsLoaded(onChangeOrg?: IdlObject) {
203         setTimeout(() => { // Avoid emitting mid-digest
204             this.componentLoaded.emit();
205             this.componentLoaded.complete();
206             if (onChangeOrg) { this.onChange.emit(onChangeOrg); }
207         });
208     }
209
210     //
211     applyPermLimitOrgs(perms: string[]) {
212
213         if (!perms) {
214             return;
215         }
216
217         // handle lazy clients that pass null perm names
218         perms = perms.filter(p => p !== null && p !== undefined);
219
220         if (perms.length === 0) {
221             return;
222         }
223
224         // NOTE: If permLimitOrgs is useful in a non-staff context
225         // we need to change this to support non-staff perm checks.
226         this.perm.hasWorkPermAt(perms, true).then(permMap => {
227             this.permLimitOrgs =
228                 // safari-friendly version of Array.flat()
229                 Object.values(permMap).reduce((acc, val) => acc.concat(val), []);
230         });
231     }
232
233     // Format for display in the selector drop-down and input.
234     formatForDisplay(org: IdlObject): OrgDisplay {
235         let label = org[this.displayField]();
236         if (!this.readOnly) {
237             label = PAD_SPACE.repeat(org.ou_type().depth()) + label;
238         }
239         return {
240             id : org.id(),
241             label : label,
242             disabled : false
243         };
244     }
245
246     // Fired by the typeahead to inform us of a change.
247     // TODO: this does not fire when the value is cleared :( -- implement
248     // change detection on this.selected to look specifically for NULL.
249     orgChanged(selEvent: NgbTypeaheadSelectItemEvent) {
250         // console.debug('org unit change occurred ' + selEvent.item);
251         this.onChange.emit(this.org.get(selEvent.item.id));
252
253         if (this.persistKey && this.valueFromSetting !== selEvent.item.id) {
254             // persistKey is active.  Update the persisted value when changed.
255
256             const key = `eg.orgselect.${this.persistKey}`;
257             this.valueFromSetting = selEvent.item.id;
258             this.serverStore.setItem(key, this.valueFromSetting);
259         }
260     }
261
262     // Remove the tree-padding spaces when matching.
263     formatter = (result: OrgDisplay) => result ? result.label.trim() : '';
264
265     // reset the state of the component
266     reset() {
267         this.selected = null;
268     }
269
270     filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
271         return text$.pipe(
272             debounceTime(200),
273             distinctUntilChanged(),
274             merge(
275                 // Inject a specifier indicating the source of the
276                 // action is a user click
277                 this.click$.pipe(filter(() => !this.instance.isPopupOpen()))
278                 .pipe(mapTo('_CLICK_'))
279             ),
280             map(term => {
281
282                 let orgs = this.sortedOrgs.filter(org =>
283                     this.hidden.filter(id => org.id() === id).length === 0
284                 );
285
286                 if (this.permLimitOrgs) {
287                     // Avoid showing org units where the user does
288                     // not have the requested permission.
289                     orgs = orgs.filter(org =>
290                         this.permLimitOrgs.includes(org.id()));
291                 }
292
293                 if (term !== '_CLICK_') {
294                     // For search-driven events, limit to the matching
295                     // org units.
296                     orgs = orgs.filter(org => {
297                         return term === '' || // show all
298                             org[this.displayField]()
299                                 .toLowerCase().indexOf(term.toLowerCase()) > -1;
300
301                     });
302                 }
303
304                 return orgs.map(org => this.formatForDisplay(org));
305             })
306         );
307     }
308 }
309
310