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