85225faa4150e9e9f97e16392d3b89a7d8ac145e
[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') 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     @Input() idlClass: string;
74     @Input() idlField: string;
75     @Input() idlIncludeLibraryInLabel: string;
76     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
77
78     // If true, an async data search is allowed to fetch all
79     // values when given an empty term. This should be used only
80     // if the maximum number of entries returned by the data source
81     // is known to be no more than a couple hundred.
82     @Input() asyncSupportsEmptyTermClick: boolean;
83
84     // Useful for efficiently preventing duplicate async entries
85     asyncIds: {[idx: string]: boolean};
86
87     // True if a default selection has been made.
88     defaultSelectionApplied: boolean;
89
90     @Input() set entries(el: ComboboxEntry[]) {
91         if (el) {
92             this.entrylist = el;
93             this.applySelection();
94
95             // It's possible to provide an entrylist at load time, but
96             // fetch all future data via async data source.  Track the
97             // values we already have so async lookup won't add them again.
98             // A new entry list wipes out any existing async values.
99             this.asyncIds = {};
100             el.forEach(entry => this.asyncIds['' + entry.id] = true);
101         }
102     }
103
104     // When provided use this as the display template for each entry.
105     @Input() displayTemplate: TemplateRef<any>;
106
107     // Emitted when the value is changed via UI.
108     // When the UI value is cleared, null is emitted.
109     @Output() onChange: EventEmitter<ComboboxEntry>;
110
111     // Useful for massaging the match string prior to comparison
112     // and display.  Default version trims leading/trailing spaces.
113     formatDisplayString: (e: ComboboxEntry) => string;
114
115     // Stub functions required by ControlValueAccessor
116     propagateChange = (_: any) => {};
117     propagateTouch = () => {};
118
119     constructor(
120       private elm: ElementRef,
121       private store: StoreService,
122       private idl: IdlService,
123       private pcrud: PcrudService,
124     ) {
125         this.entrylist = [];
126         this.asyncIds = {};
127         this.click$ = new Subject<string>();
128         this.onChange = new EventEmitter<ComboboxEntry>();
129         this.defaultSelectionApplied = false;
130
131         this.formatDisplayString = (result: ComboboxEntry) => {
132             const display = result.label || result.id;
133             return (display + '').trim();
134         };
135     }
136
137     ngOnInit() {
138         if (this.idlClass) {
139             const classDef = this.idl.classes[this.idlClass];
140             const pkeyField = classDef.pkey;
141
142             if (!pkeyField) {
143                 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
144             }
145
146             if (!this.idlField) {
147                 this.idlField = classDef.field_map[classDef.pkey].selector || 'name';
148             }
149
150             this.asyncDataSource = term => {
151                 const field = this.idlField;
152                 const args = {};
153                 const extra_args = { order_by : {} };
154                 args[field] = {'ilike': `%${term}%`}; // could -or search on label
155                 extra_args['order_by'][this.idlClass] = field;
156                 if (this.idlIncludeLibraryInLabel) {
157                     extra_args['flesh'] = 1;
158                     const flesh_fields: Object = {};
159                     flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
160                     extra_args['flesh_fields'] = flesh_fields;
161                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
162                         return {
163                             id: data[pkeyField](),
164                             label: data[field]() + ' (' + data[this.idlIncludeLibraryInLabel]().shortname() + ')'
165                         };
166                     }));
167                 } else {
168                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
169                         return {id: data[pkeyField](), label: data[field]()};
170                     }));
171                 }
172             };
173         }
174     }
175
176     onClick($event) {
177         this.click$.next($event.target.value);
178     }
179
180     openMe($event) {
181         // Give the input a chance to focus then fire the click
182         // handler to force open the typeahead
183         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
184         setTimeout(() => this.click$.next(''));
185     }
186
187     // Apply a default selection where needed
188     applySelection() {
189
190         if (this.startId !== null &&
191             this.entrylist && !this.defaultSelectionApplied) {
192
193             const entry =
194                 this.entrylist.filter(e => e.id === this.startId)[0];
195
196             if (entry) {
197                 this.selected = entry;
198                 this.defaultSelectionApplied = true;
199                 if (this.startIdFiresOnChange) {
200                     this.selectorChanged(
201                         {item: this.selected, preventDefault: () => true});
202                 }
203             }
204         }
205     }
206
207     // Called by combobox-entry.component
208     addEntry(entry: ComboboxEntry) {
209         this.entrylist.push(entry);
210         this.applySelection();
211     }
212
213     // Manually set the selected value by ID.
214     // This does NOT fire the onChange handler.
215     applyEntryId(entryId: any) {
216         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
217     }
218
219     addAsyncEntry(entry: ComboboxEntry) {
220         // Avoid duplicate async entries
221         if (!this.asyncIds['' + entry.id]) {
222             this.asyncIds['' + entry.id] = true;
223             this.addEntry(entry);
224         }
225     }
226
227     hasEntry(entryId: any): boolean {
228         return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
229     }
230
231     onBlur() {
232         // When the selected value is a string it means we have either
233         // no value (user cleared the input) or a free-text value.
234
235         if (typeof this.selected === 'string') {
236
237             if (this.allowFreeText && this.selected !== '') {
238                 // Free text entered which does not match a known entry
239                 // translate it into a dummy ComboboxEntry
240                 this.selected = {
241                     id: null,
242                     label: this.selected,
243                     freetext: true
244                 };
245
246             } else {
247
248                 this.selected = null;
249             }
250
251             // Manually fire the onchange since NgbTypeahead fails
252             // to fire the onchange when the value is cleared.
253             this.selectorChanged(
254                 {item: this.selected, preventDefault: () => true});
255         }
256         this.propagateTouch();
257     }
258
259     // Fired by the typeahead to inform us of a change.
260     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
261         this.onChange.emit(selEvent.item);
262         this.propagateChange(selEvent.item);
263     }
264
265     // Adds matching async entries to the entry list
266     // and propagates the search term for pipelining.
267     addAsyncEntries(term: string): Observable<string> {
268
269         if (!term || !this.asyncDataSource) {
270             return of(term);
271         }
272
273         let searchTerm: string;
274         searchTerm = term;
275         if (searchTerm === '_CLICK_' && this.asyncSupportsEmptyTermClick) {
276             searchTerm = '';
277         }
278
279         return new Observable(observer => {
280             this.asyncDataSource(searchTerm).subscribe(
281                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
282                 err => {},
283                 ()  => {
284                     observer.next(searchTerm);
285                     observer.complete();
286                 }
287             );
288         });
289     }
290
291     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
292         return text$.pipe(
293             debounceTime(200),
294             distinctUntilChanged(),
295
296             // Merge click actions in with the stream of text entry
297             merge(
298                 // Inject a specifier indicating the source of the
299                 // action is a user click instead of a text entry.
300                 // This tells the filter to show all values in sync mode.
301                 this.click$.pipe(filter(() =>
302                     !this.instance.isPopupOpen()
303                 )).pipe(mapTo('_CLICK_'))
304             ),
305
306             // mergeMap coalesces an observable into our stream.
307             mergeMap(term => this.addAsyncEntries(term)),
308             map((term: string) => {
309
310                 if (term === '' || term === '_CLICK_') {
311                     if (!this.asyncDataSource) {
312                         // In sync mode, a post-focus empty search or
313                         // click event displays the whole list.
314                         return this.entrylist;
315                     }
316                 }
317
318                 // Filter entrylist whose labels substring-match the
319                 // text entered.
320                 return this.entrylist.filter(entry => {
321                     const label = entry.label || entry.id;
322                     return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
323                 });
324             })
325         );
326     }
327
328     writeValue(value: ComboboxEntry) {
329         if (value !== undefined && value !== null) {
330             this.startId = value.id;
331             this.applySelection();
332         }
333     }
334
335     registerOnChange(fn) {
336         this.propagateChange = fn;
337     }
338
339     registerOnTouched(fn) {
340         this.propagateTouch = fn;
341     }
342
343 }
344
345