2 * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
3 * <!-- see also <eg-combobox-entry> -->
6 import {Component, OnInit, Input, Output, ViewChild,
7 TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
8 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
9 import {Observable, of, Subject} from 'rxjs';
10 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
11 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
12 import {StoreService} from '@eg/core/store.service';
13 import {IdlService} from '@eg/core/idl.service';
14 import {PcrudService} from '@eg/core/pcrud.service';
16 export interface ComboboxEntry {
18 // If no label is provided, the 'id' value is used.
24 selector: 'eg-combobox',
25 templateUrl: './combobox.component.html',
27 .icons {margin-left:-18px}
28 .material-icons {font-size: 16px;font-weight:bold}
31 provide: NG_VALUE_ACCESSOR,
32 useExisting: forwardRef(() => ComboboxComponent),
36 export class ComboboxComponent implements ControlValueAccessor, 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 = null;
71 @Input() startIdFiresOnChange: boolean;
73 // Allow the selected entry ID to be passed via the template
74 // This does NOT not emit onChange events.
75 @Input() set selectedId(id: any) {
77 if (this.entrylist.length) {
78 this.selected = this.entrylist.filter(e => e.id === id)[0];
82 // It's possible the selected ID lives in a set of entries
83 // that are yet to be provided.
89 get selectedId(): any {
90 return this.selected ? this.selected.id : null;
93 @Input() idlClass: string;
94 @Input() idlField: string;
95 @Input() idlIncludeLibraryInLabel: string;
96 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
98 // If true, an async data search is allowed to fetch all
99 // values when given an empty term. This should be used only
100 // if the maximum number of entries returned by the data source
101 // is known to be no more than a couple hundred.
102 @Input() asyncSupportsEmptyTermClick: boolean;
104 // Useful for efficiently preventing duplicate async entries
105 asyncIds: {[idx: string]: boolean};
107 // True if a default selection has been made.
108 defaultSelectionApplied: boolean;
110 @Input() set entries(el: ComboboxEntry[]) {
114 // new set of entries essentially means a new instance. reset.
115 this.defaultSelectionApplied = false;
116 this.applySelection();
118 // It's possible to provide an entrylist at load time, but
119 // fetch all future data via async data source. Track the
120 // values we already have so async lookup won't add them again.
121 // A new entry list wipes out any existing async values.
123 el.forEach(entry => this.asyncIds['' + entry.id] = true);
127 // When provided use this as the display template for each entry.
128 @Input() displayTemplate: TemplateRef<any>;
130 // Emitted when the value is changed via UI.
131 // When the UI value is cleared, null is emitted.
132 @Output() onChange: EventEmitter<ComboboxEntry>;
134 // Useful for massaging the match string prior to comparison
135 // and display. Default version trims leading/trailing spaces.
136 formatDisplayString: (e: ComboboxEntry) => string;
138 // Stub functions required by ControlValueAccessor
139 propagateChange = (_: any) => {};
140 propagateTouch = () => {};
143 private elm: ElementRef,
144 private store: StoreService,
145 private idl: IdlService,
146 private pcrud: PcrudService,
150 this.click$ = new Subject<string>();
151 this.onChange = new EventEmitter<ComboboxEntry>();
152 this.defaultSelectionApplied = false;
154 this.formatDisplayString = (result: ComboboxEntry) => {
155 const display = result.label || result.id;
156 return (display + '').trim();
162 const classDef = this.idl.classes[this.idlClass];
163 const pkeyField = classDef.pkey;
166 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
169 if (!this.idlField) {
170 this.idlField = classDef.field_map[classDef.pkey].selector || 'name';
173 this.asyncDataSource = term => {
174 const field = this.idlField;
176 const extra_args = { order_by : {} };
177 args[field] = {'ilike': `%${term}%`}; // could -or search on label
178 extra_args['order_by'][this.idlClass] = field;
179 if (this.idlIncludeLibraryInLabel) {
180 extra_args['flesh'] = 1;
181 const flesh_fields: Object = {};
182 flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
183 extra_args['flesh_fields'] = flesh_fields;
184 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
186 id: data[pkeyField](),
187 label: data[field]() + ' (' + data[this.idlIncludeLibraryInLabel]().shortname() + ')'
191 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
192 return {id: data[pkeyField](), label: data[field]()};
200 this.click$.next($event.target.value);
204 // Give the input a chance to focus then fire the click
205 // handler to force open the typeahead
206 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
207 setTimeout(() => this.click$.next(''));
210 // Apply a default selection where needed
213 if (this.startId !== null &&
214 this.entrylist && !this.defaultSelectionApplied) {
217 this.entrylist.filter(e => e.id === this.startId)[0];
220 this.selected = entry;
221 this.defaultSelectionApplied = true;
222 if (this.startIdFiresOnChange) {
223 this.selectorChanged(
224 {item: this.selected, preventDefault: () => true});
230 // Called by combobox-entry.component
231 addEntry(entry: ComboboxEntry) {
232 this.entrylist.push(entry);
233 this.applySelection();
236 // Manually set the selected value by ID.
237 // This does NOT fire the onChange handler.
238 // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
239 applyEntryId(entryId: any) {
240 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
243 addAsyncEntry(entry: ComboboxEntry) {
244 // Avoid duplicate async entries
245 if (!this.asyncIds['' + entry.id]) {
246 this.asyncIds['' + entry.id] = true;
247 this.addEntry(entry);
251 hasEntry(entryId: any): boolean {
252 return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
256 // When the selected value is a string it means we have either
257 // no value (user cleared the input) or a free-text value.
259 if (typeof this.selected === 'string') {
261 if (this.allowFreeText && this.selected !== '') {
262 // Free text entered which does not match a known entry
263 // translate it into a dummy ComboboxEntry
266 label: this.selected,
272 this.selected = null;
275 // Manually fire the onchange since NgbTypeahead fails
276 // to fire the onchange when the value is cleared.
277 this.selectorChanged(
278 {item: this.selected, preventDefault: () => true});
280 this.propagateTouch();
283 // Fired by the typeahead to inform us of a change.
284 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
285 this.onChange.emit(selEvent.item);
286 this.propagateChange(selEvent.item);
289 // Adds matching async entries to the entry list
290 // and propagates the search term for pipelining.
291 addAsyncEntries(term: string): Observable<string> {
293 if (!term || !this.asyncDataSource) {
297 let searchTerm: string;
299 if (searchTerm === '_CLICK_' && this.asyncSupportsEmptyTermClick) {
303 return new Observable(observer => {
304 this.asyncDataSource(searchTerm).subscribe(
305 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
308 observer.next(searchTerm);
315 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
318 distinctUntilChanged(),
320 // Merge click actions in with the stream of text entry
322 // Inject a specifier indicating the source of the
323 // action is a user click instead of a text entry.
324 // This tells the filter to show all values in sync mode.
325 this.click$.pipe(filter(() =>
326 !this.instance.isPopupOpen()
327 )).pipe(mapTo('_CLICK_'))
330 // mergeMap coalesces an observable into our stream.
331 mergeMap(term => this.addAsyncEntries(term)),
332 map((term: string) => {
334 if (term === '' || term === '_CLICK_') {
335 if (!this.asyncDataSource) {
336 // In sync mode, a post-focus empty search or
337 // click event displays the whole list.
338 return this.entrylist;
342 // Filter entrylist whose labels substring-match the
344 return this.entrylist.filter(entry => {
345 const label = entry.label || entry.id;
346 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
352 writeValue(value: ComboboxEntry) {
353 if (value !== undefined && value !== null) {
354 this.startId = value.id;
355 this.applySelection();
359 registerOnChange(fn) {
360 this.propagateChange = fn;
363 registerOnTouched(fn) {
364 this.propagateTouch = fn;