]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
LP#1904244: combobox: include org unit in label for funds
[Evergreen.git] / Open-ILS / src / eg2 / src / app / share / combobox / combobox.component.ts
1 /**
2  * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
3  *  <!-- see also <eg-combobox-entry> -->
4  * </eg-combobox>
5  */
6 import {Component, OnInit, Input, Output, ViewChild,
7     Directive, ViewChildren, QueryList, AfterViewInit,
8     OnChanges, SimpleChanges,
9     TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
10 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
11 import {Observable, of, Subject} from 'rxjs';
12 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
13 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
14 import {StoreService} from '@eg/core/store.service';
15 import {IdlService, IdlObject} from '@eg/core/idl.service';
16 import {PcrudService} from '@eg/core/pcrud.service';
17 import {OrgService} from '@eg/core/org.service';
18
19 export interface ComboboxEntry {
20   id: any;
21   // If no label is provided, the 'id' value is used.
22   label?: string;
23   freetext?: boolean;
24   userdata?: any; // opaque external value; ignored by this component.
25   fm?: IdlObject;
26 }
27
28 @Directive({
29     selector: 'ng-template[egIdlClass]'
30 })
31 export class IdlClassTemplateDirective {
32   @Input() egIdlClass: string;
33   constructor(public template: TemplateRef<any>) {}
34 }
35
36 @Component({
37   selector: 'eg-combobox',
38   templateUrl: './combobox.component.html',
39   styles: [`
40     .icons {margin-left:-18px}
41     .material-icons {font-size: 16px;font-weight:bold}
42   `],
43   providers: [{
44     provide: NG_VALUE_ACCESSOR,
45     useExisting: forwardRef(() => ComboboxComponent),
46     multi: true
47   }]
48 })
49 export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {
50
51     selected: ComboboxEntry;
52     click$: Subject<string>;
53     entrylist: ComboboxEntry[];
54
55     @ViewChild('instance', { static: true }) instance: NgbTypeahead;
56     @ViewChild('defaultDisplayTemplate', { static: true}) defaultDisplayTemplate: TemplateRef<any>;
57     @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
58
59     // Applies a name attribute to the input.
60     // Useful in forms.
61     @Input() name: string;
62
63     // Placeholder text for selector input
64     @Input() placeholder = '';
65
66     @Input() persistKey: string; // TODO
67
68     @Input() allowFreeText = false;
69
70     @Input() inputSize: number = null;
71
72     // Add a 'required' attribute to the input
73     isRequired: boolean;
74     @Input() set required(r: boolean) {
75         this.isRequired = r;
76     }
77
78     // Disable the input
79     isDisabled: boolean;
80     @Input() set disabled(d: boolean) {
81         this.isDisabled = d;
82     }
83
84     // Entry ID of the default entry to select (optional)
85     // onChange() is NOT fired when applying the default value,
86     // unless startIdFiresOnChange is set to true.
87     @Input() startId: any = null;
88     @Input() idlClass: string;
89     @Input() startIdFiresOnChange: boolean;
90
91     // Allow the selected entry ID to be passed via the template
92     // This does NOT not emit onChange events.
93     @Input() set selectedId(id: any) {
94         if (id === undefined) { return; }
95
96         // clear on explicit null
97         if (id === null) { this.selected = null; }
98
99         if (this.entrylist.length) {
100             this.selected = this.entrylist.filter(e => e.id === id)[0];
101         }
102
103         if (!this.selected) {
104             // It's possible the selected ID lives in a set of entries
105             // that are yet to be provided.
106             this.startId = id;
107             if (this.idlClass) {
108                 this.pcrud.retrieve(this.idlClass, id)
109                 .subscribe(rec => {
110                     this.entrylist = [{
111                         id: id,
112                         label: this.getFmRecordLabel(rec),
113                         fm: rec
114                     }];
115                     this.selected = this.entrylist.filter(e => e.id === id)[0];
116                 });
117             }
118         }
119     }
120
121     get selectedId(): any {
122         return this.selected ? this.selected.id : null;
123     }
124
125     @Input() idlField: string;
126     @Input() idlIncludeLibraryInLabel: string;
127     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
128
129     // If true, an async data search is allowed to fetch all
130     // values when given an empty term. This should be used only
131     // if the maximum number of entries returned by the data source
132     // is known to be no more than a couple hundred.
133     @Input() asyncSupportsEmptyTermClick: boolean;
134
135     // Useful for efficiently preventing duplicate async entries
136     asyncIds: {[idx: string]: boolean};
137
138     // True if a default selection has been made.
139     defaultSelectionApplied: boolean;
140
141     @Input() set entries(el: ComboboxEntry[]) {
142         if (el) {
143
144             if (this.entrylistMatches(el)) {
145                 // Avoid reprocessing data we already have.
146                 return;
147             }
148
149             this.entrylist = el;
150
151             // new set of entries essentially means a new instance. reset.
152             this.defaultSelectionApplied = false;
153             this.applySelection();
154
155             // It's possible to provide an entrylist at load time, but
156             // fetch all future data via async data source.  Track the
157             // values we already have so async lookup won't add them again.
158             // A new entry list wipes out any existing async values.
159             this.asyncIds = {};
160             el.forEach(entry => this.asyncIds['' + entry.id] = true);
161         }
162     }
163
164     // When provided use this as the display template for each entry.
165     @Input() displayTemplate: TemplateRef<any>;
166
167     // Emitted when the value is changed via UI.
168     // When the UI value is cleared, null is emitted.
169     @Output() onChange: EventEmitter<ComboboxEntry>;
170
171     // Useful for massaging the match string prior to comparison
172     // and display.  Default version trims leading/trailing spaces.
173     formatDisplayString: (e: ComboboxEntry) => string;
174
175     idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
176     getFmRecordLabel: (fm: IdlObject) => string;
177
178     // Stub functions required by ControlValueAccessor
179     propagateChange = (_: any) => {};
180     propagateTouch = () => {};
181
182     constructor(
183       private elm: ElementRef,
184       private store: StoreService,
185       private idl: IdlService,
186       private pcrud: PcrudService,
187       private org: OrgService,
188     ) {
189         this.entrylist = [];
190         this.asyncIds = {};
191         this.click$ = new Subject<string>();
192         this.onChange = new EventEmitter<ComboboxEntry>();
193         this.defaultSelectionApplied = false;
194
195         this.formatDisplayString = (result: ComboboxEntry) => {
196             const display = result.label || result.id;
197             return (display + '').trim();
198         };
199
200         this.getFmRecordLabel = (fm: IdlObject) => {
201             // FIXME: it would be cleaner if we could somehow use
202             // the per-IDL-class ng-templates directly
203             switch (this.idlClass) {
204                 case 'acmc':
205                     return fm.course_number() + ': ' + fm.name();
206                     break;
207                 case 'acqf':
208                     return fm.code() + ' (' + fm.year() + ')' +
209                            ' (' + this.getOrgShortname(fm.org()) + ')';
210                     break;
211                 case 'acpl':
212                     return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
213                     break;
214                 default:
215                     const field = this.idlField;
216                     if (this.idlIncludeLibraryInLabel) {
217                         return fm[field]() + ' (' + fm[this.idlIncludeLibraryInLabel]().shortname() + ')';
218                     } else {
219                         return fm[field]();
220                     }
221             }
222         };
223     }
224
225     ngOnInit() {
226         if (this.idlClass) {
227             const classDef = this.idl.classes[this.idlClass];
228             const pkeyField = classDef.pkey;
229
230             if (!pkeyField) {
231                 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
232             }
233
234             if (!this.idlField) {
235                 this.idlField = this.idl.getClassSelector(this.idlClass);
236             }
237
238             this.asyncDataSource = term => {
239                 const field = this.idlField;
240                 const args = {};
241                 const extra_args = { order_by : {} };
242                 args[field] = {'ilike': `%${term}%`}; // could -or search on label
243                 extra_args['order_by'][this.idlClass] = field;
244                 extra_args['limit'] = 100;
245                 if (this.idlIncludeLibraryInLabel) {
246                     extra_args['flesh'] = 1;
247                     const flesh_fields: Object = {};
248                     flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
249                     extra_args['flesh_fields'] = flesh_fields;
250                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
251                         return {
252                             id: data[pkeyField](),
253                             label: this.getFmRecordLabel(data),
254                             fm: data
255                         };
256                     }));
257                 } else {
258                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
259                         return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
260                     }));
261                 }
262             };
263         }
264     }
265
266     ngAfterViewInit() {
267         this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
268             acc[cur.egIdlClass] = cur.template;
269             return acc;
270         }, {});
271     }
272
273     ngOnChanges(changes: SimpleChanges) {
274         let firstTime = true;
275         Object.keys(changes).forEach(key => {
276             if (!changes[key].firstChange) {
277                 firstTime = false;
278             }
279         });
280         if (!firstTime) {
281             if ('selectedId' in changes) {
282                 if (!changes.selectedId.currentValue) {
283                     this.selected = null;
284                 }
285             }
286             if ('idlClass' in changes) {
287                 if (!('idlField' in changes)) {
288                     // let ngOnInit reset it to the
289                     // selector of the new IDL class
290                     this.idlField = null;
291                 }
292                 this.asyncIds = {};
293                 this.entrylist.length = 0;
294                 this.selected = null;
295                 this.ngOnInit();
296             }
297         }
298     }
299
300     onClick($event) {
301         this.click$.next($event.target.value);
302     }
303
304     getResultTemplate(): TemplateRef<any> {
305         if (this.displayTemplate) {
306             return this.displayTemplate;
307         }
308         if (this.idlClass in this.idlDisplayTemplateMap) {
309             return this.idlDisplayTemplateMap[this.idlClass];
310         }
311         return this.defaultDisplayTemplate;
312     }
313
314     getOrgShortname(ou: any) {
315         if (typeof ou === 'object') {
316             return ou.shortname();
317         } else {
318             return this.org.get(ou).shortname();
319         }
320     }
321
322     openMe($event) {
323         // Give the input a chance to focus then fire the click
324         // handler to force open the typeahead
325         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
326         setTimeout(() => this.click$.next(''));
327     }
328
329     // Returns true if the 2 entries are equivalent.
330     entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
331         return (
332             e1 && e2 &&
333             e1.id === e2.id &&
334             e1.label === e2.label &&
335             e1.freetext === e2.freetext
336         );
337     }
338
339     // Returns true if the 2 lists are equivalent.
340     entrylistMatches(el: ComboboxEntry[]): boolean {
341         if (el.length === 0 && this.entrylist.length === 0) {
342             // Empty arrays are only equivalent if they are the same array,
343             // since the caller may provide an array that starts empty, but
344             // is later populated.
345             return el === this.entrylist;
346         }
347         if (el.length !== this.entrylist.length) {
348             return false;
349         }
350         for (let i = 0; i < el.length; i++) {
351             const mine = this.entrylist[i];
352             if (!mine || !this.entriesMatch(mine, el[i])) {
353                 return false;
354             }
355         }
356         return true;
357     }
358
359     // Apply a default selection where needed
360     applySelection() {
361
362         if (this.startId !== null &&
363             this.entrylist && !this.defaultSelectionApplied) {
364
365             const entry =
366                 this.entrylist.filter(e => e.id === this.startId)[0];
367
368             if (entry) {
369                 this.selected = entry;
370                 this.defaultSelectionApplied = true;
371                 if (this.startIdFiresOnChange) {
372                     this.selectorChanged(
373                         {item: this.selected, preventDefault: () => true});
374                 }
375             }
376         }
377     }
378
379     // Called by combobox-entry.component
380     addEntry(entry: ComboboxEntry) {
381         this.entrylist.push(entry);
382         this.applySelection();
383     }
384
385     // Manually set the selected value by ID.
386     // This does NOT fire the onChange handler.
387     // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
388     applyEntryId(entryId: any) {
389         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
390     }
391
392     addAsyncEntry(entry: ComboboxEntry) {
393         // Avoid duplicate async entries
394         if (!this.asyncIds['' + entry.id]) {
395             this.asyncIds['' + entry.id] = true;
396             this.addEntry(entry);
397         }
398     }
399
400     hasEntry(entryId: any): boolean {
401         return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
402     }
403
404     onBlur() {
405         // When the selected value is a string it means we have either
406         // no value (user cleared the input) or a free-text value.
407
408         if (typeof this.selected === 'string') {
409
410             if (this.allowFreeText && this.selected !== '') {
411                 // Free text entered which does not match a known entry
412                 // translate it into a dummy ComboboxEntry
413                 this.selected = {
414                     id: null,
415                     label: this.selected,
416                     freetext: true
417                 };
418
419             } else {
420
421                 this.selected = null;
422             }
423
424             // Manually fire the onchange since NgbTypeahead fails
425             // to fire the onchange when the value is cleared.
426             this.selectorChanged(
427                 {item: this.selected, preventDefault: () => true});
428         }
429         this.propagateTouch();
430     }
431
432     // Fired by the typeahead to inform us of a change.
433     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
434         this.onChange.emit(selEvent.item);
435         this.propagateChange(selEvent.item);
436     }
437
438     // Adds matching async entries to the entry list
439     // and propagates the search term for pipelining.
440     addAsyncEntries(term: string): Observable<string> {
441
442         if (!term || !this.asyncDataSource) {
443             return of(term);
444         }
445
446         let searchTerm: string;
447         searchTerm = term;
448         if (searchTerm === '_CLICK_') {
449             if (this.asyncSupportsEmptyTermClick) {
450                 searchTerm = '';
451             } else {
452                 return of();
453             }
454         }
455
456         return new Observable(observer => {
457             this.asyncDataSource(searchTerm).subscribe(
458                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
459                 err => {},
460                 ()  => {
461                     observer.next(searchTerm);
462                     observer.complete();
463                 }
464             );
465         });
466     }
467
468     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
469         return text$.pipe(
470             debounceTime(200),
471             distinctUntilChanged(),
472
473             // Merge click actions in with the stream of text entry
474             merge(
475                 // Inject a specifier indicating the source of the
476                 // action is a user click instead of a text entry.
477                 // This tells the filter to show all values in sync mode.
478                 this.click$.pipe(filter(() =>
479                     !this.instance.isPopupOpen()
480                 )).pipe(mapTo('_CLICK_'))
481             ),
482
483             // mergeMap coalesces an observable into our stream.
484             mergeMap(term => this.addAsyncEntries(term)),
485             map((term: string) => {
486
487                 // Display no values when the input is empty and no
488                 // click action occurred.
489                 if (term === '') { return []; }
490
491                 // In sync-data mode, a click displays the full list.
492                 if (term === '_CLICK_' && !this.asyncDataSource) {
493                     return this.entrylist;
494                 }
495
496                 // Filter entrylist whose labels substring-match the
497                 // text entered.
498                 return this.entrylist.filter(entry => {
499                     const label = entry.label || entry.id;
500                     return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
501                 });
502             })
503         );
504     }
505
506     writeValue(value: ComboboxEntry) {
507         if (value !== undefined && value !== null) {
508             this.startId = value.id;
509             this.applySelection();
510         }
511     }
512
513     registerOnChange(fn) {
514         this.propagateChange = fn;
515     }
516
517     registerOnTouched(fn) {
518         this.propagateTouch = fn;
519     }
520
521 }
522
523