2 * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
3 * <!-- see also <eg-combobox-entry> -->
6 import {Component, OnInit, Input, Output, ViewChild,
7 Directive, ViewChildren, QueryList, AfterViewInit,
8 TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
9 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
10 import {Observable, of, Subject} from 'rxjs';
11 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
12 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
13 import {StoreService} from '@eg/core/store.service';
14 import {IdlService, IdlObject} from '@eg/core/idl.service';
15 import {PcrudService} from '@eg/core/pcrud.service';
16 import {OrgService} from '@eg/core/org.service';
18 export interface ComboboxEntry {
20 // If no label is provided, the 'id' value is used.
23 userdata?: any; // opaque external value; ignored by this component.
28 selector: 'ng-template[egIdlClass]'
30 export class IdlClassTemplateDirective {
31 @Input() egIdlClass: string;
32 constructor(public template: TemplateRef<any>) {}
36 selector: 'eg-combobox',
37 templateUrl: './combobox.component.html',
39 .icons {margin-left:-18px}
40 .material-icons {font-size: 16px;font-weight:bold}
43 provide: NG_VALUE_ACCESSOR,
44 useExisting: forwardRef(() => ComboboxComponent),
48 export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterViewInit {
50 selected: ComboboxEntry;
51 click$: Subject<string>;
52 entrylist: ComboboxEntry[];
54 @ViewChild('instance', { static: true }) instance: NgbTypeahead;
55 @ViewChild('defaultDisplayTemplate', { static: true}) t: TemplateRef<any>;
56 @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
58 // Applies a name attribute to the input.
60 @Input() name: string;
62 // Placeholder text for selector input
63 @Input() placeholder = '';
65 @Input() persistKey: string; // TODO
67 @Input() allowFreeText = false;
69 @Input() inputSize: number = null;
71 // Add a 'required' attribute to the input
73 @Input() set required(r: boolean) {
79 @Input() set disabled(d: boolean) {
83 // Entry ID of the default entry to select (optional)
84 // onChange() is NOT fired when applying the default value,
85 // unless startIdFiresOnChange is set to true.
86 @Input() startId: any = null;
87 @Input() idlClass: string;
88 @Input() startIdFiresOnChange: boolean;
90 // Allow the selected entry ID to be passed via the template
91 // This does NOT not emit onChange events.
92 @Input() set selectedId(id: any) {
93 if (id === undefined) { return; }
95 // clear on explicit null
96 if (id === null) { this.selected = null; }
98 if (this.entrylist.length) {
99 this.selected = this.entrylist.filter(e => e.id === id)[0];
102 if (!this.selected) {
103 // It's possible the selected ID lives in a set of entries
104 // that are yet to be provided.
107 this.pcrud.retrieve(this.idlClass, id)
111 label: this.getFmRecordLabel(rec),
114 this.selected = this.entrylist.filter(e => e.id === id)[0];
120 get selectedId(): any {
121 return this.selected ? this.selected.id : null;
124 @Input() idlField: string;
125 @Input() idlIncludeLibraryInLabel: string;
126 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
128 // If true, an async data search is allowed to fetch all
129 // values when given an empty term. This should be used only
130 // if the maximum number of entries returned by the data source
131 // is known to be no more than a couple hundred.
132 @Input() asyncSupportsEmptyTermClick: boolean;
134 // Useful for efficiently preventing duplicate async entries
135 asyncIds: {[idx: string]: boolean};
137 // True if a default selection has been made.
138 defaultSelectionApplied: boolean;
140 @Input() set entries(el: ComboboxEntry[]) {
143 if (this.entrylistMatches(el)) {
144 // Avoid reprocessing data we already have.
150 // new set of entries essentially means a new instance. reset.
151 this.defaultSelectionApplied = false;
152 this.applySelection();
154 // It's possible to provide an entrylist at load time, but
155 // fetch all future data via async data source. Track the
156 // values we already have so async lookup won't add them again.
157 // A new entry list wipes out any existing async values.
159 el.forEach(entry => this.asyncIds['' + entry.id] = true);
163 // When provided use this as the display template for each entry.
164 @Input() displayTemplate: TemplateRef<any>;
166 // Emitted when the value is changed via UI.
167 // When the UI value is cleared, null is emitted.
168 @Output() onChange: EventEmitter<ComboboxEntry>;
170 // Useful for massaging the match string prior to comparison
171 // and display. Default version trims leading/trailing spaces.
172 formatDisplayString: (e: ComboboxEntry) => string;
174 idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
175 getFmRecordLabel: (fm: IdlObject) => string;
177 // Stub functions required by ControlValueAccessor
178 propagateChange = (_: any) => {};
179 propagateTouch = () => {};
182 private elm: ElementRef,
183 private store: StoreService,
184 private idl: IdlService,
185 private pcrud: PcrudService,
186 private org: OrgService,
190 this.click$ = new Subject<string>();
191 this.onChange = new EventEmitter<ComboboxEntry>();
192 this.defaultSelectionApplied = false;
194 this.formatDisplayString = (result: ComboboxEntry) => {
195 const display = result.label || result.id;
196 return (display + '').trim();
199 this.getFmRecordLabel = (fm: IdlObject) => {
200 // FIXME: it would be cleaner if we could somehow use
201 // the per-IDL-class ng-templates directly
202 switch (this.idlClass) {
204 return fm.code() + ' (' + fm.year() + ')';
207 return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
210 const field = this.idlField;
211 if (this.idlIncludeLibraryInLabel) {
212 return fm[field]() + ' (' + fm[this.idlIncludeLibraryInLabel]().shortname() + ')';
222 const classDef = this.idl.classes[this.idlClass];
223 const pkeyField = classDef.pkey;
226 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
229 if (!this.idlField) {
230 this.idlField = this.idl.getClassSelector(this.idlClass);
233 this.asyncDataSource = term => {
234 const field = this.idlField;
236 const extra_args = { order_by : {} };
237 args[field] = {'ilike': `%${term}%`}; // could -or search on label
238 extra_args['order_by'][this.idlClass] = field;
239 extra_args['limit'] = 100;
240 if (this.idlIncludeLibraryInLabel) {
241 extra_args['flesh'] = 1;
242 const flesh_fields: Object = {};
243 flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
244 extra_args['flesh_fields'] = flesh_fields;
245 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
247 id: data[pkeyField](),
248 label: this.getFmRecordLabel(data),
253 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
254 return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
262 this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
263 acc[cur.egIdlClass] = cur.template;
269 this.click$.next($event.target.value);
272 getOrgShortname(ou: any) {
273 if (typeof ou === 'object') {
274 return ou.shortname();
276 return this.org.get(ou).shortname();
281 // Give the input a chance to focus then fire the click
282 // handler to force open the typeahead
283 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
284 setTimeout(() => this.click$.next(''));
287 // Returns true if the 2 entries are equivalent.
288 entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
292 e1.label === e2.label &&
293 e1.freetext === e2.freetext
297 // Returns true if the 2 lists are equivalent.
298 entrylistMatches(el: ComboboxEntry[]): boolean {
299 if (el.length === 0 && this.entrylist.length === 0) {
300 // Empty arrays are only equivalent if they are the same array,
301 // since the caller may provide an array that starts empty, but
302 // is later populated.
303 return el === this.entrylist;
305 if (el.length !== this.entrylist.length) {
308 for (let i = 0; i < el.length; i++) {
309 const mine = this.entrylist[i];
310 if (!mine || !this.entriesMatch(mine, el[i])) {
317 // Apply a default selection where needed
320 if (this.startId !== null &&
321 this.entrylist && !this.defaultSelectionApplied) {
324 this.entrylist.filter(e => e.id === this.startId)[0];
327 this.selected = entry;
328 this.defaultSelectionApplied = true;
329 if (this.startIdFiresOnChange) {
330 this.selectorChanged(
331 {item: this.selected, preventDefault: () => true});
337 // Called by combobox-entry.component
338 addEntry(entry: ComboboxEntry) {
339 this.entrylist.push(entry);
340 this.applySelection();
343 // Manually set the selected value by ID.
344 // This does NOT fire the onChange handler.
345 // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
346 applyEntryId(entryId: any) {
347 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
350 addAsyncEntry(entry: ComboboxEntry) {
351 // Avoid duplicate async entries
352 if (!this.asyncIds['' + entry.id]) {
353 this.asyncIds['' + entry.id] = true;
354 this.addEntry(entry);
358 hasEntry(entryId: any): boolean {
359 return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
363 // When the selected value is a string it means we have either
364 // no value (user cleared the input) or a free-text value.
366 if (typeof this.selected === 'string') {
368 if (this.allowFreeText && this.selected !== '') {
369 // Free text entered which does not match a known entry
370 // translate it into a dummy ComboboxEntry
373 label: this.selected,
379 this.selected = null;
382 // Manually fire the onchange since NgbTypeahead fails
383 // to fire the onchange when the value is cleared.
384 this.selectorChanged(
385 {item: this.selected, preventDefault: () => true});
387 this.propagateTouch();
390 // Fired by the typeahead to inform us of a change.
391 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
392 this.onChange.emit(selEvent.item);
393 this.propagateChange(selEvent.item);
396 // Adds matching async entries to the entry list
397 // and propagates the search term for pipelining.
398 addAsyncEntries(term: string): Observable<string> {
400 if (!term || !this.asyncDataSource) {
404 let searchTerm: string;
406 if (searchTerm === '_CLICK_') {
407 if (this.asyncSupportsEmptyTermClick) {
414 return new Observable(observer => {
415 this.asyncDataSource(searchTerm).subscribe(
416 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
419 observer.next(searchTerm);
426 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
429 distinctUntilChanged(),
431 // Merge click actions in with the stream of text entry
433 // Inject a specifier indicating the source of the
434 // action is a user click instead of a text entry.
435 // This tells the filter to show all values in sync mode.
436 this.click$.pipe(filter(() =>
437 !this.instance.isPopupOpen()
438 )).pipe(mapTo('_CLICK_'))
441 // mergeMap coalesces an observable into our stream.
442 mergeMap(term => this.addAsyncEntries(term)),
443 map((term: string) => {
445 // Display no values when the input is empty and no
446 // click action occurred.
447 if (term === '') { return []; }
449 // In sync-data mode, a click displays the full list.
450 if (term === '_CLICK_' && !this.asyncDataSource) {
451 return this.entrylist;
454 // Filter entrylist whose labels substring-match the
456 return this.entrylist.filter(entry => {
457 const label = entry.label || entry.id;
458 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
464 writeValue(value: ComboboxEntry) {
465 if (value !== undefined && value !== null) {
466 this.startId = value.id;
467 this.applySelection();
471 registerOnChange(fn) {
472 this.propagateChange = fn;
475 registerOnTouched(fn) {
476 this.propagateTouch = fn;