]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
LP#1801984 Upgrading Angular 6 to Angular 7
[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     onBlur() {
136         // When the selected value is a string it means we have either
137         // no value (user cleared the input) or a free-text value.
138
139         if (typeof this.selected === 'string') {
140
141             if (this.allowFreeText && this.selected !== '') {
142                 // Free text entered which does not match a known entry
143                 // translate it into a dummy ComboboxEntry
144                 this.selected = {
145                     id: null,
146                     label: this.selected,
147                     freetext: true
148                 };
149
150             } else {
151
152                 this.selected = null;
153             }
154
155             // Manually fire the onchange since NgbTypeahead fails
156             // to fire the onchange when the value is cleared.
157             this.selectorChanged(
158                 {item: this.selected, preventDefault: () => true});
159         }
160     }
161
162     // Fired by the typeahead to inform us of a change.
163     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
164         this.onChange.emit(selEvent.item);
165     }
166
167     // Adds matching async entries to the entry list
168     // and propagates the search term for pipelining.
169     addAsyncEntries(term: string): Observable<string> {
170
171         if (!term || !this.asyncDataSource) {
172             return of(term);
173         }
174
175         return new Observable(observer => {
176             this.asyncDataSource(term).subscribe(
177                 (entry: ComboboxEntry) => {
178                     if (!this.asyncIds['' + entry.id]) {
179                         this.asyncIds['' + entry.id] = true;
180                         this.addEntry(entry);
181                     }
182                 },
183                 err => {},
184                 ()  => {
185                     observer.next(term);
186                     observer.complete();
187                 }
188             );
189         });
190     }
191
192     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
193         return text$.pipe(
194             debounceTime(200),
195             distinctUntilChanged(),
196
197             // Merge click actions in with the stream of text entry
198             merge(
199                 // Inject a specifier indicating the source of the
200                 // action is a user click instead of a text entry.
201                 // This tells the filter to show all values in sync mode.
202                 this.click$.pipe(filter(() =>
203                     !this.instance.isPopupOpen() && !this.asyncDataSource
204                 )).pipe(mapTo('_CLICK_'))
205             ),
206
207             // mergeMap coalesces an observable into our stream.
208             mergeMap(term => this.addAsyncEntries(term)),
209             map((term: string) => {
210
211                 if (term === '' || term === '_CLICK_') {
212                     if (this.asyncDataSource) {
213                         return [];
214                     } else {
215                         // In sync mode, a post-focus empty search or
216                         // click event displays the whole list.
217                         return this.entrylist;
218                     }
219                 }
220
221                 // Filter entrylist whose labels substring-match the
222                 // text entered.
223                 return this.entrylist.filter(entry =>
224                     entry.label.toLowerCase().indexOf(term.toLowerCase()) > -1
225                 );
226             })
227         );
228     }
229 }
230
231