]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
LP1615805 No inputs after submit in patron search (AngularJS)
[Evergreen.git] / Open-ILS / src / eg2 / src / app / share / combobox / combobox.component.ts
1 /* eslint-disable no-case-declarations */
2 /**
3  * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
4  *  <!-- see also <eg-combobox-entry> -->
5  * </eg-combobox>
6  */
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';
19
20 export interface ComboboxEntry {
21   id: any;
22   // If no label is provided, the 'id' value is used.
23   label?: string;
24   freetext?: boolean;
25   userdata?: any; // opaque external value; ignored by this component.
26   fm?: IdlObject;
27   disabled?: boolean;
28 }
29
30 @Directive({
31     selector: 'ng-template[egIdlClass]'
32 })
33 export class IdlClassTemplateDirective {
34   @Input() egIdlClass: string;
35   constructor(public template: TemplateRef<any>) {}
36 }
37
38 @Component({
39     selector: 'eg-combobox',
40     templateUrl: './combobox.component.html',
41     styles: [`
42     .icons {margin-left:-18px}
43     .material-icons {font-size: 16px;font-weight:bold}
44   `],
45     providers: [{
46         provide: NG_VALUE_ACCESSOR,
47         useExisting: forwardRef(() => ComboboxComponent),
48         multi: true
49     }]
50 })
51 export class ComboboxComponent
52 implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {
53
54     static domIdAuto = 0;
55
56     selected: ComboboxEntry;
57     click$: Subject<string>;
58     entrylist: ComboboxEntry[];
59
60     @ViewChild('instance', {static: false}) instance: NgbTypeahead;
61     @ViewChild('defaultDisplayTemplate', {static: true}) defaultDisplayTemplate: TemplateRef<any>;
62     @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
63
64     @Input() domId = 'eg-combobox-' + ComboboxComponent.domIdAuto++;
65
66     // Applies a name attribute to the input.
67     // Useful in forms.
68     @Input() name: string;
69
70     // Placeholder text for selector input
71     @Input() placeholder = '';
72
73     @Input() persistKey: string; // TODO
74
75     @Input() allowFreeText = false;
76
77     @Input() inputSize: number = null;
78
79     // If true, applies form-control-sm CSS
80     @Input() smallFormControl = false;
81
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;
85
86     // Add a 'required' attribute to the input
87     isRequired: boolean;
88     @Input() set required(r: boolean) {
89         this.isRequired = r;
90     }
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) {
97         this.isRequired = r;
98     }
99
100     // Array of entry identifiers to disable in the selector
101     @Input() disableEntries: any[] = [];
102
103     // Disable the input
104     isDisabled: boolean;
105     @Input() set disabled(d: boolean) {
106         this.isDisabled = d;
107     }
108
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;
116
117     // This will be appended to the async data retrieval query
118     // when fetching objects by idlClass.
119     @Input() idlQueryAnd: {[field: string]: any};
120
121     @Input() idlQuerySort: {[cls: string]: string};
122
123     // Display the selected value as text instead of within
124     // the typeahead
125     @Input() readOnly = false;
126
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; }
131
132         // clear on explicit null
133         if (id === null) {
134             this.selected = null;
135             return;
136         }
137
138         if (this.entrylist.length) {
139             this.selected = this.entrylist.filter(e => e.id === id)[0];
140         }
141
142         if (!this.selected) {
143             // It's possible the selected ID lives in a set of entries
144             // that are yet to be provided.
145             this.startId = id;
146             if (this.idlClass) {
147                 this.pcrud.retrieve(this.idlClass, id)
148                     .subscribe(rec => {
149                         this.entrylist = [{
150                             id: id,
151                             label: this.getFmRecordLabel(rec),
152                             fm: rec,
153                             disabled : this.disableEntries.includes(id)
154                         }];
155                         this.selected = this.entrylist.filter(e => e.id === id)[0];
156                     });
157             }
158         }
159     }
160
161     get selectedId(): any {
162         return this.selected ? this.selected.id : null;
163     }
164
165     @Input() idlField: string;
166     @Input() idlIncludeLibraryInLabel: string;
167     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
168
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;
174
175     // Useful for efficiently preventing duplicate async entries
176     asyncIds: {[idx: string]: boolean};
177
178     // True if a default selection has been made.
179     defaultSelectionApplied: boolean;
180
181     @Input() set entries(el: ComboboxEntry[]) {
182         if (el) {
183
184             if (this.entrylistMatches(el)) {
185                 // Avoid reprocessing data we already have.
186                 return;
187             }
188
189             this.entrylist = el;
190
191             // new set of entries essentially means a new instance. reset.
192             this.defaultSelectionApplied = false;
193             this.applySelection();
194
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.
199             this.asyncIds = {};
200             el.forEach(entry => this.asyncIds['' + entry.id] = true);
201         }
202     }
203
204     // When provided use this as the display template for each entry.
205     @Input() displayTemplate: TemplateRef<any>;
206
207     // Emitted when the value is changed via UI.
208     // When the UI value is cleared, null is emitted.
209     @Output() onChange: EventEmitter<ComboboxEntry>;
210
211     // Useful for massaging the match string prior to comparison
212     // and display.  Default version trims leading/trailing spaces.
213     formatDisplayString: (e: ComboboxEntry) => string;
214
215     idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
216     getFmRecordLabel: (fm: IdlObject) => string;
217
218     // Stub functions required by ControlValueAccessor
219     propagateChange = (_: any) => {};
220     propagateTouch = () => {};
221
222     constructor(
223       private elm: ElementRef,
224       private store: StoreService,
225       private idl: IdlService,
226       private pcrud: PcrudService,
227       private org: OrgService,
228     ) {
229         this.entrylist = [];
230         this.asyncIds = {};
231         this.click$ = new Subject<string>();
232         this.onChange = new EventEmitter<ComboboxEntry>();
233         this.defaultSelectionApplied = false;
234
235         this.formatDisplayString = (result: ComboboxEntry) => {
236             const display = result.label || result.id;
237             return (display + '').trim();
238         };
239
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) {
244                 case 'acmc':
245                     return fm.course_number() + ': ' + fm.name();
246                 case 'acqf':
247                     return fm.code() + ' (' + fm.year() + ')' +
248                            ' (' + this.getOrgShortname(fm.org()) + ')';
249                 case 'acpl':
250                     return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
251                 default:
252                     const field = this.idlField;
253                     if (this.idlIncludeLibraryInLabel) {
254                         return fm[field]() + ' (' + this.getOrgShortname(fm[this.idlIncludeLibraryInLabel]()) + ')';
255                     } else {
256                         return fm[field]();
257                     }
258             }
259         };
260     }
261
262     ngOnInit() {
263         if (this.idlClass) {
264             const classDef = this.idl.classes[this.idlClass];
265             const pkeyField = classDef.pkey;
266
267             if (!pkeyField) {
268                 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
269             }
270
271             if (!this.idlField) {
272                 this.idlField = this.idl.getClassSelector(this.idlClass);
273             }
274
275             this.asyncDataSource = term => {
276                 const field = this.idlField;
277                 let args = {};
278                 if (this.idlBaseQuery) {
279                     args = this.idlBaseQuery;
280                 }
281                 const extra_args = { order_by : {} };
282                 if (this.startsWith) {
283                     args[field] = {'ilike': `${term}%`};
284                 } else {
285                     args[field] = {'ilike': `%${term}%`}; // could -or search on label
286                 }
287                 if (this.idlQueryAnd) {
288                     Object.assign(args, this.idlQueryAnd);
289                 }
290                 if (this.idlQuerySort) {
291                     extra_args['order_by'] = this.idlQuerySort;
292                 } else {
293                     extra_args['order_by'][this.idlClass] = field;
294                 }
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 => {
302                         return {
303                             id: data[pkeyField](),
304                             label: this.getFmRecordLabel(data),
305                             fm: data
306                         };
307                     }));
308                 } else {
309                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
310                         return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
311                     }));
312                 }
313             };
314         }
315     }
316
317     ngAfterViewInit() {
318         this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
319             acc[cur.egIdlClass] = cur.template;
320             return acc;
321         }, {});
322     }
323
324     ngOnChanges(changes: SimpleChanges) {
325         let firstTime = true;
326         Object.keys(changes).forEach(key => {
327             if (!changes[key].firstChange) {
328                 firstTime = false;
329             }
330         });
331         if (!firstTime) {
332             if ('selectedId' in changes) {
333                 if (!changes.selectedId.currentValue) {
334
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;
340                         }
341                     } else {
342                         this.selected = null;
343                     }
344                 }
345             }
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;
351                 }
352                 this.asyncIds = {};
353                 this.entrylist.length = 0;
354                 this.selected = null;
355                 this.ngOnInit();
356             }
357             if ('idlQueryAnd' in changes) {
358                 this.asyncIds = {};
359                 this.entrylist.length = 0;
360                 this.selected = null;
361                 this.ngOnInit();
362             }
363         }
364     }
365
366     onClick($event) {
367         this.click$.next($event.target.value);
368     }
369
370     getResultTemplate(): TemplateRef<any> {
371         if (this.displayTemplate) {
372             return this.displayTemplate;
373         }
374         if (this.idlClass in this.idlDisplayTemplateMap) {
375             return this.idlDisplayTemplateMap[this.idlClass];
376         }
377         return this.defaultDisplayTemplate;
378     }
379
380     getOrgShortname(ou: any) {
381         if (typeof ou === 'object') {
382             return ou.shortname();
383         } else {
384             return this.org.get(ou).shortname();
385         }
386     }
387
388     openMe($event) {
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(''));
393     }
394
395     // Returns true if the 2 entries are equivalent.
396     entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
397         return (
398             e1 && e2 &&
399             e1.id === e2.id &&
400             e1.label === e2.label &&
401             e1.freetext === e2.freetext
402         );
403     }
404
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;
412         }
413         if (el.length !== this.entrylist.length) {
414             return false;
415         }
416         for (let i = 0; i < el.length; i++) {
417             const mine = this.entrylist[i];
418             if (!mine || !this.entriesMatch(mine, el[i])) {
419                 return false;
420             }
421         }
422         return true;
423     }
424
425     // Apply a default selection where needed
426     applySelection() {
427
428         if (this.entrylist && !this.defaultSelectionApplied) {
429
430             const entry =
431                 this.entrylist.filter(e => e.id === this.startId)[0];
432
433             if (entry) {
434                 this.selected = entry;
435                 this.defaultSelectionApplied = true;
436                 if (this.startIdFiresOnChange) {
437                     this.selectorChanged(
438                         {item: this.selected, preventDefault: () => true});
439                 }
440             }
441         }
442     }
443
444     // Called by combobox-entry.component
445     addEntry(entry: ComboboxEntry) {
446         this.entrylist.push(entry);
447         this.applySelection();
448     }
449
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];
455     }
456
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);
463         }
464     }
465
466     hasEntry(entryId: any): boolean {
467         return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
468     }
469
470     onBlur() {
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.
473
474         if (typeof this.selected === 'string') {
475
476             if (this.allowFreeText && this.selected !== '') {
477                 const freeText = this.entrylist.filter(e => e.id === null)[0];
478
479                 if (freeText) {
480
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;
485
486                 }  else {
487
488                     // Free text entered which does not match a known entry
489                     // translate it into a dummy ComboboxEntry
490                     this.selected = {
491                         id: null,
492                         label: this.selected,
493                         freetext: true
494                     };
495                 }
496
497             } else {
498
499                 this.selected = null;
500             }
501
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});
506         }
507         this.propagateTouch();
508     }
509
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);
514     }
515
516     // Adds matching async entries to the entry list
517     // and propagates the search term for pipelining.
518     addAsyncEntries(term: string): Observable<string> {
519
520         if (!term || !this.asyncDataSource) {
521             return of(term);
522         }
523
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
529                 searchTerm = '';
530             } else {
531                 // Skip the final filter map and display nothing.
532                 return of();
533             }
534         }
535
536         return new Observable(observer => {
537             this.asyncDataSource(searchTerm).subscribe(
538                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
539                 (err: unknown) => {},
540                 ()  => {
541                     observer.next(term);
542                     observer.complete();
543                 }
544             );
545         });
546     }
547
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}`);
553             if (node) {
554                 const button = node.parentNode as HTMLElement;
555                 button.classList.add('disabled');
556             }
557         });
558     }
559
560     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
561         return text$.pipe(
562             // eslint-disable-next-line no-magic-numbers
563             debounceTime(200),
564             distinctUntilChanged(),
565
566             // Merge click actions in with the stream of text entry
567             merge(
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_'))
574             ),
575
576             // mergeMap coalesces an observable into our stream.
577             mergeMap(term => this.addAsyncEntries(term)),
578             map((term: string) => {
579
580                 // Display no values when the input is empty and no
581                 // click action occurred.
582                 if (term === '') { return []; }
583
584                 // If we make it this far, _CLICK_ means show everything.
585                 if (term === '_CLICK_') { term = ''; }
586
587                 // Give the typeahead a chance to open before applying
588                 // the disabled entry styling.
589                 setTimeout(() => this.applyDisableStyle());
590
591                 // Filter entrylist whose labels substring-match the
592                 // text entered.
593                 return this.entrylist.filter(entry => {
594                     const label = String(entry.label) || String(entry.id);
595                     if (!label) { return false; }
596
597                     if (this.startsWith) {
598                         return label.toLowerCase().startsWith(term.toLowerCase());
599                     } else {
600                         return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
601                     }
602                 });
603             })
604         );
605     };
606
607     writeValue(value: ComboboxEntry) {
608         if (value !== undefined && value !== null) {
609             this.startId = value.id;
610             this.applySelection();
611         }
612     }
613
614     registerOnChange(fn) {
615         this.propagateChange = fn;
616     }
617
618     registerOnTouched(fn) {
619         this.propagateTouch = fn;
620     }
621
622 }
623
624