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.
30 selector: 'ng-template[egIdlClass]'
32 export class IdlClassTemplateDirective {
33 @Input() egIdlClass: string;
34 constructor(public template: TemplateRef<any>) {}
38 selector: 'eg-combobox',
39 templateUrl: './combobox.component.html',
41 .icons {margin-left:-18px}
42 .material-icons {font-size: 16px;font-weight:bold}
45 provide: NG_VALUE_ACCESSOR,
46 useExisting: forwardRef(() => ComboboxComponent),
50 export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {
53 selected: ComboboxEntry;
54 click$: Subject<string>;
55 entrylist: ComboboxEntry[];
57 @ViewChild('instance', { static: true }) instance: NgbTypeahead;
58 @ViewChild('defaultDisplayTemplate', { static: true}) defaultDisplayTemplate: TemplateRef<any>;
59 @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
61 @Input() domId = 'eg-combobox-' + ComboboxComponent.domIdAuto++;
63 // Applies a name attribute to the input.
65 @Input() name: string;
67 // Placeholder text for selector input
68 @Input() placeholder = '';
70 @Input() persistKey: string; // TODO
72 @Input() allowFreeText = false;
74 @Input() inputSize: number = null;
76 // If true, applies form-control-sm CSS
77 @Input() smallFormControl = false;
79 // Add a 'required' attribute to the input
81 @Input() set required(r: boolean) {
84 // and a 'mandatory' synonym, as an issue
85 // has been observed in at least Firefox 88.0.1
86 // where the left border indicating whether a required
87 // value has been set or not is displayed in the
88 // container of the combobox, not just the dropdown
89 @Input() set mandatory(r: boolean) {
93 // Array of entry identifiers to disable in the selector
94 @Input() disableEntries: any[] = [];
98 @Input() set disabled(d: boolean) {
102 // Entry ID of the default entry to select (optional)
103 // onChange() is NOT fired when applying the default value,
104 // unless startIdFiresOnChange is set to true.
105 @Input() startId: any = null;
106 @Input() idlClass: string;
107 @Input() idlBaseQuery: any = null;
108 @Input() startIdFiresOnChange: boolean;
110 // Allow the selected entry ID to be passed via the template
111 // This does NOT not emit onChange events.
112 @Input() set selectedId(id: any) {
113 if (id === undefined) { return; }
115 // clear on explicit null
116 if (id === null) { this.selected = null; }
118 if (this.entrylist.length) {
119 this.selected = this.entrylist.filter(e => e.id === id)[0];
122 if (!this.selected) {
123 // It's possible the selected ID lives in a set of entries
124 // that are yet to be provided.
127 this.pcrud.retrieve(this.idlClass, id)
131 label: this.getFmRecordLabel(rec),
133 disabled : this.disableEntries.includes(id)
135 this.selected = this.entrylist.filter(e => e.id === id)[0];
141 get selectedId(): any {
142 return this.selected ? this.selected.id : null;
145 @Input() idlField: string;
146 @Input() idlIncludeLibraryInLabel: string;
147 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
149 // If true, an async data search is allowed to fetch all
150 // values when given an empty term. This should be used only
151 // if the maximum number of entries returned by the data source
152 // is known to be no more than a couple hundred.
153 @Input() asyncSupportsEmptyTermClick: boolean;
155 // Useful for efficiently preventing duplicate async entries
156 asyncIds: {[idx: string]: boolean};
158 // True if a default selection has been made.
159 defaultSelectionApplied: boolean;
161 @Input() set entries(el: ComboboxEntry[]) {
164 if (this.entrylistMatches(el)) {
165 // Avoid reprocessing data we already have.
171 // new set of entries essentially means a new instance. reset.
172 this.defaultSelectionApplied = false;
173 this.applySelection();
175 // It's possible to provide an entrylist at load time, but
176 // fetch all future data via async data source. Track the
177 // values we already have so async lookup won't add them again.
178 // A new entry list wipes out any existing async values.
180 el.forEach(entry => this.asyncIds['' + entry.id] = true);
184 // When provided use this as the display template for each entry.
185 @Input() displayTemplate: TemplateRef<any>;
187 // Emitted when the value is changed via UI.
188 // When the UI value is cleared, null is emitted.
189 @Output() onChange: EventEmitter<ComboboxEntry>;
191 // Useful for massaging the match string prior to comparison
192 // and display. Default version trims leading/trailing spaces.
193 formatDisplayString: (e: ComboboxEntry) => string;
195 idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
196 getFmRecordLabel: (fm: IdlObject) => string;
198 // Stub functions required by ControlValueAccessor
199 propagateChange = (_: any) => {};
200 propagateTouch = () => {};
203 private elm: ElementRef,
204 private store: StoreService,
205 private idl: IdlService,
206 private pcrud: PcrudService,
207 private org: OrgService,
211 this.click$ = new Subject<string>();
212 this.onChange = new EventEmitter<ComboboxEntry>();
213 this.defaultSelectionApplied = false;
215 this.formatDisplayString = (result: ComboboxEntry) => {
216 const display = result.label || result.id;
217 return (display + '').trim();
220 this.getFmRecordLabel = (fm: IdlObject) => {
221 // FIXME: it would be cleaner if we could somehow use
222 // the per-IDL-class ng-templates directly
223 switch (this.idlClass) {
225 return fm.course_number() + ': ' + fm.name();
228 return fm.code() + ' (' + fm.year() + ')' +
229 ' (' + this.getOrgShortname(fm.org()) + ')';
232 return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
235 const field = this.idlField;
236 if (this.idlIncludeLibraryInLabel) {
237 return fm[field]() + ' (' + fm[this.idlIncludeLibraryInLabel]().shortname() + ')';
247 const classDef = this.idl.classes[this.idlClass];
248 const pkeyField = classDef.pkey;
251 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
254 if (!this.idlField) {
255 this.idlField = this.idl.getClassSelector(this.idlClass);
258 this.asyncDataSource = term => {
259 const field = this.idlField;
261 if (this.idlBaseQuery) {
262 args = this.idlBaseQuery;
264 const extra_args = { order_by : {} };
265 args[field] = {'ilike': `%${term}%`}; // could -or search on label
266 extra_args['order_by'][this.idlClass] = field;
267 extra_args['limit'] = 100;
268 if (this.idlIncludeLibraryInLabel) {
269 extra_args['flesh'] = 1;
270 const flesh_fields: Object = {};
271 flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
272 extra_args['flesh_fields'] = flesh_fields;
273 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
275 id: data[pkeyField](),
276 label: this.getFmRecordLabel(data),
281 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
282 return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
290 this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
291 acc[cur.egIdlClass] = cur.template;
296 ngOnChanges(changes: SimpleChanges) {
297 let firstTime = true;
298 Object.keys(changes).forEach(key => {
299 if (!changes[key].firstChange) {
304 if ('selectedId' in changes) {
305 if (!changes.selectedId.currentValue) {
307 // In allowFreeText mode, selectedId will be null even
308 // though a freetext value may be present in the combobox.
309 if (this.allowFreeText) {
310 if (this.selected && !this.selected.freetext) {
311 this.selected = null;
314 this.selected = null;
318 if ('idlClass' in changes) {
319 if (!('idlField' in changes)) {
320 // let ngOnInit reset it to the
321 // selector of the new IDL class
322 this.idlField = null;
325 this.entrylist.length = 0;
326 this.selected = null;
333 this.click$.next($event.target.value);
336 getResultTemplate(): TemplateRef<any> {
337 if (this.displayTemplate) {
338 return this.displayTemplate;
340 if (this.idlClass in this.idlDisplayTemplateMap) {
341 return this.idlDisplayTemplateMap[this.idlClass];
343 return this.defaultDisplayTemplate;
346 getOrgShortname(ou: any) {
347 if (typeof ou === 'object') {
348 return ou.shortname();
350 return this.org.get(ou).shortname();
355 // Give the input a chance to focus then fire the click
356 // handler to force open the typeahead
357 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
358 setTimeout(() => this.click$.next(''));
361 // Returns true if the 2 entries are equivalent.
362 entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
366 e1.label === e2.label &&
367 e1.freetext === e2.freetext
371 // Returns true if the 2 lists are equivalent.
372 entrylistMatches(el: ComboboxEntry[]): boolean {
373 if (el.length === 0 && this.entrylist.length === 0) {
374 // Empty arrays are only equivalent if they are the same array,
375 // since the caller may provide an array that starts empty, but
376 // is later populated.
377 return el === this.entrylist;
379 if (el.length !== this.entrylist.length) {
382 for (let i = 0; i < el.length; i++) {
383 const mine = this.entrylist[i];
384 if (!mine || !this.entriesMatch(mine, el[i])) {
391 // Apply a default selection where needed
394 if (this.startId !== null &&
395 this.entrylist && !this.defaultSelectionApplied) {
398 this.entrylist.filter(e => e.id === this.startId)[0];
401 this.selected = entry;
402 this.defaultSelectionApplied = true;
403 if (this.startIdFiresOnChange) {
404 this.selectorChanged(
405 {item: this.selected, preventDefault: () => true});
411 // Called by combobox-entry.component
412 addEntry(entry: ComboboxEntry) {
413 this.entrylist.push(entry);
414 this.applySelection();
417 // Manually set the selected value by ID.
418 // This does NOT fire the onChange handler.
419 // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
420 applyEntryId(entryId: any) {
421 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
424 addAsyncEntry(entry: ComboboxEntry) {
425 // Avoid duplicate async entries
426 if (!this.asyncIds['' + entry.id]) {
427 this.asyncIds['' + entry.id] = true;
428 this.addEntry(entry);
432 hasEntry(entryId: any): boolean {
433 return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
437 // When the selected value is a string it means we have either
438 // no value (user cleared the input) or a free-text value.
440 if (typeof this.selected === 'string') {
442 if (this.allowFreeText && this.selected !== '') {
443 // Free text entered which does not match a known entry
444 // translate it into a dummy ComboboxEntry
447 label: this.selected,
453 this.selected = null;
456 // Manually fire the onchange since NgbTypeahead fails
457 // to fire the onchange when the value is cleared.
458 this.selectorChanged(
459 {item: this.selected, preventDefault: () => true});
461 this.propagateTouch();
464 // Fired by the typeahead to inform us of a change.
465 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
466 this.onChange.emit(selEvent.item);
467 this.propagateChange(selEvent.item);
470 // Adds matching async entries to the entry list
471 // and propagates the search term for pipelining.
472 addAsyncEntries(term: string): Observable<string> {
474 if (!term || !this.asyncDataSource) {
478 let searchTerm: string;
480 if (searchTerm === '_CLICK_') {
481 if (this.asyncSupportsEmptyTermClick) {
488 return new Observable(observer => {
489 this.asyncDataSource(searchTerm).subscribe(
490 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
500 // NgbTypeahead doesn't offer a way to style the dropdown
501 // button directly, so we have to reach up and style it ourselves.
502 applyDisableStyle() {
503 this.disableEntries.forEach(id => {
504 const node = document.getElementById(`${this.domId}-${id}`);
506 const button = node.parentNode as HTMLElement;
507 button.classList.add('disabled');
512 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
515 distinctUntilChanged(),
517 // Merge click actions in with the stream of text entry
519 // Inject a specifier indicating the source of the
520 // action is a user click instead of a text entry.
521 // This tells the filter to show all values in sync mode.
522 this.click$.pipe(filter(() =>
523 !this.instance.isPopupOpen()
524 )).pipe(mapTo('_CLICK_'))
527 // mergeMap coalesces an observable into our stream.
528 mergeMap(term => this.addAsyncEntries(term)),
529 map((term: string) => {
531 // Display no values when the input is empty and no
532 // click action occurred.
533 if (term === '') { return []; }
535 // Clicking always displays the full list.
536 if (term === '_CLICK_') {
537 if (this.asyncDataSource) {
540 // Give the typeahead a chance to open before applying
541 // the disabled entry styling.
542 setTimeout(() => this.applyDisableStyle());
543 return this.entrylist;
547 // Give the typeahead a chance to open before applying
548 // the disabled entry styling.
549 setTimeout(() => this.applyDisableStyle());
551 // Filter entrylist whose labels substring-match the
553 return this.entrylist.filter(entry => {
554 const label = entry.label || entry.id;
555 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
561 writeValue(value: ComboboxEntry) {
562 if (value !== undefined && value !== null) {
563 this.startId = value.id;
564 this.applySelection();
568 registerOnChange(fn) {
569 this.propagateChange = fn;
572 registerOnTouched(fn) {
573 this.propagateTouch = fn;