]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.ts
LP 2061136 follow-up: ng lint --fix
[Evergreen.git] / Open-ILS / src / eg2 / src / app / share / item-location-select / item-location-select.component.ts
1 import {Component, OnInit, AfterViewInit, Input, Output, ViewChild,
2     EventEmitter, forwardRef} from '@angular/core';
3 import {ControlValueAccessor, FormGroup, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms';
4 import {Observable, from, of} from 'rxjs';
5 import {tap, map, switchMap} from 'rxjs/operators';
6 import {IdlObject} from '@eg/core/idl.service';
7 import {OrgService} from '@eg/core/org.service';
8 import {AuthService} from '@eg/core/auth.service';
9 import {PermService} from '@eg/core/perm.service';
10 import {PcrudService} from '@eg/core/pcrud.service';
11 import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
12 import {StringComponent} from '@eg/share/string/string.component';
13 import {ItemLocationService} from './item-location-select.service';
14
15 /**
16  * Item (Copy) Location Selector.
17  *
18  * <eg-item-location-select [(ngModel)]="myAcplId"
19     [contextOrgId]="anOrgId" permFilter="ADMIN_STUFF">
20  * </eg-item-location-select>
21  */
22
23 @Component({
24     selector: 'eg-item-location-select',
25     templateUrl: './item-location-select.component.html',
26     providers: [{
27         provide: NG_VALUE_ACCESSOR,
28         useExisting: forwardRef(() => ItemLocationSelectComponent),
29         multi: true
30     }]
31 })
32 export class ItemLocationSelectComponent
33 implements OnInit, AfterViewInit, ControlValueAccessor {
34     static domIdAuto = 0;
35
36     // Limit copy locations to those owned at or above org units where
37     // the user has work permissions for the provided permission code.
38     @Input() permFilter: string;
39
40     // Limit copy locations to those owned at or above this org unit.
41     private _contextOrgId: number;
42     @Input() set contextOrgId(value: number) {
43         this._contextOrgId = value;
44         this.ngOnInit();
45     }
46
47     // ... though if includeDescendants is true, shelving
48     // locations at the descendants of the context OU are
49     // also included; this is a special case for the
50     // carousels editor
51     @Input() set includeDescendants(value: boolean) {
52         this._includeDescendants = value;
53         this.ngOnInit();
54     }
55     get includeDescendants(): boolean {
56         return this._includeDescendants;
57     }
58
59     get contextOrgId(): number {
60         return this._contextOrgId;
61     }
62
63     // Load locations for multiple context org units.
64     private _contextOrgIds = [];
65     private _includeDescendants = false;
66     @Input() set contextOrgIds(value: number[]) {
67         this._contextOrgIds = value;
68     }
69
70     get contextOrgIds(): number[] {
71         return this._contextOrgIds;
72     }
73
74     @Input() orgUnitLabelField = 'shortname';
75
76     // Emits an acpl object or null on combobox value change
77     @Output() valueChange: EventEmitter<IdlObject>;
78     // Emits the combobox entry or null on value change
79     @Output() entryChange: EventEmitter<ComboboxEntry>;
80
81     @Input() required: boolean;
82
83     @Input() domId = 'eg-item-location-select-' +
84         ItemLocationSelectComponent.domIdAuto++;
85
86     // If false, selector will be click-able
87     @Input() loadAsync = false;
88
89     @Input() disabled = false;
90
91     // Display the selected value as text instead of within
92     // the typeahead
93     @Input() readOnly = false;
94
95     // See combobox
96     @Input() startsWith = false;
97
98     // Show <Unset> when no value is applied.
99     // This only applies to non-required fields, since <Unset> would
100     // trick the combobox into thinking a valid value had been applied
101     @Input() showUnsetString = true;
102
103     @ViewChild('comboBox', {static: false}) comboBox: ComboboxComponent;
104     @ViewChild('unsetString', {static: false}) unsetString: StringComponent;
105
106     @Input() startId: number = null;
107     filterOrgs: number[] = [];
108     filterOrgsApplied = false;
109
110     initDone = false; // true after first data load
111     propagateChange = (id: number) => {};
112     propagateTouch = () => {};
113
114     getLocationsAsyncHandler = term => this.getLocationsAsync(term);
115
116     constructor(
117         private org: OrgService,
118         private auth: AuthService,
119         private perm: PermService,
120         private pcrud: PcrudService,
121         private loc: ItemLocationService
122     ) {
123         this.valueChange = new EventEmitter<IdlObject>();
124         this.entryChange = new EventEmitter<ComboboxEntry>();
125     }
126
127     ngOnInit() {
128         if (this.loadAsync) {
129             this.initDone = true;
130         } else {
131             this.setFilterOrgs()
132                 .then(_ => this.getLocations())
133                 .then(_ => this.initDone = true);
134         }
135     }
136
137     ngAfterViewInit() {
138
139         // Format the display of locations to include the org unit
140         this.comboBox.formatDisplayString = (result: ComboboxEntry) => {
141             let display = result.label || result.id;
142             display = (display + '').trim();
143             if (result.userdata) {
144                 display += ' (' +
145                     this.orgName(result.userdata.owning_lib()) + ')';
146             }
147             return display;
148         };
149     }
150
151     getLocations(): Promise<any> {
152
153         if (this.filterOrgs.length === 0) {
154             this.comboBox.entries = [];
155             return Promise.resolve();
156         }
157
158         const search: any = {deleted: 'f'};
159
160         if (this.startId) {
161             // Guarantee we have the load-time copy location, which
162             // may not be included in the org-scoped set of locations
163             // we fetch by default.
164             search['-or'] = [
165                 {id: this.startId},
166                 {owning_lib: this.filterOrgs}
167             ];
168         } else {
169             search.owning_lib = this.filterOrgs;
170         }
171
172         const entries: ComboboxEntry[] = [];
173
174         if (!this.required && this.showUnsetString) {
175             entries.push({id: null, label: this.unsetString.text});
176         }
177
178         return this.pcrud.search('acpl', search, {order_by: {acpl: 'name'}}
179         ).pipe(map(loc => {
180             this.loc.locationCache[loc.id()] = loc;
181             entries.push({id: loc.id(), label: loc.name(), userdata: loc});
182         })).toPromise().then(_ => {
183             this.comboBox.entries = entries;
184         });
185     }
186
187     getLocationsAsync(term: string): Observable<ComboboxEntry> {
188         // "1" is ignored, but a value is needed for pipe() below
189         let obs = of([1]);
190
191         if (!this.filterOrgsApplied) {
192             // Apply filter orgs the first time they are needed.
193             obs = from(this.setFilterOrgs());
194         }
195
196         return obs.pipe(switchMap(_ => this.getLocationsAsync2(term)));
197     }
198
199     getLocationsAsync2(term: string): Observable<ComboboxEntry> {
200
201         if (this.filterOrgs.length === 0) {
202             return of();
203         }
204
205         const ilike = this.startsWith ? `${term}%` : `%${term}%`;
206
207         const search: any = {
208             deleted: 'f',
209             name: {'ilike': ilike}
210         };
211
212         if (this.startId) {
213             // Guarantee we have the load-time copy location, which
214             // may not be included in the org-scoped set of locations
215             // we fetch by default.
216             search['-or'] = [
217                 {id: this.startId},
218                 {owning_lib: this.filterOrgs}
219             ];
220         } else {
221             search.owning_lib = this.filterOrgs;
222         }
223
224         return new Observable<ComboboxEntry>(observer => {
225             if (!this.required && this.showUnsetString) {
226                 observer.next({id: null, label: this.unsetString.text});
227             }
228
229             this.pcrud.search('acpl', search, {order_by: {acpl: 'name'}}
230             ).subscribe(
231                 loc => {
232                     this.loc.locationCache[loc.id()] = loc;
233                     observer.next({id: loc.id(), label: loc.name(), userdata: loc});
234                 },
235                 (err: unknown) => {},
236                 () => observer.complete()
237             );
238         });
239     }
240
241
242     registerOnChange(fn) {
243         this.propagateChange = fn;
244     }
245
246     registerOnTouched(fn) {
247         this.propagateTouch = fn;
248     }
249
250     cboxChanged(entry: ComboboxEntry) {
251         const id = entry ? entry.id : null;
252         this.propagateChange(id);
253         this.valueChange.emit(id ? this.loc.locationCache[id] : null);
254         this.entryChange.emit(entry ? entry : null);
255     }
256
257     writeValue(id: number) {
258         if (this.initDone) {
259             this.getOneLocation(id).then(_ => this.comboBox.selectedId = id);
260         } else {
261             this.startId = id;
262         }
263     }
264
265     getOneLocation(id: number) {
266         if (!id) { return Promise.resolve(); }
267
268         const promise = this.loc.locationCache[id] ?
269             Promise.resolve(this.loc.locationCache[id]) :
270             this.pcrud.retrieve('acpl', id).toPromise();
271
272         return promise.then(loc => {
273
274             this.loc.locationCache[loc.id()] = loc;
275             const entry: ComboboxEntry = {
276                 id: loc.id(), label: loc.name(), userdata: loc};
277
278             if (this.comboBox.entries) {
279                 this.comboBox.entries.push(entry);
280             } else {
281                 this.comboBox.entries = [entry];
282             }
283         });
284     }
285
286     setFilterOrgs(): Promise<number[]> {
287         let contextOrgIds: number[] = [];
288
289         if (this.contextOrgIds.length) {
290             contextOrgIds = this.contextOrgIds;
291         } else {
292             contextOrgIds = [this.contextOrgId || this.auth.user().ws_ou()];
293         }
294
295         let orgIds = [];
296         contextOrgIds.forEach(id => orgIds = orgIds.concat(this.org.ancestors(id, true)));
297         if (this.includeDescendants) {
298             contextOrgIds.forEach(id => orgIds = orgIds.concat(this.org.descendants(id, true)));
299         }
300
301         this.filterOrgsApplied = true;
302
303         if (!this.permFilter) {
304             return Promise.resolve(this.filterOrgs = [...new Set(orgIds)]);
305         }
306
307         const orgsFromCache = this.loc.filterOrgsCache[this.permFilter];
308         if (orgsFromCache && !this._contextOrgId) {
309             // if we're using contextOrgId, it may well change, so
310             // don't use the cache
311             return Promise.resolve(this.filterOrgs = orgsFromCache);
312         }
313
314         return this.perm.hasWorkPermAt([this.permFilter], true)
315             .then(values => {
316             // Include ancestors of perm-approved org units (shared item locations)
317
318                 const permOrgIds = values[this.permFilter];
319                 let trimmedOrgIds = [];
320                 permOrgIds.forEach(orgId => {
321                     if (orgIds.includes(orgId)) {
322                         trimmedOrgIds = trimmedOrgIds.concat(this.org.ancestors(orgId, true));
323                         if (this.includeDescendants) {
324                             trimmedOrgIds = trimmedOrgIds.concat(this.org.descendants(orgId, true));
325                         }
326                     }
327                 });
328
329                 this.filterOrgs = [...new Set(trimmedOrgIds)];
330                 this.loc.filterOrgsCache[this.permFilter] = this.filterOrgs;
331
332                 return this.filterOrgs;
333             });
334     }
335
336     orgName(orgId: number): string {
337         return this.org.get(orgId)[this.orgUnitLabelField]();
338     }
339 }
340
341
342