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