2 * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
3 * <!-- see also <eg-combobox-entry> -->
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';
13 export interface ComboboxEntry {
15 // If no label is provided, the 'id' value is used.
21 selector: 'eg-combobox',
22 templateUrl: './combobox.component.html',
24 .icons {margin-left:-18px}
25 .material-icons {font-size: 16px;font-weight:bold}
28 export class ComboboxComponent implements OnInit {
30 selected: ComboboxEntry;
31 click$: Subject<string>;
32 entrylist: ComboboxEntry[];
34 @ViewChild('instance') instance: NgbTypeahead;
36 // Applies a name attribute to the input.
38 @Input() name: string;
40 // Placeholder text for selector input
41 @Input() placeholder = '';
43 @Input() persistKey: string; // TODO
45 @Input() allowFreeText = false;
47 // Add a 'required' attribute to the input
49 @Input() set required(r: boolean) {
55 @Input() set disabled(d: boolean) {
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;
65 @Input() idlClass: string;
66 @Input() idlField: string;
67 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
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;
75 // Useful for efficiently preventing duplicate async entries
76 asyncIds: {[idx: string]: boolean};
78 // True if a default selection has been made.
79 defaultSelectionApplied: boolean;
81 @Input() set entries(el: ComboboxEntry[]) {
84 this.applySelection();
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.
91 el.forEach(entry => this.asyncIds['' + entry.id] = true);
95 // Emitted when the value is changed via UI.
96 // When the UI value is cleared, null is emitted.
97 @Output() onChange: EventEmitter<ComboboxEntry>;
99 // Useful for massaging the match string prior to comparison
100 // and display. Default version trims leading/trailing spaces.
101 formatDisplayString: (e: ComboboxEntry) => string;
104 private elm: ElementRef,
105 private store: StoreService,
106 private pcrud: PcrudService,
110 this.click$ = new Subject<string>();
111 this.onChange = new EventEmitter<ComboboxEntry>();
112 this.defaultSelectionApplied = false;
114 this.formatDisplayString = (result: ComboboxEntry) => {
115 const display = result.label || result.id;
116 return (display + '').trim();
122 this.asyncDataSource = term => {
123 const field = this.idlField || 'name';
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]()};
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(''));
142 // Apply a default selection where needed
146 this.entrylist && !this.defaultSelectionApplied) {
149 this.entrylist.filter(e => e.id === this.startId)[0];
152 this.selected = entry;
153 this.defaultSelectionApplied = true;
154 if (this.startIdFiresOnChange) {
155 this.selectorChanged(
156 {item: this.selected, preventDefault: () => true});
162 // Called by combobox-entry.component
163 addEntry(entry: ComboboxEntry) {
164 this.entrylist.push(entry);
165 this.applySelection();
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];
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);
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.
186 if (typeof this.selected === 'string') {
188 if (this.allowFreeText && this.selected !== '') {
189 // Free text entered which does not match a known entry
190 // translate it into a dummy ComboboxEntry
193 label: this.selected,
199 this.selected = null;
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});
209 // Fired by the typeahead to inform us of a change.
210 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
211 this.onChange.emit(selEvent.item);
214 // Adds matching async entries to the entry list
215 // and propagates the search term for pipelining.
216 addAsyncEntries(term: string): Observable<string> {
218 if (!term || !this.asyncDataSource) {
222 let searchTerm: string;
224 if (searchTerm === '_CLICK_' && this.asyncSupportsEmptyTermClick) {
229 return new Observable(observer => {
230 this.asyncDataSource(searchTerm).subscribe(
231 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
234 observer.next(searchTerm);
241 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
244 distinctUntilChanged(),
246 // Merge click actions in with the stream of text entry
248 // Inject a specifier indicating the source of the
249 // action is a user click instead of a text entry.
250 // This tells the filter to show all values in sync mode.
251 this.click$.pipe(filter(() =>
252 !this.instance.isPopupOpen()
253 )).pipe(mapTo('_CLICK_'))
256 // mergeMap coalesces an observable into our stream.
257 mergeMap(term => this.addAsyncEntries(term)),
258 map((term: string) => {
260 if (term === '' || term === '_CLICK_') {
261 if (!this.asyncDataSource) {
262 // In sync mode, a post-focus empty search or
263 // click event displays the whole list.
264 return this.entrylist;
268 // Filter entrylist whose labels substring-match the
270 return this.entrylist.filter(entry => {
271 const label = entry.label || entry.id;
272 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;