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