]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
LP1929741 ACQ Selection List & PO Angluar Port
[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     static domId = 0;
43
44     showCombinedNames = false; // Managed via user/workstation setting
45
46     selected: OrgDisplay;
47     click$ = new Subject<string>();
48     valueFromSetting: number = null;
49     sortedOrgs: IdlObject[] = [];
50
51     // Disable the entire input
52     @Input() disabled: boolean;
53
54     @ViewChild('instance', { static: false }) instance: NgbTypeahead;
55
56     // Placeholder text for selector input
57     @Input() placeholder = '';
58
59     // ID to display in the DOM for this selector
60     @Input() domId = 'eg-org-select-' + OrgSelectComponent.domId++;
61
62     // Org unit field displayed in the selector
63     @Input() displayField = 'shortname';
64
65     // if no initialOrg is provided, none could be found via persist
66     // setting, and no fallbackoOrg is provided, apply a sane default.
67     // First tries workstation org unit, then user home org unit.
68     // An onChange event WILL be generated when a default is applied.
69     @Input() applyDefault = false;
70
71     @Input() readOnly = false;
72
73     // List of org unit IDs to exclude from the selector
74     hidden: number[] = [];
75     @Input() set hideOrgs(ids: number[]) {
76         if (ids) { this.hidden = ids; }
77     }
78
79     // List of org unit IDs to disable in the selector
80     _disabledOrgs: number[] = [];
81     @Input() set disableOrgs(ids: number[]) {
82         if (ids) { this._disabledOrgs = ids; }
83     }
84
85     get disableOrgs(): number[] {
86         return this._disabledOrgs;
87     }
88
89     // Apply an org unit value at load time.
90     // These will NOT result in an onChange event.
91     @Input() initialOrg: IdlObject;
92     @Input() initialOrgId: number;
93
94     // Value is persisted via server setting with this key.
95     // Key is prepended with 'eg.orgselect.'
96     @Input() persistKey: string;
97
98     // If no initialOrg is provided and no value could be found
99     // from a persist setting, fall back to one of these values.
100     // These WILL result in an onChange event
101     @Input() fallbackOrg: IdlObject;
102     @Input() fallbackOrgId: number;
103
104     // Modify the selected org unit via data binding.
105     // This WILL NOT result in an onChange event firing.
106     @Input() set applyOrg(org: IdlObject) {
107         this.selected = org ? this.formatForDisplay(org) : null;
108     }
109
110     // Modify the selected org unit by ID via data binding.
111     // This WILL NOT result in an onChange event firing.
112     @Input() set applyOrgId(id: number) {
113         this.selected = id ? this.formatForDisplay(this.org.get(id)) : null;
114     }
115
116     // Limit org unit display to those where the logged in user
117     // has the following permissions.
118     permLimitOrgs: number[];
119     @Input() set limitPerms(perms: string[]) {
120         this.applyPermLimitOrgs(perms);
121     }
122
123     // Function which should return a string value representing
124     // a CSS class name to use for styling each org unit label
125     // in the selector.
126     @Input() orgClassCallback: (orgId: number) => string;
127
128     // Emitted when the org unit value is changed via the selector.
129     // Does not fire on initialOrg
130     @Output() onChange = new EventEmitter<IdlObject>();
131
132     // Emitted once when the component is done fetching settings
133     // and applying its initial value.  For apps that use the value
134     // of this selector to load data, this event can be used to reliably
135     // detect when the selector is done with all of its automated
136     // underground shuffling and landed on a value.
137     @Output() componentLoaded: EventEmitter<void> = new EventEmitter<void>();
138
139     // convenience method to get an IdlObject representing the current
140     // selected org unit. One way of invoking this is via a template
141     // reference variable.
142     selectedOrg(): IdlObject {
143         if (this.selected == null) {
144             return null;
145         }
146         return this.org.get(this.selected.id);
147     }
148
149     selectedOrgId(): number {
150         return this.selected ? this.selected.id : null;
151     }
152
153     constructor(
154       private auth: AuthService,
155       private store: StoreService,
156       private serverStore: ServerStoreService,
157       private org: OrgService,
158       private perm: PermService
159     ) {
160         this.orgClassCallback = (orgId: number): string => '';
161     }
162
163     ngOnInit() {
164
165
166         let promise = this.persistKey ?
167             this.getFromSetting() : Promise.resolve(null);
168
169         promise = promise.then(startupOrg => {
170             return this.serverStore.getItem('eg.orgselect.show_combined_names')
171             .then(show => {
172                 const sortField = show ? 'name' : this.displayField;
173
174                 // Sort the tree and reabsorb to propagate the sorted
175                 // nodes to the org.list() used by this component.
176                 // Maintain our own copy of the org list in case the
177                 // org service is sorted in a different manner by other
178                 // parts of the code.
179                 this.org.sortTree(sortField);
180                 this.org.absorbTree();
181                 this.sortedOrgs = this.org.list();
182
183                 this.showCombinedNames = show;
184             })
185             .then(_ => startupOrg);
186         });
187
188         promise.then((startupOrgId: number) => {
189
190             if (!startupOrgId) {
191
192                 if (this.selected) {
193                     // A value may have been applied while we were
194                     // talking to the network.
195                     startupOrgId = this.selected.id;
196
197                 } else if (this.initialOrg) {
198                     startupOrgId = this.initialOrg.id();
199
200                 } else if (this.initialOrgId) {
201                     startupOrgId = this.initialOrgId;
202
203                 } else if (this.fallbackOrgId) {
204                     startupOrgId = this.fallbackOrgId;
205
206                 } else if (this.fallbackOrg) {
207                     startupOrgId = this.org.get(this.fallbackOrg).id();
208
209                 } else if (this.applyDefault && this.auth.user()) {
210                     startupOrgId = this.auth.user().ws_ou();
211                 }
212             }
213
214             let startupOrg;
215             if (startupOrgId) {
216                 startupOrg = this.org.get(startupOrgId);
217                 this.selected = this.formatForDisplay(startupOrg);
218             }
219
220             this.markAsLoaded(startupOrg);
221         });
222     }
223
224     getDisplayLabel(org: IdlObject): string {
225         if (this.showCombinedNames) {
226             return `${org.name()} (${org.shortname()})`;
227         } else {
228             return org[this.displayField]();
229         }
230     }
231
232     getFromSetting(): Promise<number> {
233
234         const key = `eg.orgselect.${this.persistKey}`;
235
236         return this.serverStore.getItem(key).then(
237             value => this.valueFromSetting = value
238         );
239     }
240
241     // Indicate all load-time shuffling has completed.
242     markAsLoaded(onChangeOrg?: IdlObject) {
243         setTimeout(() => { // Avoid emitting mid-digest
244             this.componentLoaded.emit();
245             this.componentLoaded.complete();
246             if (onChangeOrg) { this.onChange.emit(onChangeOrg); }
247         });
248     }
249
250     //
251     applyPermLimitOrgs(perms: string[]) {
252
253         if (!perms) {
254             return;
255         }
256
257         // handle lazy clients that pass null perm names
258         perms = perms.filter(p => p !== null && p !== undefined);
259
260         if (perms.length === 0) {
261             return;
262         }
263
264         // NOTE: If permLimitOrgs is useful in a non-staff context
265         // we need to change this to support non-staff perm checks.
266         this.perm.hasWorkPermAt(perms, true).then(permMap => {
267             this.permLimitOrgs =
268                 // safari-friendly version of Array.flat()
269                 Object.values(permMap).reduce((acc, val) => acc.concat(val), []);
270         });
271     }
272
273     // Format for display in the selector drop-down and input.
274     formatForDisplay(org: IdlObject): OrgDisplay {
275         let label = this.getDisplayLabel(org);
276         if (!this.readOnly) {
277             label = PAD_SPACE.repeat(org.ou_type().depth()) + label;
278         }
279         return {
280             id : org.id(),
281             label : label,
282             disabled : this.disableOrgs.includes(org.id())
283         };
284     }
285
286     // Fired by the typeahead to inform us of a change.
287     // TODO: this does not fire when the value is cleared :( -- implement
288     // change detection on this.selected to look specifically for NULL.
289     orgChanged(selEvent: NgbTypeaheadSelectItemEvent) {
290         // console.debug('org unit change occurred ' + selEvent.item);
291         this.onChange.emit(this.org.get(selEvent.item.id));
292
293         if (this.persistKey && this.valueFromSetting !== selEvent.item.id) {
294             // persistKey is active.  Update the persisted value when changed.
295
296             const key = `eg.orgselect.${this.persistKey}`;
297             this.valueFromSetting = selEvent.item.id;
298             this.serverStore.setItem(key, this.valueFromSetting);
299         }
300     }
301
302     // Remove the tree-padding spaces when matching.
303     formatter = (result: OrgDisplay) => result ? result.label.trim() : '';
304
305     // reset the state of the component
306     reset() {
307         this.selected = null;
308     }
309
310     // NgbTypeahead doesn't offer a way to style the dropdown
311     // button directly, so we have to reach up and style it ourselves.
312     applyDisableStyle() {
313         this.disableOrgs.forEach(id => {
314             const node = document.getElementById(`${this.domId}-${id}`);
315             if (node) {
316                 const button = node.parentNode as HTMLElement;
317                 button.classList.add('disabled');
318             }
319         });
320     }
321
322     filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
323
324         return text$.pipe(
325             debounceTime(200),
326             distinctUntilChanged(),
327             merge(
328                 // Inject a specifier indicating the source of the
329                 // action is a user click
330                 this.click$.pipe(filter(() => !this.instance.isPopupOpen()))
331                 .pipe(mapTo('_CLICK_'))
332             ),
333             map(term => {
334
335                 let orgs = this.sortedOrgs.filter(org =>
336                     this.hidden.filter(id => org.id() === id).length === 0
337                 );
338
339                 if (this.permLimitOrgs) {
340                     // Avoid showing org units where the user does
341                     // not have the requested permission.
342                     orgs = orgs.filter(org =>
343                         this.permLimitOrgs.includes(org.id()));
344                 }
345
346                 if (term !== '_CLICK_') {
347                     // For search-driven events, limit to the matching
348                     // org units.
349                     orgs = orgs.filter(org => {
350                         return term === '' || // show all
351                             this.getDisplayLabel(org)
352                                 .toLowerCase().indexOf(term.toLowerCase()) > -1;
353
354                     });
355                 }
356
357                 // Give the typeahead a chance to open before applying
358                 // the disabled org unit styling.
359                 setTimeout(() => this.applyDisableStyle());
360
361                 return orgs.map(org => this.formatForDisplay(org));
362             })
363         );
364     }
365 }
366
367