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