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