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: (e: 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 const display = result.label || result.id;
106 return (display + '').trim();
114 // Give the input a chance to focus then fire the click
115 // handler to force open the typeahead
116 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
117 setTimeout(() => this.click$.next(''));
120 // Apply a default selection where needed
124 this.entrylist && !this.defaultSelectionApplied) {
127 this.entrylist.filter(e => e.id === this.startId)[0];
130 this.selected = entry;
131 this.defaultSelectionApplied = true;
132 if (this.startIdFiresOnChange) {
133 this.selectorChanged(
134 {item: this.selected, preventDefault: () => true});
140 // Called by combobox-entry.component
141 addEntry(entry: ComboboxEntry) {
142 this.entrylist.push(entry);
143 this.applySelection();
146 // Manually set the selected value by ID.
147 // This does NOT fire the onChange handler.
148 applyEntryId(entryId: any) {
149 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
152 addAsyncEntry(entry: ComboboxEntry) {
153 // Avoid duplicate async entries
154 if (!this.asyncIds['' + entry.id]) {
155 this.asyncIds['' + entry.id] = true;
156 this.addEntry(entry);
161 // When the selected value is a string it means we have either
162 // no value (user cleared the input) or a free-text value.
164 if (typeof this.selected === 'string') {
166 if (this.allowFreeText && this.selected !== '') {
167 // Free text entered which does not match a known entry
168 // translate it into a dummy ComboboxEntry
171 label: this.selected,
177 this.selected = null;
180 // Manually fire the onchange since NgbTypeahead fails
181 // to fire the onchange when the value is cleared.
182 this.selectorChanged(
183 {item: this.selected, preventDefault: () => true});
187 // Fired by the typeahead to inform us of a change.
188 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
189 this.onChange.emit(selEvent.item);
192 // Adds matching async entries to the entry list
193 // and propagates the search term for pipelining.
194 addAsyncEntries(term: string): Observable<string> {
196 if (!term || !this.asyncDataSource) {
200 return new Observable(observer => {
201 this.asyncDataSource(term).subscribe(
202 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
212 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
215 distinctUntilChanged(),
217 // Merge click actions in with the stream of text entry
219 // Inject a specifier indicating the source of the
220 // action is a user click instead of a text entry.
221 // This tells the filter to show all values in sync mode.
222 this.click$.pipe(filter(() =>
223 !this.instance.isPopupOpen() && !this.asyncDataSource
224 )).pipe(mapTo('_CLICK_'))
227 // mergeMap coalesces an observable into our stream.
228 mergeMap(term => this.addAsyncEntries(term)),
229 map((term: string) => {
231 if (term === '' || term === '_CLICK_') {
232 // Avoid displaying the existing entries on-click
233 // for async sources, becuase that implies we have
234 // the full data set. (setting?)
235 if (this.asyncDataSource) {
238 // In sync mode, a post-focus empty search or
239 // click event displays the whole list.
240 return this.entrylist;
244 // Filter entrylist whose labels substring-match the
246 return this.entrylist.filter(entry => {
247 const label = entry.label || entry.id;
248 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;