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