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