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';
12 export interface ComboboxEntry {
14 // If no label is provided, the 'id' value is used.
20 selector: 'eg-combobox',
21 templateUrl: './combobox.component.html',
23 .icons {margin-left:-18px}
24 .material-icons {font-size: 16px;font-weight:bold}
27 export class ComboboxComponent implements OnInit {
29 selected: ComboboxEntry;
30 click$: Subject<string>;
31 entrylist: ComboboxEntry[];
33 @ViewChild('instance') instance: NgbTypeahead;
35 // Applies a name attribute to the input.
37 @Input() name: string;
39 // Placeholder text for selector input
40 @Input() placeholder = '';
42 @Input() persistKey: string; // TODO
44 @Input() allowFreeText = false;
46 // Add a 'required' attribute to the input
48 @Input() set required(r: boolean) {
54 @Input() set disabled(d: boolean) {
58 // Entry ID of the default entry to select (optional)
59 // onChange() is NOT fired when applying the default value,
60 // unless startIdFiresOnChange is set to true.
61 @Input() startId: any;
62 @Input() startIdFiresOnChange: boolean;
64 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
66 // Useful for efficiently preventing duplicate async entries
67 asyncIds: {[idx: string]: boolean};
69 // True if a default selection has been made.
70 defaultSelectionApplied: boolean;
72 @Input() set entries(el: ComboboxEntry[]) {
75 this.applySelection();
77 // It's possible to provide an entrylist at load time, but
78 // fetch all future data via async data source. Track the
79 // values we already have so async lookup won't add them again.
80 // A new entry list wipes out any existing async values.
82 el.forEach(entry => this.asyncIds['' + entry.id] = true);
86 // Emitted when the value is changed via UI.
87 // When the UI value is cleared, null is emitted.
88 @Output() onChange: EventEmitter<ComboboxEntry>;
90 // Useful for massaging the match string prior to comparison
91 // and display. Default version trims leading/trailing spaces.
92 formatDisplayString: (ComboboxEntry) => string;
95 private elm: ElementRef,
96 private store: StoreService,
100 this.click$ = new Subject<string>();
101 this.onChange = new EventEmitter<ComboboxEntry>();
102 this.defaultSelectionApplied = false;
104 this.formatDisplayString = (result: ComboboxEntry) => {
105 return result.label.trim();
113 // Give the input a chance to focus then fire the click
114 // handler to force open the typeahead
115 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
116 setTimeout(() => this.click$.next(''));
119 // Apply a default selection where needed
123 this.entrylist && !this.defaultSelectionApplied) {
126 this.entrylist.filter(e => e.id === this.startId)[0];
129 this.selected = entry;
130 this.defaultSelectionApplied = true;
131 if (this.startIdFiresOnChange) {
132 this.selectorChanged(
133 {item: this.selected, preventDefault: () => true});
139 // Called by combobox-entry.component
140 addEntry(entry: ComboboxEntry) {
141 this.entrylist.push(entry);
142 this.applySelection();
145 // Manually set the selected value by ID.
146 // This does NOT fire the onChange handler.
147 applyEntryId(entryId: any) {
148 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
151 addAsyncEntry(entry: ComboboxEntry) {
152 // Avoid duplicate async entries
153 if (!this.asyncIds['' + entry.id]) {
154 this.asyncIds['' + entry.id] = true;
155 this.addEntry(entry);
160 // When the selected value is a string it means we have either
161 // no value (user cleared the input) or a free-text value.
163 if (typeof this.selected === 'string') {
165 if (this.allowFreeText && this.selected !== '') {
166 // Free text entered which does not match a known entry
167 // translate it into a dummy ComboboxEntry
170 label: this.selected,
176 this.selected = null;
179 // Manually fire the onchange since NgbTypeahead fails
180 // to fire the onchange when the value is cleared.
181 this.selectorChanged(
182 {item: this.selected, preventDefault: () => true});
186 // Fired by the typeahead to inform us of a change.
187 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
188 this.onChange.emit(selEvent.item);
191 // Adds matching async entries to the entry list
192 // and propagates the search term for pipelining.
193 addAsyncEntries(term: string): Observable<string> {
195 if (!term || !this.asyncDataSource) {
199 return new Observable(observer => {
200 this.asyncDataSource(term).subscribe(
201 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
211 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
214 distinctUntilChanged(),
216 // Merge click actions in with the stream of text entry
218 // Inject a specifier indicating the source of the
219 // action is a user click instead of a text entry.
220 // This tells the filter to show all values in sync mode.
221 this.click$.pipe(filter(() =>
222 !this.instance.isPopupOpen() && !this.asyncDataSource
223 )).pipe(mapTo('_CLICK_'))
226 // mergeMap coalesces an observable into our stream.
227 mergeMap(term => this.addAsyncEntries(term)),
228 map((term: string) => {
230 if (term === '' || term === '_CLICK_') {
231 // Avoid displaying the existing entries on-click
232 // for async sources, becuase that implies we have
233 // the full data set. (setting?)
234 if (this.asyncDataSource) {
237 // In sync mode, a post-focus empty search or
238 // click event displays the whole list.
239 return this.entrylist;
243 // Filter entrylist whose labels substring-match the
245 return this.entrylist.filter(entry => {
246 const label = entry.label || entry.id;
247 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;