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