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