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