1 /* eslint-disable no-case-declarations */
3 * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
4 * <!-- see also <eg-combobox-entry> -->
7 import {Component, OnInit, Input, Output, ViewChild,
8 Directive, ViewChildren, QueryList, AfterViewInit,
9 OnChanges, SimpleChanges,
10 TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
11 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
12 import {Observable, of, Subject} from 'rxjs';
13 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
14 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
15 import {StoreService} from '@eg/core/store.service';
16 import {IdlService, IdlObject} from '@eg/core/idl.service';
17 import {PcrudService} from '@eg/core/pcrud.service';
18 import {OrgService} from '@eg/core/org.service';
20 export interface ComboboxEntry {
22 // If no label is provided, the 'id' value is used.
25 userdata?: any; // opaque external value; ignored by this component.
31 selector: 'ng-template[egIdlClass]'
33 export class IdlClassTemplateDirective {
34 @Input() egIdlClass: string;
35 constructor(public template: TemplateRef<any>) {}
39 selector: 'eg-combobox',
40 templateUrl: './combobox.component.html',
42 .icons {margin-left:-18px}
43 .material-icons {font-size: 16px;font-weight:bold}
46 provide: NG_VALUE_ACCESSOR,
47 useExisting: forwardRef(() => ComboboxComponent),
51 export class ComboboxComponent
52 implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {
56 selected: ComboboxEntry;
57 click$: Subject<string>;
58 entrylist: ComboboxEntry[];
60 @ViewChild('instance', {static: false}) instance: NgbTypeahead;
61 @ViewChild('defaultDisplayTemplate', {static: true}) defaultDisplayTemplate: TemplateRef<any>;
62 @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
64 @Input() domId = 'eg-combobox-' + ComboboxComponent.domIdAuto++;
66 // Applies a name attribute to the input.
68 @Input() name: string;
70 // Placeholder text for selector input
71 @Input() placeholder = '';
73 @Input() persistKey: string; // TODO
75 @Input() allowFreeText = false;
77 @Input() inputSize: number = null;
79 // If true, applies form-control-sm CSS
80 @Input() smallFormControl = false;
82 // If true, the typeahead only matches values that start with
83 // the value typed as opposed to a 'contains' match.
84 @Input() startsWith = false;
86 // Add a 'required' attribute to the input
88 @Input() set required(r: boolean) {
91 // and a 'mandatory' synonym, as an issue
92 // has been observed in at least Firefox 88.0.1
93 // where the left border indicating whether a required
94 // value has been set or not is displayed in the
95 // container of the combobox, not just the dropdown
96 @Input() set mandatory(r: boolean) {
100 // Array of entry identifiers to disable in the selector
101 @Input() disableEntries: any[] = [];
105 @Input() set disabled(d: boolean) {
109 // Entry ID of the default entry to select (optional)
110 // onChange() is NOT fired when applying the default value,
111 // unless startIdFiresOnChange is set to true.
112 @Input() startId: any = null;
113 @Input() idlClass: string;
114 @Input() idlBaseQuery: any = null;
115 @Input() startIdFiresOnChange: boolean;
117 // This will be appended to the async data retrieval query
118 // when fetching objects by idlClass.
119 @Input() idlQueryAnd: {[field: string]: any};
121 @Input() idlQuerySort: {[cls: string]: string};
123 // Display the selected value as text instead of within
125 @Input() readOnly = false;
127 // Allow the selected entry ID to be passed via the template
128 // This does NOT not emit onChange events.
129 @Input() set selectedId(id: any) {
130 if (id === undefined) { return; }
132 // clear on explicit null
134 this.selected = null;
138 if (this.entrylist.length) {
139 this.selected = this.entrylist.filter(e => e.id === id)[0];
142 if (!this.selected) {
143 // It's possible the selected ID lives in a set of entries
144 // that are yet to be provided.
147 this.pcrud.retrieve(this.idlClass, id)
151 label: this.getFmRecordLabel(rec),
153 disabled : this.disableEntries.includes(id)
155 this.selected = this.entrylist.filter(e => e.id === id)[0];
161 get selectedId(): any {
162 return this.selected ? this.selected.id : null;
165 @Input() idlField: string;
166 @Input() idlIncludeLibraryInLabel: string;
167 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
169 // If true, an async data search is allowed to fetch all
170 // values when given an empty term. This should be used only
171 // if the maximum number of entries returned by the data source
172 // is known to be no more than a couple hundred.
173 @Input() asyncSupportsEmptyTermClick: boolean;
175 // Useful for efficiently preventing duplicate async entries
176 asyncIds: {[idx: string]: boolean};
178 // True if a default selection has been made.
179 defaultSelectionApplied: boolean;
181 @Input() set entries(el: ComboboxEntry[]) {
184 if (this.entrylistMatches(el)) {
185 // Avoid reprocessing data we already have.
191 // new set of entries essentially means a new instance. reset.
192 this.defaultSelectionApplied = false;
193 this.applySelection();
195 // It's possible to provide an entrylist at load time, but
196 // fetch all future data via async data source. Track the
197 // values we already have so async lookup won't add them again.
198 // A new entry list wipes out any existing async values.
200 el.forEach(entry => this.asyncIds['' + entry.id] = true);
204 // When provided use this as the display template for each entry.
205 @Input() displayTemplate: TemplateRef<any>;
207 // Emitted when the value is changed via UI.
208 // When the UI value is cleared, null is emitted.
209 @Output() onChange: EventEmitter<ComboboxEntry>;
211 // Useful for massaging the match string prior to comparison
212 // and display. Default version trims leading/trailing spaces.
213 formatDisplayString: (e: ComboboxEntry) => string;
215 idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
216 getFmRecordLabel: (fm: IdlObject) => string;
218 // Stub functions required by ControlValueAccessor
219 propagateChange = (_: any) => {};
220 propagateTouch = () => {};
223 private elm: ElementRef,
224 private store: StoreService,
225 private idl: IdlService,
226 private pcrud: PcrudService,
227 private org: OrgService,
231 this.click$ = new Subject<string>();
232 this.onChange = new EventEmitter<ComboboxEntry>();
233 this.defaultSelectionApplied = false;
235 this.formatDisplayString = (result: ComboboxEntry) => {
236 const display = result.label || result.id;
237 return (display + '').trim();
240 this.getFmRecordLabel = (fm: IdlObject) => {
241 // FIXME: it would be cleaner if we could somehow use
242 // the per-IDL-class ng-templates directly
243 switch (this.idlClass) {
245 return fm.course_number() + ': ' + fm.name();
247 return fm.code() + ' (' + fm.year() + ')' +
248 ' (' + this.getOrgShortname(fm.org()) + ')';
250 return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
252 const field = this.idlField;
253 if (this.idlIncludeLibraryInLabel) {
254 return fm[field]() + ' (' + this.getOrgShortname(fm[this.idlIncludeLibraryInLabel]()) + ')';
264 const classDef = this.idl.classes[this.idlClass];
265 const pkeyField = classDef.pkey;
268 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
271 if (!this.idlField) {
272 this.idlField = this.idl.getClassSelector(this.idlClass);
275 this.asyncDataSource = term => {
276 const field = this.idlField;
278 if (this.idlBaseQuery) {
279 args = this.idlBaseQuery;
281 const extra_args = { order_by : {} };
282 if (this.startsWith) {
283 args[field] = {'ilike': `${term}%`};
285 args[field] = {'ilike': `%${term}%`}; // could -or search on label
287 if (this.idlQueryAnd) {
288 Object.assign(args, this.idlQueryAnd);
290 if (this.idlQuerySort) {
291 extra_args['order_by'] = this.idlQuerySort;
293 extra_args['order_by'][this.idlClass] = field;
295 extra_args['limit'] = 100;
296 if (this.idlIncludeLibraryInLabel) {
297 extra_args['flesh'] = 1;
298 const flesh_fields: Object = {};
299 flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
300 extra_args['flesh_fields'] = flesh_fields;
301 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
303 id: data[pkeyField](),
304 label: this.getFmRecordLabel(data),
309 return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
310 return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
318 this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
319 acc[cur.egIdlClass] = cur.template;
324 ngOnChanges(changes: SimpleChanges) {
325 let firstTime = true;
326 Object.keys(changes).forEach(key => {
327 if (!changes[key].firstChange) {
332 if ('selectedId' in changes) {
333 if (!changes.selectedId.currentValue) {
335 // In allowFreeText mode, selectedId will be null even
336 // though a freetext value may be present in the combobox.
337 if (this.allowFreeText) {
338 if (this.selected && !this.selected.freetext) {
339 this.selected = null;
342 this.selected = null;
346 if ('idlClass' in changes) {
347 if (!('idlField' in changes)) {
348 // let ngOnInit reset it to the
349 // selector of the new IDL class
350 this.idlField = null;
353 this.entrylist.length = 0;
354 this.selected = null;
357 if ('idlQueryAnd' in changes) {
359 this.entrylist.length = 0;
360 this.selected = null;
367 this.click$.next($event.target.value);
370 getResultTemplate(): TemplateRef<any> {
371 if (this.displayTemplate) {
372 return this.displayTemplate;
374 if (this.idlClass in this.idlDisplayTemplateMap) {
375 return this.idlDisplayTemplateMap[this.idlClass];
377 return this.defaultDisplayTemplate;
380 getOrgShortname(ou: any) {
381 if (typeof ou === 'object') {
382 return ou.shortname();
384 return this.org.get(ou).shortname();
389 // Give the input a chance to focus then fire the click
390 // handler to force open the typeahead
391 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
392 setTimeout(() => this.click$.next(''));
395 // Returns true if the 2 entries are equivalent.
396 entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
400 e1.label === e2.label &&
401 e1.freetext === e2.freetext
405 // Returns true if the 2 lists are equivalent.
406 entrylistMatches(el: ComboboxEntry[]): boolean {
407 if (el.length === 0 && this.entrylist.length === 0) {
408 // Empty arrays are only equivalent if they are the same array,
409 // since the caller may provide an array that starts empty, but
410 // is later populated.
411 return el === this.entrylist;
413 if (el.length !== this.entrylist.length) {
416 for (let i = 0; i < el.length; i++) {
417 const mine = this.entrylist[i];
418 if (!mine || !this.entriesMatch(mine, el[i])) {
425 // Apply a default selection where needed
428 if (this.entrylist && !this.defaultSelectionApplied) {
431 this.entrylist.filter(e => e.id === this.startId)[0];
434 this.selected = entry;
435 this.defaultSelectionApplied = true;
436 if (this.startIdFiresOnChange) {
437 this.selectorChanged(
438 {item: this.selected, preventDefault: () => true});
444 // Called by combobox-entry.component
445 addEntry(entry: ComboboxEntry) {
446 this.entrylist.push(entry);
447 this.applySelection();
450 // Manually set the selected value by ID.
451 // This does NOT fire the onChange handler.
452 // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
453 applyEntryId(entryId: any) {
454 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
457 addAsyncEntry(entry: ComboboxEntry) {
458 if (!entry) { return; }
459 // Avoid duplicate async entries
460 if (!this.asyncIds['' + entry.id]) {
461 this.asyncIds['' + entry.id] = true;
462 this.addEntry(entry);
466 hasEntry(entryId: any): boolean {
467 return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
471 // When the selected value is a string it means we have either
472 // no value (user cleared the input) or a free-text value.
474 if (typeof this.selected === 'string') {
476 if (this.allowFreeText && this.selected !== '') {
477 const freeText = this.entrylist.filter(e => e.id === null)[0];
481 // If we already had a free text entry, just replace
482 // the label with the new value
483 freeText.label = this.selected;
484 this.selected = freeText;
488 // Free text entered which does not match a known entry
489 // translate it into a dummy ComboboxEntry
492 label: this.selected,
499 this.selected = null;
502 // Manually fire the onchange since NgbTypeahead fails
503 // to fire the onchange when the value is cleared.
504 this.selectorChanged(
505 {item: this.selected, preventDefault: () => true});
507 this.propagateTouch();
510 // Fired by the typeahead to inform us of a change.
511 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
512 this.onChange.emit(selEvent.item);
513 this.propagateChange(selEvent.item);
516 // Adds matching async entries to the entry list
517 // and propagates the search term for pipelining.
518 addAsyncEntries(term: string): Observable<string> {
520 if (!term || !this.asyncDataSource) {
524 let searchTerm = term;
525 if (term === '_CLICK_') {
526 if (this.asyncSupportsEmptyTermClick) {
527 // Search for "all", but retain and propage the _CLICK_
528 // term so the filter knows to open the selector
531 // Skip the final filter map and display nothing.
536 return new Observable(observer => {
537 this.asyncDataSource(searchTerm).subscribe(
538 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
539 (err: unknown) => {},
548 // NgbTypeahead doesn't offer a way to style the dropdown
549 // button directly, so we have to reach up and style it ourselves.
550 applyDisableStyle() {
551 this.disableEntries.forEach(id => {
552 const node = document.getElementById(`${this.domId}-${id}`);
554 const button = node.parentNode as HTMLElement;
555 button.classList.add('disabled');
560 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
562 // eslint-disable-next-line no-magic-numbers
564 distinctUntilChanged(),
566 // Merge click actions in with the stream of text entry
568 // Inject a specifier indicating the source of the
569 // action is a user click instead of a text entry.
570 // This tells the filter to show all values in sync mode.
571 this.click$.pipe(filter(() =>
572 !this.instance.isPopupOpen()
573 )).pipe(mapTo('_CLICK_'))
576 // mergeMap coalesces an observable into our stream.
577 mergeMap(term => this.addAsyncEntries(term)),
578 map((term: string) => {
580 // Display no values when the input is empty and no
581 // click action occurred.
582 if (term === '') { return []; }
584 // If we make it this far, _CLICK_ means show everything.
585 if (term === '_CLICK_') { term = ''; }
587 // Give the typeahead a chance to open before applying
588 // the disabled entry styling.
589 setTimeout(() => this.applyDisableStyle());
591 // Filter entrylist whose labels substring-match the
593 return this.entrylist.filter(entry => {
594 const label = String(entry.label) || String(entry.id);
595 if (!label) { return false; }
597 if (this.startsWith) {
598 return label.toLowerCase().startsWith(term.toLowerCase());
600 return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
607 writeValue(value: ComboboxEntry) {
608 if (value !== undefined && value !== null) {
609 this.startId = value.id;
610 this.applySelection();
614 registerOnChange(fn) {
615 this.propagateChange = fn;
618 registerOnTouched(fn) {
619 this.propagateTouch = fn;