323623c3a02583deaa2e7a3b257409ec06708572
[working/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, EventEmitter, ElementRef} from '@angular/core';
7 import {Observable, of, Subject} from 'rxjs';
8 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
9 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
10 import {StoreService} from '@eg/core/store.service';
11
12 export interface ComboboxEntry {
13   id: any;
14   label: string;
15   freetext?: boolean;
16 }
17
18 @Component({
19   selector: 'eg-combobox',
20   templateUrl: './combobox.component.html',
21   styles: [`
22     .icons {margin-left:-18px}
23     .material-icons {font-size: 16px;font-weight:bold}
24   `]
25 })
26 export class ComboboxComponent implements OnInit {
27
28     selected: ComboboxEntry;
29     click$: Subject<string>;
30     entrylist: ComboboxEntry[];
31
32     @ViewChild('instance') instance: NgbTypeahead;
33
34     // Applies a name attribute to the input.
35     // Useful in forms.
36     @Input() name: string;
37
38     // Placeholder text for selector input
39     @Input() placeholder = '';
40
41     @Input() persistKey: string; // TODO
42
43     @Input() allowFreeText = false;
44
45     // Add a 'required' attribute to the input
46     isRequired: boolean;
47     @Input() set required(r: boolean) {
48         this.isRequired = r;
49     }
50
51     // Disable the input
52     isDisabled: boolean;
53     @Input() set disabled(d: boolean) {
54         this.isDisabled = d;
55     }
56
57     // Entry ID of the default entry to select (optional)
58     // onChange() is NOT fired when applying the default value,
59     // unless startIdFiresOnChange is set to true.
60     @Input() startId: any;
61     @Input() startIdFiresOnChange: boolean;
62
63     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
64
65     // Useful for efficiently preventing duplicate async entries
66     asyncIds: {[idx: string]: boolean};
67
68     // True if a default selection has been made.
69     defaultSelectionApplied: boolean;
70
71     @Input() set entries(el: ComboboxEntry[]) {
72         this.entrylist = el;
73         this.applySelection();
74     }
75
76     // Emitted when the value is changed via UI.
77     // When the UI value is cleared, null is emitted.
78     @Output() onChange: EventEmitter<ComboboxEntry>;
79
80     // Useful for massaging the match string prior to comparison
81     // and display.  Default version trims leading/trailing spaces.
82     formatDisplayString: (ComboboxEntry) => string;
83
84     constructor(
85       private elm: ElementRef,
86       private store: StoreService,
87     ) {
88         this.entrylist = [];
89         this.asyncIds = {};
90         this.click$ = new Subject<string>();
91         this.onChange = new EventEmitter<ComboboxEntry>();
92         this.defaultSelectionApplied = false;
93
94         this.formatDisplayString = (result: ComboboxEntry) => {
95             return result.label.trim();
96         };
97     }
98
99     ngOnInit() {
100     }
101
102     openMe($event) {
103         // Give the input a chance to focus then fire the click
104         // handler to force open the typeahead
105         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
106         setTimeout(() => this.click$.next(''));
107     }
108
109     // Apply a default selection where needed
110     applySelection() {
111
112         if (this.startId &&
113             this.entrylist && !this.defaultSelectionApplied) {
114
115             const entry =
116                 this.entrylist.filter(e => e.id === this.startId)[0];
117
118             if (entry) {
119                 this.selected = entry;
120                 this.defaultSelectionApplied = true;
121                 if (this.startIdFiresOnChange) {
122                     this.selectorChanged(
123                         {item: this.selected, preventDefault: () => true});
124                 }
125             }
126         }
127     }
128
129     // Called by combobox-entry.component
130     addEntry(entry: ComboboxEntry) {
131         this.entrylist.push(entry);
132         this.applySelection();
133     }
134
135     // Manually set the selected value by ID.
136     // This does NOT fire the onChange handler.
137     applyEntryId(entryId: any) {
138         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
139     }
140
141     onBlur() {
142         // When the selected value is a string it means we have either
143         // no value (user cleared the input) or a free-text value.
144
145         if (typeof this.selected === 'string') {
146
147             if (this.allowFreeText && this.selected !== '') {
148                 // Free text entered which does not match a known entry
149                 // translate it into a dummy ComboboxEntry
150                 this.selected = {
151                     id: null,
152                     label: this.selected,
153                     freetext: true
154                 };
155
156             } else {
157
158                 this.selected = null;
159             }
160
161             // Manually fire the onchange since NgbTypeahead fails
162             // to fire the onchange when the value is cleared.
163             this.selectorChanged(
164                 {item: this.selected, preventDefault: () => true});
165         }
166     }
167
168     // Fired by the typeahead to inform us of a change.
169     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
170         this.onChange.emit(selEvent.item);
171     }
172
173     // Adds matching async entries to the entry list
174     // and propagates the search term for pipelining.
175     addAsyncEntries(term: string): Observable<string> {
176
177         if (!term || !this.asyncDataSource) {
178             return of(term);
179         }
180
181         return new Observable(observer => {
182             this.asyncDataSource(term).subscribe(
183                 (entry: ComboboxEntry) => {
184                     if (!this.asyncIds['' + entry.id]) {
185                         this.asyncIds['' + entry.id] = true;
186                         this.addEntry(entry);
187                     }
188                 },
189                 err => {},
190                 ()  => {
191                     observer.next(term);
192                     observer.complete();
193                 }
194             );
195         });
196     }
197
198     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
199         return text$.pipe(
200             debounceTime(200),
201             distinctUntilChanged(),
202
203             // Merge click actions in with the stream of text entry
204             merge(
205                 // Inject a specifier indicating the source of the
206                 // action is a user click instead of a text entry.
207                 // This tells the filter to show all values in sync mode.
208                 this.click$.pipe(filter(() =>
209                     !this.instance.isPopupOpen() && !this.asyncDataSource
210                 )).pipe(mapTo('_CLICK_'))
211             ),
212
213             // mergeMap coalesces an observable into our stream.
214             mergeMap(term => this.addAsyncEntries(term)),
215             map((term: string) => {
216
217                 if (term === '' || term === '_CLICK_') {
218                     if (this.asyncDataSource) {
219                         return [];
220                     } else {
221                         // In sync mode, a post-focus empty search or
222                         // click event displays the whole list.
223                         return this.entrylist;
224                     }
225                 }
226
227                 // Filter entrylist whose labels substring-match the
228                 // text entered.
229                 return this.entrylist.filter(entry =>
230                     entry.label.toLowerCase().indexOf(term.toLowerCase()) > -1
231                 );
232             })
233         );
234     }
235 }
236
237