2 * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
3 * <!-- see also <eg-combobox-entry> -->
6 import {Component, OnInit, Input, Output, ViewChild, EventEmitter, ElementRef, forwardRef} from '@angular/core';
7 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
8 import {Observable, of, Subject} from 'rxjs';
9 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
10 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
11 import {StoreService} from '@eg/core/store.service';
12 import {IdlService} from '@eg/core/idl.service';
13 import {PcrudService} from '@eg/core/pcrud.service';
15 export interface ComboboxEntry {
17 // If no label is provided, the 'id' value is used.
23 selector: 'eg-combobox',
24 templateUrl: './combobox.component.html',
26 .icons {margin-left:-18px}
27 .material-icons {font-size: 16px;font-weight:bold}
30 provide: NG_VALUE_ACCESSOR,
31 useExisting: forwardRef(() => ComboboxComponent),
35 export class ComboboxComponent implements ControlValueAccessor, OnInit {
37 selected: ComboboxEntry;
38 click$: Subject<string>;
39 entrylist: ComboboxEntry[];
41 @ViewChild('instance') instance: NgbTypeahead;
43 // Applies a name attribute to the input.
45 @Input() name: string;
47 // Placeholder text for selector input
48 @Input() placeholder = '';
50 @Input() persistKey: string; // TODO
52 @Input() allowFreeText = false;
54 // Add a 'required' attribute to the input
56 @Input() set required(r: boolean) {
62 @Input() set disabled(d: boolean) {
66 // Entry ID of the default entry to select (optional)
67 // onChange() is NOT fired when applying the default value,
68 // unless startIdFiresOnChange is set to true.
69 @Input() startId: any;
70 @Input() startIdFiresOnChange: boolean;
72 @Input() idlClass: string;
73 @Input() idlField: string;
74 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
76 // If true, an async data search is allowed to fetch all
77 // values when given an empty term. This should be used only
78 // if the maximum number of entries returned by the data source
79 // is known to be no more than a couple hundred.
80 @Input() asyncSupportsEmptyTermClick: boolean;
82 // Useful for efficiently preventing duplicate async entries
83 asyncIds: {[idx: string]: boolean};
85 // True if a default selection has been made.
86 defaultSelectionApplied: boolean;
88 @Input() set entries(el: ComboboxEntry[]) {
91 this.applySelection();
93 // It's possible to provide an entrylist at load time, but
94 // fetch all future data via async data source. Track the
95 // values we already have so async lookup won't add them again.
96 // A new entry list wipes out any existing async values.
98 el.forEach(entry => this.asyncIds['' + entry.id] = true);
102 // Emitted when the value is changed via UI.
103 // When the UI value is cleared, null is emitted.
104 @Output() onChange: EventEmitter<ComboboxEntry>;
106 // Useful for massaging the match string prior to comparison
107 // and display. Default version trims leading/trailing spaces.
108 formatDisplayString: (e: ComboboxEntry) => string;
110 // Stub functions required by ControlValueAccessor
111 propagateChange = (_: any) => {};
112 propagateTouch = () => {};
115 private elm: ElementRef,
116 private store: StoreService,
117 private idl: IdlService,
118 private pcrud: PcrudService,
122 this.click$ = new Subject<string>();
123 this.onChange = new EventEmitter<ComboboxEntry>();
124 this.defaultSelectionApplied = false;
126 this.formatDisplayString = (result: ComboboxEntry) => {
127 const display = result.label || result.id;
128 return (display + '').trim();
134 const classDef = this.idl.classes[this.idlClass];
135 const pkeyField = classDef.pkey;
138 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
141 if (!this.idlField) {
142 this.idlField = classDef.field_map[classDef.pkey].selector || 'name';
145 this.asyncDataSource = term => {
146 const field = this.idlField;
148 const extra_args = { order_by : {} };
149 args[field] = {'ilike': `%${term}%`}; // could -or search on label
150 extra_args['order_by'][this.idlClass] = field;
151 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
152 return {id: data[pkeyField](), label: data[field]()};
159 this.click$.next($event.target.value);
163 // Give the input a chance to focus then fire the click
164 // handler to force open the typeahead
165 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
166 setTimeout(() => this.click$.next(''));
169 // Apply a default selection where needed
173 this.entrylist && !this.defaultSelectionApplied) {
176 this.entrylist.filter(e => e.id === this.startId)[0];
179 this.selected = entry;
180 this.defaultSelectionApplied = true;
181 if (this.startIdFiresOnChange) {
182 this.selectorChanged(
183 {item: this.selected, preventDefault: () => true});
189 // Called by combobox-entry.component
190 addEntry(entry: ComboboxEntry) {
191 this.entrylist.push(entry);
192 this.applySelection();
195 // Manually set the selected value by ID.
196 // This does NOT fire the onChange handler.
197 applyEntryId(entryId: any) {
198 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
201 addAsyncEntry(entry: ComboboxEntry) {
202 // Avoid duplicate async entries
203 if (!this.asyncIds['' + entry.id]) {
204 this.asyncIds['' + entry.id] = true;
205 this.addEntry(entry);
210 // When the selected value is a string it means we have either
211 // no value (user cleared the input) or a free-text value.
213 if (typeof this.selected === 'string') {
215 if (this.allowFreeText && this.selected !== '') {
216 // Free text entered which does not match a known entry
217 // translate it into a dummy ComboboxEntry
220 label: this.selected,
226 this.selected = null;
229 // Manually fire the onchange since NgbTypeahead fails
230 // to fire the onchange when the value is cleared.
231 this.selectorChanged(
232 {item: this.selected, preventDefault: () => true});
234 this.propagateTouch();
237 // Fired by the typeahead to inform us of a change.
238 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
239 this.onChange.emit(selEvent.item);
240 this.propagateChange(selEvent.item);
243 // Adds matching async entries to the entry list
244 // and propagates the search term for pipelining.
245 addAsyncEntries(term: string): Observable<string> {
247 if (!term || !this.asyncDataSource) {
251 let searchTerm: string;
253 if (searchTerm === '_CLICK_' && this.asyncSupportsEmptyTermClick) {
257 return new Observable(observer => {
258 this.asyncDataSource(searchTerm).subscribe(
259 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
262 observer.next(searchTerm);
269 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
272 distinctUntilChanged(),
274 // Merge click actions in with the stream of text entry
276 // Inject a specifier indicating the source of the
277 // action is a user click instead of a text entry.
278 // This tells the filter to show all values in sync mode.
279 this.click$.pipe(filter(() =>
280 !this.instance.isPopupOpen()
281 )).pipe(mapTo('_CLICK_'))
284 // mergeMap coalesces an observable into our stream.
285 mergeMap(term => this.addAsyncEntries(term)),
286 map((term: string) => {
288 if (term === '' || term === '_CLICK_') {
289 if (!this.asyncDataSource) {
290 // In sync mode, a post-focus empty search or
291 // click event displays the whole list.
292 return this.entrylist;
296 // Filter entrylist whose labels substring-match the
298 return this.entrylist.filter(entry => {
299 const label = entry.label || entry.id;
300 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
306 writeValue(value: ComboboxEntry) {
307 if (value !== undefined && value !== null) {
308 this.startId = value.id;
309 this.applySelection();
313 registerOnChange(fn) {
314 this.propagateChange = fn;
317 registerOnTouched(fn) {
318 this.propagateTouch = fn;