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 OnChanges, SimpleChanges,
9 TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
10 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
11 import {Observable, of, Subject} from 'rxjs';
12 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
13 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
14 import {StoreService} from '@eg/core/store.service';
15 import {IdlService, IdlObject} from '@eg/core/idl.service';
16 import {PcrudService} from '@eg/core/pcrud.service';
17 import {OrgService} from '@eg/core/org.service';
19 export interface ComboboxEntry {
21 // If no label is provided, the 'id' value is used.
24 userdata?: any; // opaque external value; ignored by this component.
29 selector: 'ng-template[egIdlClass]'
31 export class IdlClassTemplateDirective {
32 @Input() egIdlClass: string;
33 constructor(public template: TemplateRef<any>) {}
37 selector: 'eg-combobox',
38 templateUrl: './combobox.component.html',
40 .icons {margin-left:-18px}
41 .material-icons {font-size: 16px;font-weight:bold}
44 provide: NG_VALUE_ACCESSOR,
45 useExisting: forwardRef(() => ComboboxComponent),
49 export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {
52 selected: ComboboxEntry;
53 click$: Subject<string>;
54 entrylist: ComboboxEntry[];
56 @ViewChild('instance', { static: true }) instance: NgbTypeahead;
57 @ViewChild('defaultDisplayTemplate', { static: true}) defaultDisplayTemplate: TemplateRef<any>;
58 @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
60 @Input() domId = 'eg-combobox-' + ComboboxComponent.domIdAuto++;
62 // Applies a name attribute to the input.
64 @Input() name: string;
66 // Placeholder text for selector input
67 @Input() placeholder = '';
69 @Input() persistKey: string; // TODO
71 @Input() allowFreeText = false;
73 @Input() inputSize: number = null;
75 // If true, applies form-control-sm CSS
76 @Input() smallFormControl = false;
78 // Add a 'required' attribute to the input
80 @Input() set required(r: boolean) {
83 // and a 'mandatory' synonym, as an issue
84 // has been observed in at least Firefox 88.0.1
85 // where the left border indicating whether a required
86 // value has been set or not is displayed in the
87 // container of the combobox, not just the dropdown
88 @Input() set mandatory(r: boolean) {
94 @Input() set disabled(d: boolean) {
98 // Entry ID of the default entry to select (optional)
99 // onChange() is NOT fired when applying the default value,
100 // unless startIdFiresOnChange is set to true.
101 @Input() startId: any = null;
102 @Input() idlClass: string;
103 @Input() idlBaseQuery: any = null;
104 @Input() startIdFiresOnChange: boolean;
106 // Allow the selected entry ID to be passed via the template
107 // This does NOT not emit onChange events.
108 @Input() set selectedId(id: any) {
109 if (id === undefined) { return; }
111 // clear on explicit null
112 if (id === null) { this.selected = null; }
114 if (this.entrylist.length) {
115 this.selected = this.entrylist.filter(e => e.id === id)[0];
118 if (!this.selected) {
119 // It's possible the selected ID lives in a set of entries
120 // that are yet to be provided.
123 this.pcrud.retrieve(this.idlClass, id)
127 label: this.getFmRecordLabel(rec),
130 this.selected = this.entrylist.filter(e => e.id === id)[0];
136 get selectedId(): any {
137 return this.selected ? this.selected.id : null;
140 @Input() idlField: string;
141 @Input() idlIncludeLibraryInLabel: string;
142 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
144 // If true, an async data search is allowed to fetch all
145 // values when given an empty term. This should be used only
146 // if the maximum number of entries returned by the data source
147 // is known to be no more than a couple hundred.
148 @Input() asyncSupportsEmptyTermClick: boolean;
150 // Useful for efficiently preventing duplicate async entries
151 asyncIds: {[idx: string]: boolean};
153 // True if a default selection has been made.
154 defaultSelectionApplied: boolean;
156 @Input() set entries(el: ComboboxEntry[]) {
159 if (this.entrylistMatches(el)) {
160 // Avoid reprocessing data we already have.
166 // new set of entries essentially means a new instance. reset.
167 this.defaultSelectionApplied = false;
168 this.applySelection();
170 // It's possible to provide an entrylist at load time, but
171 // fetch all future data via async data source. Track the
172 // values we already have so async lookup won't add them again.
173 // A new entry list wipes out any existing async values.
175 el.forEach(entry => this.asyncIds['' + entry.id] = true);
179 // When provided use this as the display template for each entry.
180 @Input() displayTemplate: TemplateRef<any>;
182 // Emitted when the value is changed via UI.
183 // When the UI value is cleared, null is emitted.
184 @Output() onChange: EventEmitter<ComboboxEntry>;
186 // Useful for massaging the match string prior to comparison
187 // and display. Default version trims leading/trailing spaces.
188 formatDisplayString: (e: ComboboxEntry) => string;
190 idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
191 getFmRecordLabel: (fm: IdlObject) => string;
193 // Stub functions required by ControlValueAccessor
194 propagateChange = (_: any) => {};
195 propagateTouch = () => {};
198 private elm: ElementRef,
199 private store: StoreService,
200 private idl: IdlService,
201 private pcrud: PcrudService,
202 private org: OrgService,
206 this.click$ = new Subject<string>();
207 this.onChange = new EventEmitter<ComboboxEntry>();
208 this.defaultSelectionApplied = false;
210 this.formatDisplayString = (result: ComboboxEntry) => {
211 const display = result.label || result.id;
212 return (display + '').trim();
215 this.getFmRecordLabel = (fm: IdlObject) => {
216 // FIXME: it would be cleaner if we could somehow use
217 // the per-IDL-class ng-templates directly
218 switch (this.idlClass) {
220 return fm.course_number() + ': ' + fm.name();
223 return fm.code() + ' (' + fm.year() + ')' +
224 ' (' + this.getOrgShortname(fm.org()) + ')';
227 return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
230 const field = this.idlField;
231 if (this.idlIncludeLibraryInLabel) {
232 return fm[field]() + ' (' + fm[this.idlIncludeLibraryInLabel]().shortname() + ')';
242 const classDef = this.idl.classes[this.idlClass];
243 const pkeyField = classDef.pkey;
246 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
249 if (!this.idlField) {
250 this.idlField = this.idl.getClassSelector(this.idlClass);
253 this.asyncDataSource = term => {
254 const field = this.idlField;
256 if (this.idlBaseQuery) {
257 args = this.idlBaseQuery;
259 const extra_args = { order_by : {} };
260 args[field] = {'ilike': `%${term}%`}; // could -or search on label
261 extra_args['order_by'][this.idlClass] = field;
262 extra_args['limit'] = 100;
263 if (this.idlIncludeLibraryInLabel) {
264 extra_args['flesh'] = 1;
265 const flesh_fields: Object = {};
266 flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
267 extra_args['flesh_fields'] = flesh_fields;
268 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
270 id: data[pkeyField](),
271 label: this.getFmRecordLabel(data),
276 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
277 return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
285 this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
286 acc[cur.egIdlClass] = cur.template;
291 ngOnChanges(changes: SimpleChanges) {
292 let firstTime = true;
293 Object.keys(changes).forEach(key => {
294 if (!changes[key].firstChange) {
299 if ('selectedId' in changes) {
300 if (!changes.selectedId.currentValue) {
302 // In allowFreeText mode, selectedId will be null even
303 // though a freetext value may be present in the combobox.
304 if (this.allowFreeText) {
305 if (this.selected && !this.selected.freetext) {
306 this.selected = null;
309 this.selected = null;
313 if ('idlClass' in changes) {
314 if (!('idlField' in changes)) {
315 // let ngOnInit reset it to the
316 // selector of the new IDL class
317 this.idlField = null;
320 this.entrylist.length = 0;
321 this.selected = null;
328 this.click$.next($event.target.value);
331 getResultTemplate(): TemplateRef<any> {
332 if (this.displayTemplate) {
333 return this.displayTemplate;
335 if (this.idlClass in this.idlDisplayTemplateMap) {
336 return this.idlDisplayTemplateMap[this.idlClass];
338 return this.defaultDisplayTemplate;
341 getOrgShortname(ou: any) {
342 if (typeof ou === 'object') {
343 return ou.shortname();
345 return this.org.get(ou).shortname();
350 // Give the input a chance to focus then fire the click
351 // handler to force open the typeahead
352 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
353 setTimeout(() => this.click$.next(''));
356 // Returns true if the 2 entries are equivalent.
357 entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
361 e1.label === e2.label &&
362 e1.freetext === e2.freetext
366 // Returns true if the 2 lists are equivalent.
367 entrylistMatches(el: ComboboxEntry[]): boolean {
368 if (el.length === 0 && this.entrylist.length === 0) {
369 // Empty arrays are only equivalent if they are the same array,
370 // since the caller may provide an array that starts empty, but
371 // is later populated.
372 return el === this.entrylist;
374 if (el.length !== this.entrylist.length) {
377 for (let i = 0; i < el.length; i++) {
378 const mine = this.entrylist[i];
379 if (!mine || !this.entriesMatch(mine, el[i])) {
386 // Apply a default selection where needed
389 if (this.startId !== null &&
390 this.entrylist && !this.defaultSelectionApplied) {
393 this.entrylist.filter(e => e.id === this.startId)[0];
396 this.selected = entry;
397 this.defaultSelectionApplied = true;
398 if (this.startIdFiresOnChange) {
399 this.selectorChanged(
400 {item: this.selected, preventDefault: () => true});
406 // Called by combobox-entry.component
407 addEntry(entry: ComboboxEntry) {
408 this.entrylist.push(entry);
409 this.applySelection();
412 // Manually set the selected value by ID.
413 // This does NOT fire the onChange handler.
414 // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
415 applyEntryId(entryId: any) {
416 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
419 addAsyncEntry(entry: ComboboxEntry) {
420 // Avoid duplicate async entries
421 if (!this.asyncIds['' + entry.id]) {
422 this.asyncIds['' + entry.id] = true;
423 this.addEntry(entry);
427 hasEntry(entryId: any): boolean {
428 return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
432 // When the selected value is a string it means we have either
433 // no value (user cleared the input) or a free-text value.
435 if (typeof this.selected === 'string') {
437 if (this.allowFreeText && this.selected !== '') {
438 // Free text entered which does not match a known entry
439 // translate it into a dummy ComboboxEntry
442 label: this.selected,
448 this.selected = null;
451 // Manually fire the onchange since NgbTypeahead fails
452 // to fire the onchange when the value is cleared.
453 this.selectorChanged(
454 {item: this.selected, preventDefault: () => true});
456 this.propagateTouch();
459 // Fired by the typeahead to inform us of a change.
460 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
461 this.onChange.emit(selEvent.item);
462 this.propagateChange(selEvent.item);
465 // Adds matching async entries to the entry list
466 // and propagates the search term for pipelining.
467 addAsyncEntries(term: string): Observable<string> {
469 if (!term || !this.asyncDataSource) {
473 let searchTerm: string;
475 if (searchTerm === '_CLICK_') {
476 if (this.asyncSupportsEmptyTermClick) {
483 return new Observable(observer => {
484 this.asyncDataSource(searchTerm).subscribe(
485 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
495 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
498 distinctUntilChanged(),
500 // Merge click actions in with the stream of text entry
502 // Inject a specifier indicating the source of the
503 // action is a user click instead of a text entry.
504 // This tells the filter to show all values in sync mode.
505 this.click$.pipe(filter(() =>
506 !this.instance.isPopupOpen()
507 )).pipe(mapTo('_CLICK_'))
510 // mergeMap coalesces an observable into our stream.
511 mergeMap(term => this.addAsyncEntries(term)),
512 map((term: string) => {
514 // Display no values when the input is empty and no
515 // click action occurred.
516 if (term === '') { return []; }
518 // Clicking always displays the full list.
519 if (term === '_CLICK_') {
520 if (this.asyncDataSource) {
523 return this.entrylist;
527 // Filter entrylist whose labels substring-match the
529 return this.entrylist.filter(entry => {
530 const label = entry.label || entry.id;
531 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
537 writeValue(value: ComboboxEntry) {
538 if (value !== undefined && value !== null) {
539 this.startId = value.id;
540 this.applySelection();
544 registerOnChange(fn) {
545 this.propagateChange = fn;
548 registerOnTouched(fn) {
549 this.propagateTouch = fn;