]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
LP1821382 Angular holdings maintenance continued.
[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: (e: 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             const display = result.label || result.id;
106             return (display + '').trim();
107         };
108     }
109
110     ngOnInit() {
111     }
112
113     openMe($event) {
114         // Give the input a chance to focus then fire the click
115         // handler to force open the typeahead
116         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
117         setTimeout(() => this.click$.next(''));
118     }
119
120     // Apply a default selection where needed
121     applySelection() {
122
123         if (this.startId &&
124             this.entrylist && !this.defaultSelectionApplied) {
125
126             const entry =
127                 this.entrylist.filter(e => e.id === this.startId)[0];
128
129             if (entry) {
130                 this.selected = entry;
131                 this.defaultSelectionApplied = true;
132                 if (this.startIdFiresOnChange) {
133                     this.selectorChanged(
134                         {item: this.selected, preventDefault: () => true});
135                 }
136             }
137         }
138     }
139
140     // Called by combobox-entry.component
141     addEntry(entry: ComboboxEntry) {
142         this.entrylist.push(entry);
143         this.applySelection();
144     }
145
146     // Manually set the selected value by ID.
147     // This does NOT fire the onChange handler.
148     applyEntryId(entryId: any) {
149         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
150     }
151
152     addAsyncEntry(entry: ComboboxEntry) {
153         // Avoid duplicate async entries
154         if (!this.asyncIds['' + entry.id]) {
155             this.asyncIds['' + entry.id] = true;
156             this.addEntry(entry);
157         }
158     }
159
160     onBlur() {
161         // When the selected value is a string it means we have either
162         // no value (user cleared the input) or a free-text value.
163
164         if (typeof this.selected === 'string') {
165
166             if (this.allowFreeText && this.selected !== '') {
167                 // Free text entered which does not match a known entry
168                 // translate it into a dummy ComboboxEntry
169                 this.selected = {
170                     id: null,
171                     label: this.selected,
172                     freetext: true
173                 };
174
175             } else {
176
177                 this.selected = null;
178             }
179
180             // Manually fire the onchange since NgbTypeahead fails
181             // to fire the onchange when the value is cleared.
182             this.selectorChanged(
183                 {item: this.selected, preventDefault: () => true});
184         }
185     }
186
187     // Fired by the typeahead to inform us of a change.
188     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
189         this.onChange.emit(selEvent.item);
190     }
191
192     // Adds matching async entries to the entry list
193     // and propagates the search term for pipelining.
194     addAsyncEntries(term: string): Observable<string> {
195
196         if (!term || !this.asyncDataSource) {
197             return of(term);
198         }
199
200         return new Observable(observer => {
201             this.asyncDataSource(term).subscribe(
202                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
203                 err => {},
204                 ()  => {
205                     observer.next(term);
206                     observer.complete();
207                 }
208             );
209         });
210     }
211
212     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
213         return text$.pipe(
214             debounceTime(200),
215             distinctUntilChanged(),
216
217             // Merge click actions in with the stream of text entry
218             merge(
219                 // Inject a specifier indicating the source of the
220                 // action is a user click instead of a text entry.
221                 // This tells the filter to show all values in sync mode.
222                 this.click$.pipe(filter(() =>
223                     !this.instance.isPopupOpen() && !this.asyncDataSource
224                 )).pipe(mapTo('_CLICK_'))
225             ),
226
227             // mergeMap coalesces an observable into our stream.
228             mergeMap(term => this.addAsyncEntries(term)),
229             map((term: string) => {
230
231                 if (term === '' || term === '_CLICK_') {
232                     // Avoid displaying the existing entries on-click
233                     // for async sources, becuase that implies we have
234                     // the full data set. (setting?)
235                     if (this.asyncDataSource) {
236                         return [];
237                     } else {
238                         // In sync mode, a post-focus empty search or
239                         // click event displays the whole list.
240                         return this.entrylist;
241                     }
242                 }
243
244                 // Filter entrylist whose labels substring-match the
245                 // text entered.
246                 return this.entrylist.filter(entry => {
247                     const label = entry.label || entry.id;
248                     return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
249                 });
250             })
251         );
252     }
253 }
254
255