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.
21 userdata?: any; // opaque external value; ignored by this component.
25 selector: 'eg-combobox',
26 templateUrl: './combobox.component.html',
28 .icons {margin-left:-18px}
29 .material-icons {font-size: 16px;font-weight:bold}
32 provide: NG_VALUE_ACCESSOR,
33 useExisting: forwardRef(() => ComboboxComponent),
37 export class ComboboxComponent implements ControlValueAccessor, OnInit {
39 selected: ComboboxEntry;
40 click$: Subject<string>;
41 entrylist: ComboboxEntry[];
43 @ViewChild('instance', { static: true }) instance: NgbTypeahead;
45 // Applies a name attribute to the input.
47 @Input() name: string;
49 // Placeholder text for selector input
50 @Input() placeholder = '';
52 @Input() persistKey: string; // TODO
54 @Input() allowFreeText = false;
56 @Input() inputSize: number = null;
58 // Add a 'required' attribute to the input
60 @Input() set required(r: boolean) {
66 @Input() set disabled(d: boolean) {
70 // Entry ID of the default entry to select (optional)
71 // onChange() is NOT fired when applying the default value,
72 // unless startIdFiresOnChange is set to true.
73 @Input() startId: any = null;
74 @Input() idlClass: string;
75 @Input() startIdFiresOnChange: boolean;
77 // Allow the selected entry ID to be passed via the template
78 // This does NOT not emit onChange events.
79 @Input() set selectedId(id: any) {
80 if (id === undefined) { return; }
82 // clear on explicit null
83 if (id === null) { this.selected = null; }
85 if (this.entrylist.length) {
86 this.selected = this.entrylist.filter(e => e.id === id)[0];
90 // It's possible the selected ID lives in a set of entries
91 // that are yet to be provided.
94 this.pcrud.retrieve(this.idlClass, id)
98 label: rec[this.idlField]()
100 this.selected = this.entrylist.filter(e => e.id === id)[0];
106 get selectedId(): any {
107 return this.selected ? this.selected.id : null;
110 @Input() idlField: string;
111 @Input() idlIncludeLibraryInLabel: string;
112 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
114 // If true, an async data search is allowed to fetch all
115 // values when given an empty term. This should be used only
116 // if the maximum number of entries returned by the data source
117 // is known to be no more than a couple hundred.
118 @Input() asyncSupportsEmptyTermClick: boolean;
120 // Useful for efficiently preventing duplicate async entries
121 asyncIds: {[idx: string]: boolean};
123 // True if a default selection has been made.
124 defaultSelectionApplied: boolean;
126 @Input() set entries(el: ComboboxEntry[]) {
129 if (this.entrylistMatches(el)) {
130 // Avoid reprocessing data we already have.
136 // new set of entries essentially means a new instance. reset.
137 this.defaultSelectionApplied = false;
138 this.applySelection();
140 // It's possible to provide an entrylist at load time, but
141 // fetch all future data via async data source. Track the
142 // values we already have so async lookup won't add them again.
143 // A new entry list wipes out any existing async values.
145 el.forEach(entry => this.asyncIds['' + entry.id] = true);
149 // When provided use this as the display template for each entry.
150 @Input() displayTemplate: TemplateRef<any>;
152 // Emitted when the value is changed via UI.
153 // When the UI value is cleared, null is emitted.
154 @Output() onChange: EventEmitter<ComboboxEntry>;
156 // Useful for massaging the match string prior to comparison
157 // and display. Default version trims leading/trailing spaces.
158 formatDisplayString: (e: ComboboxEntry) => string;
160 // Stub functions required by ControlValueAccessor
161 propagateChange = (_: any) => {};
162 propagateTouch = () => {};
165 private elm: ElementRef,
166 private store: StoreService,
167 private idl: IdlService,
168 private pcrud: PcrudService,
172 this.click$ = new Subject<string>();
173 this.onChange = new EventEmitter<ComboboxEntry>();
174 this.defaultSelectionApplied = false;
176 this.formatDisplayString = (result: ComboboxEntry) => {
177 const display = result.label || result.id;
178 return (display + '').trim();
184 const classDef = this.idl.classes[this.idlClass];
185 const pkeyField = classDef.pkey;
188 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
191 if (!this.idlField) {
192 this.idlField = this.idl.getClassSelector(this.idlClass);
195 this.asyncDataSource = term => {
196 const field = this.idlField;
198 const extra_args = { order_by : {} };
199 args[field] = {'ilike': `%${term}%`}; // could -or search on label
200 extra_args['order_by'][this.idlClass] = field;
201 extra_args['limit'] = 100;
202 if (this.idlIncludeLibraryInLabel) {
203 extra_args['flesh'] = 1;
204 const flesh_fields: Object = {};
205 flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
206 extra_args['flesh_fields'] = flesh_fields;
207 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
209 id: data[pkeyField](),
210 label: data[field]() + ' (' + data[this.idlIncludeLibraryInLabel]().shortname() + ')'
214 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
215 return {id: data[pkeyField](), label: data[field]()};
223 this.click$.next($event.target.value);
227 // Give the input a chance to focus then fire the click
228 // handler to force open the typeahead
229 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
230 setTimeout(() => this.click$.next(''));
233 // Returns true if the 2 entries are equivalent.
234 entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
238 e1.label === e2.label &&
239 e1.freetext === e2.freetext
243 // Returns true if the 2 lists are equivalent.
244 entrylistMatches(el: ComboboxEntry[]): boolean {
245 if (el.length === 0 && this.entrylist.length === 0) {
246 // Empty arrays are only equivalent if they are the same array,
247 // since the caller may provide an array that starts empty, but
248 // is later populated.
249 return el === this.entrylist;
251 if (el.length !== this.entrylist.length) {
254 for (let i = 0; i < el.length; i++) {
255 const mine = this.entrylist[i];
256 if (!mine || !this.entriesMatch(mine, el[i])) {
263 // Apply a default selection where needed
266 if (this.startId !== null &&
267 this.entrylist && !this.defaultSelectionApplied) {
270 this.entrylist.filter(e => e.id === this.startId)[0];
273 this.selected = entry;
274 this.defaultSelectionApplied = true;
275 if (this.startIdFiresOnChange) {
276 this.selectorChanged(
277 {item: this.selected, preventDefault: () => true});
283 // Called by combobox-entry.component
284 addEntry(entry: ComboboxEntry) {
285 this.entrylist.push(entry);
286 this.applySelection();
289 // Manually set the selected value by ID.
290 // This does NOT fire the onChange handler.
291 // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
292 applyEntryId(entryId: any) {
293 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
296 addAsyncEntry(entry: ComboboxEntry) {
297 // Avoid duplicate async entries
298 if (!this.asyncIds['' + entry.id]) {
299 this.asyncIds['' + entry.id] = true;
300 this.addEntry(entry);
304 hasEntry(entryId: any): boolean {
305 return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
309 // When the selected value is a string it means we have either
310 // no value (user cleared the input) or a free-text value.
312 if (typeof this.selected === 'string') {
314 if (this.allowFreeText && this.selected !== '') {
315 // Free text entered which does not match a known entry
316 // translate it into a dummy ComboboxEntry
319 label: this.selected,
325 this.selected = null;
328 // Manually fire the onchange since NgbTypeahead fails
329 // to fire the onchange when the value is cleared.
330 this.selectorChanged(
331 {item: this.selected, preventDefault: () => true});
333 this.propagateTouch();
336 // Fired by the typeahead to inform us of a change.
337 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
338 this.onChange.emit(selEvent.item);
339 this.propagateChange(selEvent.item);
342 // Adds matching async entries to the entry list
343 // and propagates the search term for pipelining.
344 addAsyncEntries(term: string): Observable<string> {
346 if (!term || !this.asyncDataSource) {
350 let searchTerm: string;
352 if (searchTerm === '_CLICK_') {
353 if (this.asyncSupportsEmptyTermClick) {
360 return new Observable(observer => {
361 this.asyncDataSource(searchTerm).subscribe(
362 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
365 observer.next(searchTerm);
372 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
375 distinctUntilChanged(),
377 // Merge click actions in with the stream of text entry
379 // Inject a specifier indicating the source of the
380 // action is a user click instead of a text entry.
381 // This tells the filter to show all values in sync mode.
382 this.click$.pipe(filter(() =>
383 !this.instance.isPopupOpen()
384 )).pipe(mapTo('_CLICK_'))
387 // mergeMap coalesces an observable into our stream.
388 mergeMap(term => this.addAsyncEntries(term)),
389 map((term: string) => {
391 // Display no values when the input is empty and no
392 // click action occurred.
393 if (term === '') { return []; }
395 // In sync-data mode, a click displays the full list.
396 if (term === '_CLICK_' && !this.asyncDataSource) {
397 return this.entrylist;
400 // Filter entrylist whose labels substring-match the
402 return this.entrylist.filter(entry => {
403 const label = entry.label || entry.id;
404 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
410 writeValue(value: ComboboxEntry) {
411 if (value !== undefined && value !== null) {
412 this.startId = value.id;
413 this.applySelection();
417 registerOnChange(fn) {
418 this.propagateChange = fn;
421 registerOnTouched(fn) {
422 this.propagateTouch = fn;