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