LP1831390 ControlValueAccessor continued
[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, 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 function required by ControlValueAccessor
111     propagateChange = (_: any) => {};
112
113     constructor(
114       private elm: ElementRef,
115       private store: StoreService,
116       private idl: IdlService,
117       private pcrud: PcrudService,
118     ) {
119         this.entrylist = [];
120         this.asyncIds = {};
121         this.click$ = new Subject<string>();
122         this.onChange = new EventEmitter<ComboboxEntry>();
123         this.defaultSelectionApplied = false;
124
125         this.formatDisplayString = (result: ComboboxEntry) => {
126             const display = result.label || result.id;
127             return (display + '').trim();
128         };
129     }
130
131     ngOnInit() {
132         if (this.idlClass) {
133             const classDef = this.idl.classes[this.idlClass];
134             const pkeyField = classDef.pkey;
135
136             if (!pkeyField) {
137                 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
138             }
139
140             if (!this.idlField) {
141                 this.idlField = classDef.field_map[classDef.pkey].selector || 'name';
142             }
143
144             this.asyncDataSource = term => {
145                 const field = this.idlField;
146                 const args = {};
147                 const extra_args = { order_by : {} };
148                 args[field] = {'ilike': `%${term}%`}; // could -or search on label
149                 extra_args['order_by'][this.idlClass] = field;
150                 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
151                     return {id: data[pkeyField](), label: data[field]()};
152                 }));
153             };
154         }
155     }
156
157     onClick($event) {
158         this.registerOnTouched();
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.registerOnTouched();
166         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
167         setTimeout(() => this.click$.next(''));
168     }
169
170     // Apply a default selection where needed
171     applySelection() {
172
173         if (this.startId &&
174             this.entrylist && !this.defaultSelectionApplied) {
175
176             const entry =
177                 this.entrylist.filter(e => e.id === this.startId)[0];
178
179             if (entry) {
180                 this.selected = entry;
181                 this.defaultSelectionApplied = true;
182                 if (this.startIdFiresOnChange) {
183                     this.selectorChanged(
184                         {item: this.selected, preventDefault: () => true});
185                 }
186             }
187         }
188     }
189
190     // Called by combobox-entry.component
191     addEntry(entry: ComboboxEntry) {
192         this.entrylist.push(entry);
193         this.applySelection();
194     }
195
196     // Manually set the selected value by ID.
197     // This does NOT fire the onChange handler.
198     applyEntryId(entryId: any) {
199         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
200     }
201
202     addAsyncEntry(entry: ComboboxEntry) {
203         // Avoid duplicate async entries
204         if (!this.asyncIds['' + entry.id]) {
205             this.asyncIds['' + entry.id] = true;
206             this.addEntry(entry);
207         }
208     }
209
210     onBlur() {
211         // When the selected value is a string it means we have either
212         // no value (user cleared the input) or a free-text value.
213
214         if (typeof this.selected === 'string') {
215
216             if (this.allowFreeText && this.selected !== '') {
217                 // Free text entered which does not match a known entry
218                 // translate it into a dummy ComboboxEntry
219                 this.selected = {
220                     id: null,
221                     label: this.selected,
222                     freetext: true
223                 };
224
225             } else {
226
227                 this.selected = null;
228             }
229
230             // Manually fire the onchange since NgbTypeahead fails
231             // to fire the onchange when the value is cleared.
232             this.selectorChanged(
233                 {item: this.selected, preventDefault: () => true});
234         }
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() { }
317
318 }
319
320