]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
LP1904036 Combobox supports freetext startId entries
[Evergreen.git] / Open-ILS / src / eg2 / src / app / share / combobox / combobox.component.ts
1 /**
2  * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
3  *  <!-- see also <eg-combobox-entry> -->
4  * </eg-combobox>
5  */
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';
18
19 export interface ComboboxEntry {
20   id: any;
21   // If no label is provided, the 'id' value is used.
22   label?: string;
23   freetext?: boolean;
24   userdata?: any; // opaque external value; ignored by this component.
25   fm?: IdlObject;
26   disabled?: boolean;
27 }
28
29 @Directive({
30     selector: 'ng-template[egIdlClass]'
31 })
32 export class IdlClassTemplateDirective {
33   @Input() egIdlClass: string;
34   constructor(public template: TemplateRef<any>) {}
35 }
36
37 @Component({
38   selector: 'eg-combobox',
39   templateUrl: './combobox.component.html',
40   styles: [`
41     .icons {margin-left:-18px}
42     .material-icons {font-size: 16px;font-weight:bold}
43   `],
44   providers: [{
45     provide: NG_VALUE_ACCESSOR,
46     useExisting: forwardRef(() => ComboboxComponent),
47     multi: true
48   }]
49 })
50 export class ComboboxComponent
51     implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {
52
53     static domIdAuto = 0;
54
55     selected: ComboboxEntry;
56     click$: Subject<string>;
57     entrylist: ComboboxEntry[];
58
59     @ViewChild('instance', {static: false}) instance: NgbTypeahead;
60     @ViewChild('defaultDisplayTemplate', {static: true}) defaultDisplayTemplate: TemplateRef<any>;
61     @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
62
63     @Input() domId = 'eg-combobox-' + ComboboxComponent.domIdAuto++;
64
65     // Applies a name attribute to the input.
66     // Useful in forms.
67     @Input() name: string;
68
69     // Placeholder text for selector input
70     @Input() placeholder = '';
71
72     @Input() persistKey: string; // TODO
73
74     @Input() allowFreeText = false;
75
76     @Input() inputSize: number = null;
77
78     // If true, applies form-control-sm CSS
79     @Input() smallFormControl = false;
80
81     // If true, the typeahead only matches values that start with
82     // the value typed as opposed to a 'contains' match.
83     @Input() startsWith = false;
84
85     // Add a 'required' attribute to the input
86     isRequired: boolean;
87     @Input() set required(r: boolean) {
88         this.isRequired = r;
89     }
90     // and a 'mandatory' synonym, as an issue
91     // has been observed in at least Firefox 88.0.1
92     // where the left border indicating whether a required
93     // value has been set or not is displayed in the
94     // container of the combobox, not just the dropdown
95     @Input() set mandatory(r: boolean) {
96         this.isRequired = r;
97     }
98
99     // Array of entry identifiers to disable in the selector
100     @Input() disableEntries: any[] = [];
101
102     // Disable the input
103     isDisabled: boolean;
104     @Input() set disabled(d: boolean) {
105         this.isDisabled = d;
106     }
107
108     // Entry ID of the default entry to select (optional)
109     // onChange() is NOT fired when applying the default value,
110     // unless startIdFiresOnChange is set to true.
111     @Input() startId: any = null;
112     @Input() idlClass: string;
113     @Input() idlBaseQuery: any = null;
114     @Input() startIdFiresOnChange: boolean;
115
116     // This will be appended to the async data retrieval query
117     // when fetching objects by idlClass.
118     @Input() idlQueryAnd: {[field: string]: any};
119
120     @Input() idlQuerySort: {[cls: string]: string};
121
122     // Display the selected value as text instead of within
123     // the typeahead
124     @Input() readOnly = false;
125
126     // Allow the selected entry ID to be passed via the template
127     // This does NOT not emit onChange events.
128     @Input() set selectedId(id: any) {
129         if (id === undefined) { return; }
130
131         // clear on explicit null
132         if (id === null) {
133             this.selected = null;
134             return;
135         }
136
137         if (this.entrylist.length) {
138             this.selected = this.entrylist.filter(e => e.id === id)[0];
139         }
140
141         if (!this.selected) {
142             // It's possible the selected ID lives in a set of entries
143             // that are yet to be provided.
144             this.startId = id;
145             if (this.idlClass) {
146                 this.pcrud.retrieve(this.idlClass, id)
147                 .subscribe(rec => {
148                     this.entrylist = [{
149                         id: id,
150                         label: this.getFmRecordLabel(rec),
151                         fm: rec,
152                         disabled : this.disableEntries.includes(id)
153                     }];
154                     this.selected = this.entrylist.filter(e => e.id === id)[0];
155                 });
156             }
157         }
158     }
159
160     get selectedId(): any {
161         return this.selected ? this.selected.id : null;
162     }
163
164     @Input() idlField: string;
165     @Input() idlIncludeLibraryInLabel: string;
166     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
167
168     // If true, an async data search is allowed to fetch all
169     // values when given an empty term. This should be used only
170     // if the maximum number of entries returned by the data source
171     // is known to be no more than a couple hundred.
172     @Input() asyncSupportsEmptyTermClick: boolean;
173
174     // Useful for efficiently preventing duplicate async entries
175     asyncIds: {[idx: string]: boolean};
176
177     // True if a default selection has been made.
178     defaultSelectionApplied: boolean;
179
180     @Input() set entries(el: ComboboxEntry[]) {
181         if (el) {
182
183             if (this.entrylistMatches(el)) {
184                 // Avoid reprocessing data we already have.
185                 return;
186             }
187
188             this.entrylist = el;
189
190             // new set of entries essentially means a new instance. reset.
191             this.defaultSelectionApplied = false;
192             this.applySelection();
193
194             // It's possible to provide an entrylist at load time, but
195             // fetch all future data via async data source.  Track the
196             // values we already have so async lookup won't add them again.
197             // A new entry list wipes out any existing async values.
198             this.asyncIds = {};
199             el.forEach(entry => this.asyncIds['' + entry.id] = true);
200         }
201     }
202
203     // When provided use this as the display template for each entry.
204     @Input() displayTemplate: TemplateRef<any>;
205
206     // Emitted when the value is changed via UI.
207     // When the UI value is cleared, null is emitted.
208     @Output() onChange: EventEmitter<ComboboxEntry>;
209
210     // Useful for massaging the match string prior to comparison
211     // and display.  Default version trims leading/trailing spaces.
212     formatDisplayString: (e: ComboboxEntry) => string;
213
214     idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
215     getFmRecordLabel: (fm: IdlObject) => string;
216
217     // Stub functions required by ControlValueAccessor
218     propagateChange = (_: any) => {};
219     propagateTouch = () => {};
220
221     constructor(
222       private elm: ElementRef,
223       private store: StoreService,
224       private idl: IdlService,
225       private pcrud: PcrudService,
226       private org: OrgService,
227     ) {
228         this.entrylist = [];
229         this.asyncIds = {};
230         this.click$ = new Subject<string>();
231         this.onChange = new EventEmitter<ComboboxEntry>();
232         this.defaultSelectionApplied = false;
233
234         this.formatDisplayString = (result: ComboboxEntry) => {
235             const display = result.label || result.id;
236             return (display + '').trim();
237         };
238
239         this.getFmRecordLabel = (fm: IdlObject) => {
240             // FIXME: it would be cleaner if we could somehow use
241             // the per-IDL-class ng-templates directly
242             switch (this.idlClass) {
243                 case 'acmc':
244                     return fm.course_number() + ': ' + fm.name();
245                     break;
246                 case 'acqf':
247                     return fm.code() + ' (' + fm.year() + ')' +
248                            ' (' + this.getOrgShortname(fm.org()) + ')';
249                     break;
250                 case 'acpl':
251                     return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
252                     break;
253                 default:
254                     const field = this.idlField;
255                     if (this.idlIncludeLibraryInLabel) {
256                         return fm[field]() + ' (' + this.getOrgShortname(fm[this.idlIncludeLibraryInLabel]()) + ')';
257                     } else {
258                         return fm[field]();
259                     }
260             }
261         };
262     }
263
264     ngOnInit() {
265         if (this.idlClass) {
266             const classDef = this.idl.classes[this.idlClass];
267             const pkeyField = classDef.pkey;
268
269             if (!pkeyField) {
270                 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
271             }
272
273             if (!this.idlField) {
274                 this.idlField = this.idl.getClassSelector(this.idlClass);
275             }
276
277             this.asyncDataSource = term => {
278                 const field = this.idlField;
279                 let args = {};
280                 if (this.idlBaseQuery) {
281                     args = this.idlBaseQuery;
282                 }
283                 const extra_args = { order_by : {} };
284                 if (this.startsWith) {
285                     args[field] = {'ilike': `${term}%`};
286                 } else {
287                     args[field] = {'ilike': `%${term}%`}; // could -or search on label
288                 }
289                 if (this.idlQueryAnd) {
290                     Object.assign(args, this.idlQueryAnd);
291                 }
292                 if (this.idlQuerySort) {
293                     extra_args['order_by'] = this.idlQuerySort;
294                 } else {
295                     extra_args['order_by'][this.idlClass] = field;
296                 }
297                 extra_args['limit'] = 100;
298                 if (this.idlIncludeLibraryInLabel) {
299                     extra_args['flesh'] = 1;
300                     const flesh_fields: Object = {};
301                     flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
302                     extra_args['flesh_fields'] = flesh_fields;
303                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
304                         return {
305                             id: data[pkeyField](),
306                             label: this.getFmRecordLabel(data),
307                             fm: data
308                         };
309                     }));
310                 } else {
311                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
312                         return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
313                     }));
314                 }
315             };
316         }
317     }
318
319     ngAfterViewInit() {
320         this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
321             acc[cur.egIdlClass] = cur.template;
322             return acc;
323         }, {});
324     }
325
326     ngOnChanges(changes: SimpleChanges) {
327         let firstTime = true;
328         Object.keys(changes).forEach(key => {
329             if (!changes[key].firstChange) {
330                 firstTime = false;
331             }
332         });
333         if (!firstTime) {
334             if ('selectedId' in changes) {
335                 if (!changes.selectedId.currentValue) {
336
337                     // In allowFreeText mode, selectedId will be null even
338                     // though a freetext value may be present in the combobox.
339                     if (this.allowFreeText) {
340                         if (this.selected && !this.selected.freetext) {
341                             this.selected = null;
342                         }
343                     } else {
344                         this.selected = null;
345                     }
346                 }
347             }
348             if ('idlClass' in changes) {
349                 if (!('idlField' in changes)) {
350                     // let ngOnInit reset it to the
351                     // selector of the new IDL class
352                     this.idlField = null;
353                 }
354                 this.asyncIds = {};
355                 this.entrylist.length = 0;
356                 this.selected = null;
357                 this.ngOnInit();
358             }
359             if ('idlQueryAnd' in changes) {
360                 this.asyncIds = {};
361                 this.entrylist.length = 0;
362                 this.selected = null;
363                 this.ngOnInit();
364             }
365         }
366     }
367
368     onClick($event) {
369         this.click$.next($event.target.value);
370     }
371
372     getResultTemplate(): TemplateRef<any> {
373         if (this.displayTemplate) {
374             return this.displayTemplate;
375         }
376         if (this.idlClass in this.idlDisplayTemplateMap) {
377             return this.idlDisplayTemplateMap[this.idlClass];
378         }
379         return this.defaultDisplayTemplate;
380     }
381
382     getOrgShortname(ou: any) {
383         if (typeof ou === 'object') {
384             return ou.shortname();
385         } else {
386             return this.org.get(ou).shortname();
387         }
388     }
389
390     openMe($event) {
391         // Give the input a chance to focus then fire the click
392         // handler to force open the typeahead
393         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
394         setTimeout(() => this.click$.next(''));
395     }
396
397     // Returns true if the 2 entries are equivalent.
398     entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
399         return (
400             e1 && e2 &&
401             e1.id === e2.id &&
402             e1.label === e2.label &&
403             e1.freetext === e2.freetext
404         );
405     }
406
407     // Returns true if the 2 lists are equivalent.
408     entrylistMatches(el: ComboboxEntry[]): boolean {
409         if (el.length === 0 && this.entrylist.length === 0) {
410             // Empty arrays are only equivalent if they are the same array,
411             // since the caller may provide an array that starts empty, but
412             // is later populated.
413             return el === this.entrylist;
414         }
415         if (el.length !== this.entrylist.length) {
416             return false;
417         }
418         for (let i = 0; i < el.length; i++) {
419             const mine = this.entrylist[i];
420             if (!mine || !this.entriesMatch(mine, el[i])) {
421                 return false;
422             }
423         }
424         return true;
425     }
426
427     // Apply a default selection where needed
428     applySelection() {
429
430         if (this.entrylist && !this.defaultSelectionApplied) {
431
432             const entry =
433                 this.entrylist.filter(e => e.id === this.startId)[0];
434
435             if (entry) {
436                 this.selected = entry;
437                 this.defaultSelectionApplied = true;
438                 if (this.startIdFiresOnChange) {
439                     this.selectorChanged(
440                         {item: this.selected, preventDefault: () => true});
441                 }
442             }
443         }
444     }
445
446     // Called by combobox-entry.component
447     addEntry(entry: ComboboxEntry) {
448         this.entrylist.push(entry);
449         this.applySelection();
450     }
451
452     // Manually set the selected value by ID.
453     // This does NOT fire the onChange handler.
454     // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
455     applyEntryId(entryId: any) {
456         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
457     }
458
459     addAsyncEntry(entry: ComboboxEntry) {
460         if (!entry) { return; }
461         // Avoid duplicate async entries
462         if (!this.asyncIds['' + entry.id]) {
463             this.asyncIds['' + entry.id] = true;
464             this.addEntry(entry);
465         }
466     }
467
468     hasEntry(entryId: any): boolean {
469         return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
470     }
471
472     onBlur() {
473         // When the selected value is a string it means we have either
474         // no value (user cleared the input) or a free-text value.
475
476         if (typeof this.selected === 'string') {
477
478             if (this.allowFreeText && this.selected !== '') {
479                 const freeText = this.entrylist.filter(e => e.id === null)[0];
480
481                 if (freeText) {
482
483                     // If we already had a free text entry, just replace
484                     // the label with the new value
485                     freeText.label = this.selected;
486                     this.selected = freeText;
487
488                 }  else {
489
490                     // Free text entered which does not match a known entry
491                     // translate it into a dummy ComboboxEntry
492                     this.selected = {
493                         id: null,
494                         label: this.selected,
495                         freetext: true
496                     };
497                 }
498
499             } else {
500
501                 this.selected = null;
502             }
503
504             // Manually fire the onchange since NgbTypeahead fails
505             // to fire the onchange when the value is cleared.
506             this.selectorChanged(
507                 {item: this.selected, preventDefault: () => true});
508         }
509         this.propagateTouch();
510     }
511
512     // Fired by the typeahead to inform us of a change.
513     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
514         this.onChange.emit(selEvent.item);
515         this.propagateChange(selEvent.item);
516     }
517
518     // Adds matching async entries to the entry list
519     // and propagates the search term for pipelining.
520     addAsyncEntries(term: string): Observable<string> {
521
522         if (!term || !this.asyncDataSource) {
523             return of(term);
524         }
525
526         let searchTerm = term;
527         if (term === '_CLICK_') {
528             if (this.asyncSupportsEmptyTermClick) {
529                 // Search for "all", but retain and propage the _CLICK_
530                 // term so the filter knows to open the selector
531                 searchTerm = '';
532             } else {
533                 // Skip the final filter map and display nothing.
534                 return of();
535             }
536         }
537
538         return new Observable(observer => {
539             this.asyncDataSource(searchTerm).subscribe(
540                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
541                 err => {},
542                 ()  => {
543                     observer.next(term);
544                     observer.complete();
545                 }
546             );
547         });
548     }
549
550     // NgbTypeahead doesn't offer a way to style the dropdown
551     // button directly, so we have to reach up and style it ourselves.
552     applyDisableStyle() {
553         this.disableEntries.forEach(id => {
554             const node = document.getElementById(`${this.domId}-${id}`);
555             if (node) {
556                 const button = node.parentNode as HTMLElement;
557                 button.classList.add('disabled');
558             }
559         });
560     }
561
562     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
563         return text$.pipe(
564             debounceTime(200),
565             distinctUntilChanged(),
566
567             // Merge click actions in with the stream of text entry
568             merge(
569                 // Inject a specifier indicating the source of the
570                 // action is a user click instead of a text entry.
571                 // This tells the filter to show all values in sync mode.
572                 this.click$.pipe(filter(() =>
573                     !this.instance.isPopupOpen()
574                 )).pipe(mapTo('_CLICK_'))
575             ),
576
577             // mergeMap coalesces an observable into our stream.
578             mergeMap(term => this.addAsyncEntries(term)),
579             map((term: string) => {
580
581                 // Display no values when the input is empty and no
582                 // click action occurred.
583                 if (term === '') { return []; }
584
585                 // If we make it this far, _CLICK_ means show everything.
586                 if (term === '_CLICK_') { term = ''; }
587
588                 // Give the typeahead a chance to open before applying
589                 // the disabled entry styling.
590                 setTimeout(() => this.applyDisableStyle());
591
592                 // Filter entrylist whose labels substring-match the
593                 // text entered.
594                 return this.entrylist.filter(entry => {
595                     const label = String(entry.label) || String(entry.id);
596                     if (!label) { return false; }
597
598                     if (this.startsWith) {
599                         return label.toLowerCase().startsWith(term.toLowerCase());
600                     } else {
601                         return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
602                     }
603                 });
604             })
605         );
606     }
607
608     writeValue(value: ComboboxEntry) {
609         if (value !== undefined && value !== null) {
610             this.startId = value.id;
611             this.applySelection();
612         }
613     }
614
615     registerOnChange(fn) {
616         this.propagateChange = fn;
617     }
618
619     registerOnTouched(fn) {
620         this.propagateTouch = fn;
621     }
622
623 }
624
625