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';
15 * The following precedence is used when applying a load-time value
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).
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.
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
31 interface OrgDisplay {
38 selector: 'eg-org-select',
39 templateUrl: './org-select.component.html'
41 export class OrgSelectComponent implements OnInit {
44 click$ = new Subject<string>();
45 valueFromSetting: number = null;
46 sortedOrgs: IdlObject[] = [];
48 // Disable the entire input
49 @Input() disabled: boolean;
51 @ViewChild('instance', { static: false }) instance: NgbTypeahead;
53 // Placeholder text for selector input
54 @Input() placeholder = '';
56 // ID to display in the DOM for this selector
59 // Org unit field displayed in the selector
60 @Input() displayField = 'shortname';
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;
68 @Input() readOnly = false;
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; }
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; }
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;
87 // Value is persisted via server setting with this key.
88 // Key is prepended with 'eg.orgselect.'
89 @Input() persistKey: string;
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;
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) {
101 this.selected = this.formatForDisplay(org);
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) {
109 this.selected = this.formatForDisplay(this.org.get(id));
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);
120 // Emitted when the org unit value is changed via the selector.
121 // Does not fire on initialOrg
122 @Output() onChange = new EventEmitter<IdlObject>();
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>();
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) {
138 return this.org.get(this.selected.id);
142 private auth: AuthService,
143 private store: StoreService,
144 private serverStore: ServerStoreService,
145 private org: OrgService,
146 private perm: PermService
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();
159 if (this.initialOrg || this.initialOrgId) {
160 this.selected = this.formatForDisplay(
161 this.initialOrg || this.org.get(this.initialOrgId)
168 const promise = this.persistKey ?
169 this.getFromSetting() : Promise.resolve(null);
171 promise.then((startupOrgId: number) => {
175 if (this.fallbackOrgId) {
176 startupOrgId = this.fallbackOrgId;
178 } else if (this.fallbackOrg) {
179 startupOrgId = this.org.get(this.fallbackOrg).id();
181 } else if (this.applyDefault && this.auth.user()) {
182 startupOrgId = this.auth.user().ws_ou();
188 startupOrg = this.org.get(startupOrgId);
189 this.selected = this.formatForDisplay(startupOrg);
192 this.markAsLoaded(startupOrg);
196 getFromSetting(): Promise<number> {
198 const key = `eg.orgselect.${this.persistKey}`;
200 return this.serverStore.getItem(key).then(
201 value => this.valueFromSetting = value
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); }
215 applyPermLimitOrgs(perms: string[]) {
221 // handle lazy clients that pass null perm names
222 perms = perms.filter(p => p !== null && p !== undefined);
224 if (perms.length === 0) {
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 => {
232 // safari-friendly version of Array.flat()
233 Object.values(permMap).reduce((acc, val) => acc.concat(val), []);
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;
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));
257 if (this.persistKey && this.valueFromSetting !== selEvent.item.id) {
258 // persistKey is active. Update the persisted value when changed.
260 const key = `eg.orgselect.${this.persistKey}`;
261 this.valueFromSetting = selEvent.item.id;
262 this.serverStore.setItem(key, this.valueFromSetting);
266 // Remove the tree-padding spaces when matching.
267 formatter = (result: OrgDisplay) => result ? result.label.trim() : '';
269 // reset the state of the component
271 this.selected = null;
274 filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
277 distinctUntilChanged(),
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_'))
286 let orgs = this.sortedOrgs.filter(org =>
287 this.hidden.filter(id => org.id() === id).length === 0
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()));
297 if (term !== '_CLICK_') {
298 // For search-driven events, limit to the matching
300 orgs = orgs.filter(org => {
301 return term === '' || // show all
302 org[this.displayField]()
303 .toLowerCase().indexOf(term.toLowerCase()) > -1;
308 return orgs.map(org => this.formatForDisplay(org));