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