]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
LP1889113 Angular org select persistKey support
[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         if (org) {
101             this.selected = this.formatForDisplay(org);
102         }
103     }
104
105     // Modify the selected org unit by ID via data binding.
106     // This WILL NOT result in an onChange event firing.
107     @Input() set applyOrgId(id: number) {
108         if (id) {
109             this.selected = this.formatForDisplay(this.org.get(id));
110         }
111     }
112
113     // Limit org unit display to those where the logged in user
114     // has the following permissions.
115     permLimitOrgs: number[];
116     @Input() set limitPerms(perms: string[]) {
117         this.applyPermLimitOrgs(perms);
118     }
119
120     // Emitted when the org unit value is changed via the selector.
121     // Does not fire on initialOrg
122     @Output() onChange = new EventEmitter<IdlObject>();
123
124     // Emitted once when the component is done fetching settings
125     // and applying its initial value.  For apps that use the value
126     // of this selector to load data, this event can be used to reliably
127     // detect when the selector is done with all of its automated
128     // underground shuffling and landed on a value.
129     @Output() componentLoaded: EventEmitter<void> = new EventEmitter<void>();
130
131     // convenience method to get an IdlObject representing the current
132     // selected org unit. One way of invoking this is via a template
133     // reference variable.
134     selectedOrg(): IdlObject {
135         if (this.selected == null) {
136             return null;
137         }
138         return this.org.get(this.selected.id);
139     }
140
141     constructor(
142       private auth: AuthService,
143       private store: StoreService,
144       private serverStore: ServerStoreService,
145       private org: OrgService,
146       private perm: PermService
147     ) { }
148
149     ngOnInit() {
150
151         // Sort the tree and reabsorb to propagate the sorted nodes to
152         // the org.list() used by this component.  Maintain our own
153         // copy of the org list in case the org service is sorted in a
154         // different manner by other parts of the code.
155         this.org.sortTree(this.displayField);
156         this.org.absorbTree();
157         this.sortedOrgs = this.org.list();
158
159         if (this.initialOrg || this.initialOrgId) {
160             this.selected = this.formatForDisplay(
161                 this.initialOrg || this.org.get(this.initialOrgId)
162             );
163
164             this.markAsLoaded();
165             return;
166         }
167
168         const promise = this.persistKey ?
169             this.getFromSetting() : Promise.resolve(null);
170
171         promise.then((startupOrgId: number) => {
172
173             if (!startupOrgId) {
174
175                 if (this.fallbackOrgId) {
176                     startupOrgId = this.fallbackOrgId;
177
178                 } else if (this.fallbackOrg) {
179                     startupOrgId = this.org.get(this.fallbackOrg).id();
180
181                 } else if (this.applyDefault && this.auth.user()) {
182                     startupOrgId = this.auth.user().ws_ou();
183                 }
184             }
185
186             let startupOrg;
187             if (startupOrgId) {
188                 startupOrg = this.org.get(startupOrgId);
189                 this.selected = this.formatForDisplay(startupOrg);
190             }
191
192             this.markAsLoaded(startupOrg);
193         });
194     }
195
196     getFromSetting(): Promise<number> {
197
198         const key = `eg.orgselect.${this.persistKey}`;
199
200         return this.serverStore.getItem(key).then(
201             value => this.valueFromSetting = value
202         );
203     }
204
205     // Indicate all load-time shuffling has completed.
206     markAsLoaded(onChangeOrg?: IdlObject) {
207         setTimeout(() => { // Avoid emitting mid-digest
208             this.componentLoaded.emit();
209             this.componentLoaded.complete();
210             if (onChangeOrg) { this.onChange.emit(onChangeOrg); }
211         });
212     }
213
214     //
215     applyPermLimitOrgs(perms: string[]) {
216
217         if (!perms) {
218             return;
219         }
220
221         // handle lazy clients that pass null perm names
222         perms = perms.filter(p => p !== null && p !== undefined);
223
224         if (perms.length === 0) {
225             return;
226         }
227
228         // NOTE: If permLimitOrgs is useful in a non-staff context
229         // we need to change this to support non-staff perm checks.
230         this.perm.hasWorkPermAt(perms, true).then(permMap => {
231             this.permLimitOrgs =
232                 // safari-friendly version of Array.flat()
233                 Object.values(permMap).reduce((acc, val) => acc.concat(val), []);
234         });
235     }
236
237     // Format for display in the selector drop-down and input.
238     formatForDisplay(org: IdlObject): OrgDisplay {
239         let label = org[this.displayField]();
240         if (!this.readOnly) {
241             label = PAD_SPACE.repeat(org.ou_type().depth()) + label;
242         }
243         return {
244             id : org.id(),
245             label : label,
246             disabled : false
247         };
248     }
249
250     // Fired by the typeahead to inform us of a change.
251     // TODO: this does not fire when the value is cleared :( -- implement
252     // change detection on this.selected to look specifically for NULL.
253     orgChanged(selEvent: NgbTypeaheadSelectItemEvent) {
254         // console.debug('org unit change occurred ' + selEvent.item);
255         this.onChange.emit(this.org.get(selEvent.item.id));
256
257         if (this.persistKey && this.valueFromSetting !== selEvent.item.id) {
258             // persistKey is active.  Update the persisted value when changed.
259
260             const key = `eg.orgselect.${this.persistKey}`;
261             this.valueFromSetting = selEvent.item.id;
262             this.serverStore.setItem(key, this.valueFromSetting);
263         }
264     }
265
266     // Remove the tree-padding spaces when matching.
267     formatter = (result: OrgDisplay) => result ? result.label.trim() : '';
268
269     // reset the state of the component
270     reset() {
271         this.selected = null;
272     }
273
274     filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
275         return text$.pipe(
276             debounceTime(200),
277             distinctUntilChanged(),
278             merge(
279                 // Inject a specifier indicating the source of the
280                 // action is a user click
281                 this.click$.pipe(filter(() => !this.instance.isPopupOpen()))
282                 .pipe(mapTo('_CLICK_'))
283             ),
284             map(term => {
285
286                 let orgs = this.sortedOrgs.filter(org =>
287                     this.hidden.filter(id => org.id() === id).length === 0
288                 );
289
290                 if (this.permLimitOrgs) {
291                     // Avoid showing org units where the user does
292                     // not have the requested permission.
293                     orgs = orgs.filter(org =>
294                         this.permLimitOrgs.includes(org.id()));
295                 }
296
297                 if (term !== '_CLICK_') {
298                     // For search-driven events, limit to the matching
299                     // org units.
300                     orgs = orgs.filter(org => {
301                         return term === '' || // show all
302                             org[this.displayField]()
303                                 .toLowerCase().indexOf(term.toLowerCase()) > -1;
304
305                     });
306                 }
307
308                 return orgs.map(org => this.formatForDisplay(org));
309             })
310         );
311     }
312 }
313
314