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} from 'rxjs/Observable';
8 import {map} from 'rxjs/operators/map';
9 import {tap} from 'rxjs/operators/tap';
10 import {reduce} from 'rxjs/operators/reduce';
11 import {of} from 'rxjs';
12 import {mergeMap} from 'rxjs/operators/mergeMap';
13 import {mapTo} from 'rxjs/operators/mapTo';
14 import {debounceTime} from 'rxjs/operators/debounceTime';
15 import {distinctUntilChanged} from 'rxjs/operators/distinctUntilChanged';
16 import {merge} from 'rxjs/operators/merge';
17 import {filter} from 'rxjs/operators/filter';
18 import {Subject} from 'rxjs/Subject';
19 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
20 import {StoreService} from '@eg/core/store.service';
22 export interface ComboboxEntry {
29 selector: 'eg-combobox',
30 templateUrl: './combobox.component.html',
32 .icons {margin-left:-18px}
33 .material-icons {font-size: 16px;font-weight:bold}
36 export class ComboboxComponent implements OnInit {
38 selected: ComboboxEntry;
39 click$: Subject<string>;
40 entrylist: ComboboxEntry[];
42 @ViewChild('instance') instance: NgbTypeahead;
44 // Applies a name attribute to the input.
46 @Input() name: string;
48 // Placeholder text for selector input
49 @Input() placeholder = '';
51 @Input() persistKey: string; // TODO
53 @Input() allowFreeText = false;
55 // Add a 'required' attribute to the input
57 @Input() set required(r: boolean) {
63 @Input() set disabled(d: boolean) {
67 // Entry ID of the default entry to select (optional)
68 // onChange() is NOT fired when applying the default value,
69 // unless startIdFiresOnChange is set to true.
70 @Input() startId: any;
71 @Input() startIdFiresOnChange: boolean;
73 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
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[]) {
83 this.applySelection();
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();
146 // When the selected value is a string it means we have either
147 // no value (user cleared the input) or a free-text value.
149 if (typeof this.selected === 'string') {
151 if (this.allowFreeText && this.selected !== '') {
152 // Free text entered which does not match a known entry
153 // translate it into a dummy ComboboxEntry
156 label: this.selected,
162 this.selected = null;
165 // Manually fire the onchange since NgbTypeahead fails
166 // to fire the onchange when the value is cleared.
167 this.selectorChanged(
168 {item: this.selected, preventDefault: () => true});
172 // Fired by the typeahead to inform us of a change.
173 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
174 this.onChange.emit(selEvent.item);
177 // Adds matching async entries to the entry list
178 // and propagates the search term for pipelining.
179 addAsyncEntries(term: string): Observable<string> {
181 if (!term || !this.asyncDataSource) {
185 return new Observable(observer => {
186 this.asyncDataSource(term).subscribe(
187 (entry: ComboboxEntry) => {
188 if (!this.asyncIds['' + entry.id]) {
189 this.asyncIds['' + entry.id] = true;
190 this.addEntry(entry);
202 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
205 distinctUntilChanged(),
207 // Merge click actions in with the stream of text entry
209 // Inject a specifier indicating the source of the
210 // action is a user click instead of a text entry.
211 // This tells the filter to show all values in sync mode.
212 this.click$.pipe(filter(() =>
213 !this.instance.isPopupOpen() && !this.asyncDataSource
214 )).pipe(mapTo('_CLICK_'))
217 // mergeMap coalesces an observable into our stream.
218 mergeMap(term => this.addAsyncEntries(term)),
219 map((term: string) => {
221 if (term === '' || term === '_CLICK_') {
222 if (this.asyncDataSource) {
225 // In sync mode, a post-focus empty search or
226 // click event displays the whole list.
227 return this.entrylist;
231 // Filter entrylist whose labels substring-match the
233 return this.entrylist.filter(entry =>
234 entry.label.toLowerCase().indexOf(term.toLowerCase()) > -1