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 showCombinedNames = false; // Managed via user/workstation setting
46 _selected: OrgDisplay;
47 set selected(s: OrgDisplay) {
48 if (s !== this._selected) {
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);
60 get selected(): OrgDisplay {
61 return this._selected;
64 click$ = new Subject<string>();
65 valueFromSetting: number = null;
66 sortedOrgs: IdlObject[] = [];
68 // Disable the entire input
69 @Input() disabled: boolean;
71 @ViewChild('instance', { static: false }) instance: NgbTypeahead;
73 // Placeholder text for selector input
74 @Input() placeholder = '';
76 // ARIA label for selector. Required if there is no <label> in the markup.
77 @Input() ariaLabel?: string;
79 // ID to display in the DOM for this selector
80 @Input() domId = 'eg-org-select-' + OrgSelectComponent.domId++;
84 // Org unit field displayed in the selector
85 @Input() displayField = 'shortname';
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;
93 @Input() readOnly = false;
95 @Input() required = false;
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; }
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; }
109 get disableOrgs(): number[] {
110 return this._disabledOrgs;
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;
118 // Value is persisted via server setting with this key.
119 // Key is prepended with 'eg.orgselect.'
120 @Input() persistKey: string;
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;
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());
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());
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);
149 // Function which should return a string value representing
150 // a CSS class name to use for styling each org unit label
152 @Input() orgClassCallback: (orgId: number) => string;
154 // Emitted when the org unit value is changed via the selector.
155 // Does not fire on initialOrg
156 @Output() onChange = new EventEmitter<IdlObject>();
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>();
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) {
173 return this.org.get(this.selected.id);
176 selectedOrgId(): number {
177 return this.selected ? this.selected.id : null;
181 private auth: AuthService,
182 private store: StoreService,
183 private serverStore: ServerStoreService,
184 private org: OrgService,
185 private perm: PermService
187 this.orgClassCallback = (orgId: number): string => '';
193 let promise = this.persistKey ?
194 this.getFromSetting() : Promise.resolve(null);
196 promise = promise.then(startupOrg => {
197 return this.serverStore.getItem('eg.orgselect.show_combined_names')
199 const sortField = show ? 'name' : this.displayField;
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();
210 this.showCombinedNames = show;
212 .then(_ => startupOrg);
215 promise.then((startupOrgId: number) => {
220 // A value may have been applied while we were
221 // talking to the network.
222 startupOrgId = this.selected.id;
224 } else if (this.initialOrg) {
225 startupOrgId = this.initialOrg.id();
227 } else if (this.initialOrgId) {
228 startupOrgId = this.initialOrgId;
230 } else if (this.fallbackOrgId) {
231 startupOrgId = this.fallbackOrgId;
233 } else if (this.fallbackOrg) {
234 startupOrgId = this.org.get(this.fallbackOrg).id();
236 } else if (this.applyDefault && this.auth.user()) {
237 startupOrgId = this.auth.user().ws_ou();
243 startupOrg = this.org.get(startupOrgId);
244 this.selected = this.formatForDisplay(startupOrg);
247 this.markAsLoaded(startupOrg);
251 getDisplayLabel(org: IdlObject): string {
252 if (this.showCombinedNames) {
253 return `${org.name()} (${org.shortname()})`;
255 return org[this.displayField]();
259 getFromSetting(): Promise<number> {
261 const key = `eg.orgselect.${this.persistKey}`;
263 return this.serverStore.getItem(key).then(
264 value => this.valueFromSetting = value
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); }
278 applyPermLimitOrgs(perms: string[]) {
284 // handle lazy clients that pass null perm names
285 perms = perms.filter(p => p !== null && p !== undefined);
287 if (perms.length === 0) {
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 => {
295 // safari-friendly version of Array.flat()
296 Object.values(permMap).reduce((acc, val) => acc.concat(val), []);
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;
309 disabled : this.disableOrgs.includes(org.id())
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));
319 if (this.persistKey && this.valueFromSetting !== selEvent.item.id) {
320 // persistKey is active. Update the persisted value when changed.
322 const key = `eg.orgselect.${this.persistKey}`;
323 this.valueFromSetting = selEvent.item.id;
324 this.serverStore.setItem(key, this.valueFromSetting);
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');
337 node.classList.replace('ng-valid', 'ng-invalid');
342 isValidOrg(org: any) : boolean {
343 if (!org) { return false; }
345 if (this.disableOrgs.includes(org)) { return false; }
350 // Remove the tree-padding spaces when matching.
351 formatter = (result: OrgDisplay) => result ? result.label.trim() : '';
353 // reset the state of the component
355 this.selected = null;
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}`);
364 const button = node.parentNode as HTMLElement;
365 button.classList.add('disabled');
370 // Free-text values are not allowed.
372 if (typeof this.selected === 'string') {
373 this.selected = null;
377 filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
380 // eslint-disable-next-line no-magic-numbers
382 distinctUntilChanged(),
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_'))
391 let orgs = this.sortedOrgs.filter(org =>
392 this.hidden.filter(id => org.id() === id).length === 0
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()));
402 if (term !== '_CLICK_') {
403 // For search-driven events, limit to the matching
405 orgs = orgs.filter(org => {
406 return term === '' || // show all
407 this.getDisplayLabel(org)
408 .toLowerCase().indexOf(term.toLowerCase()) > -1;
413 // Give the typeahead a chance to open before applying
414 // the disabled org unit styling.
415 setTimeout(() => this.applyDisableStyle());
417 return orgs.map(org => this.formatForDisplay(org));