LP1811288 Combobox support entrylist+async / id labels
[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   // If no label is provided, the 'id' value is used.
15   label?: string;
16   freetext?: boolean;
17 }
18
19 @Component({
20   selector: 'eg-combobox',
21   templateUrl: './combobox.component.html',
22   styles: [`
23     .icons {margin-left:-18px}
24     .material-icons {font-size: 16px;font-weight:bold}
25   `]
26 })
27 export class ComboboxComponent implements OnInit {
28
29     selected: ComboboxEntry;
30     click$: Subject<string>;
31     entrylist: ComboboxEntry[];
32
33     @ViewChild('instance') instance: NgbTypeahead;
34
35     // Applies a name attribute to the input.
36     // Useful in forms.
37     @Input() name: string;
38
39     // Placeholder text for selector input
40     @Input() placeholder = '';
41
42     @Input() persistKey: string; // TODO
43
44     @Input() allowFreeText = false;
45
46     // Add a 'required' attribute to the input
47     isRequired: boolean;
48     @Input() set required(r: boolean) {
49         this.isRequired = r;
50     }
51
52     // Disable the input
53     isDisabled: boolean;
54     @Input() set disabled(d: boolean) {
55         this.isDisabled = d;
56     }
57
58     // Entry ID of the default entry to select (optional)
59     // onChange() is NOT fired when applying the default value,
60     // unless startIdFiresOnChange is set to true.
61     @Input() startId: any;
62     @Input() startIdFiresOnChange: boolean;
63
64     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
65
66     // Useful for efficiently preventing duplicate async entries
67     asyncIds: {[idx: string]: boolean};
68
69     // True if a default selection has been made.
70     defaultSelectionApplied: boolean;
71
72     @Input() set entries(el: ComboboxEntry[]) {
73         if (el) {
74             this.entrylist = el;
75             this.applySelection();
76
77             // It's possible to provide an entrylist at load time, but
78             // fetch all future data via async data source.  Track the
79             // values we already have so async lookup won't add them again.
80             // A new entry list wipes out any existing async values.
81             this.asyncIds = {};
82             el.forEach(entry => this.asyncIds['' + entry.id] = true);
83         }
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     // Manually set the selected value by ID.
146     // This does NOT fire the onChange handler.
147     applyEntryId(entryId: any) {
148         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
149     }
150
151     addAsyncEntry(entry: ComboboxEntry) {
152         // Avoid duplicate async entries
153         if (!this.asyncIds['' + entry.id]) {
154             this.asyncIds['' + entry.id] = true;
155             this.addEntry(entry);
156         }
157     }
158
159     onBlur() {
160         // When the selected value is a string it means we have either
161         // no value (user cleared the input) or a free-text value.
162
163         if (typeof this.selected === 'string') {
164
165             if (this.allowFreeText && this.selected !== '') {
166                 // Free text entered which does not match a known entry
167                 // translate it into a dummy ComboboxEntry
168                 this.selected = {
169                     id: null,
170                     label: this.selected,
171                     freetext: true
172                 };
173
174             } else {
175
176                 this.selected = null;
177             }
178
179             // Manually fire the onchange since NgbTypeahead fails
180             // to fire the onchange when the value is cleared.
181             this.selectorChanged(
182                 {item: this.selected, preventDefault: () => true});
183         }
184     }
185
186     // Fired by the typeahead to inform us of a change.
187     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
188         this.onChange.emit(selEvent.item);
189     }
190
191     // Adds matching async entries to the entry list
192     // and propagates the search term for pipelining.
193     addAsyncEntries(term: string): Observable<string> {
194
195         if (!term || !this.asyncDataSource) {
196             return of(term);
197         }
198
199         return new Observable(observer => {
200             this.asyncDataSource(term).subscribe(
201                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
202                 err => {},
203                 ()  => {
204                     observer.next(term);
205                     observer.complete();
206                 }
207             );
208         });
209     }
210
211     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
212         return text$.pipe(
213             debounceTime(200),
214             distinctUntilChanged(),
215
216             // Merge click actions in with the stream of text entry
217             merge(
218                 // Inject a specifier indicating the source of the
219                 // action is a user click instead of a text entry.
220                 // This tells the filter to show all values in sync mode.
221                 this.click$.pipe(filter(() =>
222                     !this.instance.isPopupOpen() && !this.asyncDataSource
223                 )).pipe(mapTo('_CLICK_'))
224             ),
225
226             // mergeMap coalesces an observable into our stream.
227             mergeMap(term => this.addAsyncEntries(term)),
228             map((term: string) => {
229
230                 if (term === '' || term === '_CLICK_') {
231                     // Avoid displaying the existing entries on-click
232                     // for async sources, becuase that implies we have
233                     // the full data set. (setting?)
234                     if (this.asyncDataSource) {
235                         return [];
236                     } else {
237                         // In sync mode, a post-focus empty search or
238                         // click event displays the whole list.
239                         return this.entrylist;
240                     }
241                 }
242
243                 // Filter entrylist whose labels substring-match the
244                 // text entered.
245                 return this.entrylist.filter(entry => {
246                     const label = entry.label || entry.id;
247                     return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
248                 });
249             })
250         );
251     }
252 }
253
254