b4c85bf5e2bb0fa6411a92e203e34d83c111f318
[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     constructor(
111       private elm: ElementRef,
112       private store: StoreService,
113       private idl: IdlService,
114       private pcrud: PcrudService,
115     ) {
116         this.entrylist = [];
117         this.asyncIds = {};
118         this.click$ = new Subject<string>();
119         this.onChange = new EventEmitter<ComboboxEntry>();
120         this.defaultSelectionApplied = false;
121
122         this.formatDisplayString = (result: ComboboxEntry) => {
123             const display = result.label || result.id;
124             return (display + '').trim();
125         };
126     }
127
128     ngOnInit() {
129         if (this.idlClass) {
130             const classDef = this.idl.classes[this.idlClass];
131             const pkeyField = classDef.pkey;
132
133             if (!pkeyField) {
134                 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
135             }
136
137             if (!this.idlField) {
138                 this.idlField = classDef.field_map[classDef.pkey].selector || 'name';
139             }
140
141             this.asyncDataSource = term => {
142                 const field = this.idlField;
143                 const args = {};
144                 const extra_args = { order_by : {} };
145                 args[field] = {'ilike': `%${term}%`}; // could -or search on label
146                 extra_args['order_by'][this.idlClass] = field;
147                 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
148                     return {id: data[pkeyField](), label: data[field]()};
149                 }));
150             };
151         }
152     }
153
154     onClick($event) {
155         this.registerOnTouched();
156         this.click$.next($event.target.value)
157     }
158
159     openMe($event) {
160         // Give the input a chance to focus then fire the click
161         // handler to force open the typeahead
162         this.registerOnTouched();
163         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
164         setTimeout(() => this.click$.next(''));
165     }
166
167     // Apply a default selection where needed
168     applySelection() {
169
170         if (this.startId &&
171             this.entrylist && !this.defaultSelectionApplied) {
172
173             const entry =
174                 this.entrylist.filter(e => e.id === this.startId)[0];
175
176             if (entry) {
177                 this.selected = entry;
178                 this.defaultSelectionApplied = true;
179                 if (this.startIdFiresOnChange) {
180                     this.selectorChanged(
181                         {item: this.selected, preventDefault: () => true});
182                 }
183             }
184         }
185     }
186
187     // Called by combobox-entry.component
188     addEntry(entry: ComboboxEntry) {
189         this.entrylist.push(entry);
190         this.applySelection();
191     }
192
193     // Manually set the selected value by ID.
194     // This does NOT fire the onChange handler.
195     applyEntryId(entryId: any) {
196         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
197     }
198
199     addAsyncEntry(entry: ComboboxEntry) {
200         // Avoid duplicate async entries
201         if (!this.asyncIds['' + entry.id]) {
202             this.asyncIds['' + entry.id] = true;
203             this.addEntry(entry);
204         }
205     }
206
207     onBlur() {
208         // When the selected value is a string it means we have either
209         // no value (user cleared the input) or a free-text value.
210
211         if (typeof this.selected === 'string') {
212
213             if (this.allowFreeText && this.selected !== '') {
214                 // Free text entered which does not match a known entry
215                 // translate it into a dummy ComboboxEntry
216                 this.selected = {
217                     id: null,
218                     label: this.selected,
219                     freetext: true
220                 };
221
222             } else {
223
224                 this.selected = null;
225             }
226
227             // Manually fire the onchange since NgbTypeahead fails
228             // to fire the onchange when the value is cleared.
229             this.selectorChanged(
230                 {item: this.selected, preventDefault: () => true});
231         }
232     }
233
234     // Fired by the typeahead to inform us of a change.
235     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
236         this.onChange.emit(selEvent.item);
237         this.propagateChange(selEvent.item);
238     }
239
240     // Adds matching async entries to the entry list
241     // and propagates the search term for pipelining.
242     addAsyncEntries(term: string): Observable<string> {
243
244         if (!term || !this.asyncDataSource) {
245             return of(term);
246         }
247
248         let searchTerm: string;
249         searchTerm = term;
250         if (searchTerm === '_CLICK_' && this.asyncSupportsEmptyTermClick) {
251             searchTerm = '';
252         }
253
254         return new Observable(observer => {
255             this.asyncDataSource(searchTerm).subscribe(
256                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
257                 err => {},
258                 ()  => {
259                     observer.next(searchTerm);
260                     observer.complete();
261                 }
262             );
263         });
264     }
265
266     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
267         return text$.pipe(
268             debounceTime(200),
269             distinctUntilChanged(),
270
271             // Merge click actions in with the stream of text entry
272             merge(
273                 // Inject a specifier indicating the source of the
274                 // action is a user click instead of a text entry.
275                 // This tells the filter to show all values in sync mode.
276                 this.click$.pipe(filter(() =>
277                     !this.instance.isPopupOpen()
278                 )).pipe(mapTo('_CLICK_'))
279             ),
280
281             // mergeMap coalesces an observable into our stream.
282             mergeMap(term => this.addAsyncEntries(term)),
283             map((term: string) => {
284
285                 if (term === '' || term === '_CLICK_') {
286                     if (!this.asyncDataSource) {
287                         // In sync mode, a post-focus empty search or
288                         // click event displays the whole list.
289                         return this.entrylist;
290                     }
291                 }
292
293                 // Filter entrylist whose labels substring-match the
294                 // text entered.
295                 return this.entrylist.filter(entry => {
296                     const label = entry.label || entry.id;
297                     return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
298                 });
299             })
300         );
301     }
302
303     writeValue(value: any) {
304         if (value !== undefined) {
305             this.startId = value;
306             this.startIdFiresOnChange = true;
307         }
308     }
309
310     propagateChange = (_: any) => {};
311
312     registerOnChange(fn) {
313         this.propagateChange = fn;
314     }
315
316     registerOnTouched() { }
317
318 }
319
320