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