]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
LP#1850547: eg-combobox: add per-IDL-class formatting
[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,
7     Directive, ViewChildren, QueryList, AfterViewInit,
8     TemplateRef, EventEmitter, ElementRef, forwardRef} from '@angular/core';
9 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
10 import {Observable, of, Subject} from 'rxjs';
11 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
12 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
13 import {StoreService} from '@eg/core/store.service';
14 import {IdlService, IdlObject} from '@eg/core/idl.service';
15 import {PcrudService} from '@eg/core/pcrud.service';
16 import {OrgService} from '@eg/core/org.service';
17
18 export interface ComboboxEntry {
19   id: any;
20   // If no label is provided, the 'id' value is used.
21   label?: string;
22   freetext?: boolean;
23   userdata?: any; // opaque external value; ignored by this component.
24   fm?: IdlObject;
25 }
26
27 @Directive({
28     selector: 'ng-template[egIdlClass]'
29 })
30 export class IdlClassTemplateDirective {
31   @Input() egIdlClass: string;
32   constructor(public template: TemplateRef<any>) {}
33 }
34
35 @Component({
36   selector: 'eg-combobox',
37   templateUrl: './combobox.component.html',
38   styles: [`
39     .icons {margin-left:-18px}
40     .material-icons {font-size: 16px;font-weight:bold}
41   `],
42   providers: [{
43     provide: NG_VALUE_ACCESSOR,
44     useExisting: forwardRef(() => ComboboxComponent),
45     multi: true
46   }]
47 })
48 export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterViewInit {
49
50     selected: ComboboxEntry;
51     click$: Subject<string>;
52     entrylist: ComboboxEntry[];
53
54     @ViewChild('instance', { static: true }) instance: NgbTypeahead;
55     @ViewChild('defaultDisplayTemplate', { static: true}) t: TemplateRef<any>;
56     @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
57
58     // Applies a name attribute to the input.
59     // Useful in forms.
60     @Input() name: string;
61
62     // Placeholder text for selector input
63     @Input() placeholder = '';
64
65     @Input() persistKey: string; // TODO
66
67     @Input() allowFreeText = false;
68
69     @Input() inputSize: number = null;
70
71     // Add a 'required' attribute to the input
72     isRequired: boolean;
73     @Input() set required(r: boolean) {
74         this.isRequired = r;
75     }
76
77     // Disable the input
78     isDisabled: boolean;
79     @Input() set disabled(d: boolean) {
80         this.isDisabled = d;
81     }
82
83     // Entry ID of the default entry to select (optional)
84     // onChange() is NOT fired when applying the default value,
85     // unless startIdFiresOnChange is set to true.
86     @Input() startId: any = null;
87     @Input() idlClass: string;
88     @Input() startIdFiresOnChange: boolean;
89
90     // Allow the selected entry ID to be passed via the template
91     // This does NOT not emit onChange events.
92     @Input() set selectedId(id: any) {
93         if (id === undefined) { return; }
94
95         // clear on explicit null
96         if (id === null) { this.selected = null; }
97
98         if (this.entrylist.length) {
99             this.selected = this.entrylist.filter(e => e.id === id)[0];
100         }
101
102         if (!this.selected) {
103             // It's possible the selected ID lives in a set of entries
104             // that are yet to be provided.
105             this.startId = id;
106             if (this.idlClass) {
107                 this.pcrud.retrieve(this.idlClass, id)
108                 .subscribe(rec => {
109                     this.entrylist = [{
110                         id: id,
111                         label: this.getFmRecordLabel(rec),
112                         fm: rec
113                     }];
114                     this.selected = this.entrylist.filter(e => e.id === id)[0];
115                 });
116             }
117         }
118     }
119
120     get selectedId(): any {
121         return this.selected ? this.selected.id : null;
122     }
123
124     @Input() idlField: string;
125     @Input() idlIncludeLibraryInLabel: string;
126     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
127
128     // If true, an async data search is allowed to fetch all
129     // values when given an empty term. This should be used only
130     // if the maximum number of entries returned by the data source
131     // is known to be no more than a couple hundred.
132     @Input() asyncSupportsEmptyTermClick: boolean;
133
134     // Useful for efficiently preventing duplicate async entries
135     asyncIds: {[idx: string]: boolean};
136
137     // True if a default selection has been made.
138     defaultSelectionApplied: boolean;
139
140     @Input() set entries(el: ComboboxEntry[]) {
141         if (el) {
142
143             if (this.entrylistMatches(el)) {
144                 // Avoid reprocessing data we already have.
145                 return;
146             }
147
148             this.entrylist = el;
149
150             // new set of entries essentially means a new instance. reset.
151             this.defaultSelectionApplied = false;
152             this.applySelection();
153
154             // It's possible to provide an entrylist at load time, but
155             // fetch all future data via async data source.  Track the
156             // values we already have so async lookup won't add them again.
157             // A new entry list wipes out any existing async values.
158             this.asyncIds = {};
159             el.forEach(entry => this.asyncIds['' + entry.id] = true);
160         }
161     }
162
163     // When provided use this as the display template for each entry.
164     @Input() displayTemplate: TemplateRef<any>;
165
166     // Emitted when the value is changed via UI.
167     // When the UI value is cleared, null is emitted.
168     @Output() onChange: EventEmitter<ComboboxEntry>;
169
170     // Useful for massaging the match string prior to comparison
171     // and display.  Default version trims leading/trailing spaces.
172     formatDisplayString: (e: ComboboxEntry) => string;
173
174     idlDisplayTemplateMap: { [key: string]: TemplateRef<any> } = {};
175     getFmRecordLabel: (fm: IdlObject) => string;
176
177     // Stub functions required by ControlValueAccessor
178     propagateChange = (_: any) => {};
179     propagateTouch = () => {};
180
181     constructor(
182       private elm: ElementRef,
183       private store: StoreService,
184       private idl: IdlService,
185       private pcrud: PcrudService,
186       private org: OrgService,
187     ) {
188         this.entrylist = [];
189         this.asyncIds = {};
190         this.click$ = new Subject<string>();
191         this.onChange = new EventEmitter<ComboboxEntry>();
192         this.defaultSelectionApplied = false;
193
194         this.formatDisplayString = (result: ComboboxEntry) => {
195             const display = result.label || result.id;
196             return (display + '').trim();
197         };
198
199         this.getFmRecordLabel = (fm: IdlObject) => {
200             // FIXME: it would be cleaner if we could somehow use
201             // the per-IDL-class ng-templates directly
202             switch (this.idlClass) {
203                 case 'acqf':
204                     return fm.code() + ' (' + fm.year() + ')';
205                     break;
206                 case 'acpl':
207                     return fm.name() + ' (' + this.getOrgShortname(fm.owning_lib()) + ')';
208                     break;
209                 default:
210                     const field = this.idlField;
211                     if (this.idlIncludeLibraryInLabel) {
212                         return fm[field]() + ' (' + fm[this.idlIncludeLibraryInLabel]().shortname() + ')';
213                     } else {
214                         return fm[field]();
215                     }
216             }
217         };
218     }
219
220     ngOnInit() {
221         if (this.idlClass) {
222             const classDef = this.idl.classes[this.idlClass];
223             const pkeyField = classDef.pkey;
224
225             if (!pkeyField) {
226                 throw new Error(`IDL class ${this.idlClass} has no pkey field`);
227             }
228
229             if (!this.idlField) {
230                 this.idlField = this.idl.getClassSelector(this.idlClass);
231             }
232
233             this.asyncDataSource = term => {
234                 const field = this.idlField;
235                 const args = {};
236                 const extra_args = { order_by : {} };
237                 args[field] = {'ilike': `%${term}%`}; // could -or search on label
238                 extra_args['order_by'][this.idlClass] = field;
239                 extra_args['limit'] = 100;
240                 if (this.idlIncludeLibraryInLabel) {
241                     extra_args['flesh'] = 1;
242                     const flesh_fields: Object = {};
243                     flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
244                     extra_args['flesh_fields'] = flesh_fields;
245                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
246                         return {
247                             id: data[pkeyField](),
248                             label: this.getFmRecordLabel(data),
249                             fm: data
250                         };
251                     }));
252                 } else {
253                     return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
254                         return {id: data[pkeyField](), label: this.getFmRecordLabel(data), fm: data};
255                     }));
256                 }
257             };
258         }
259     }
260
261     ngAfterViewInit() {
262         this.idlDisplayTemplateMap = this.idlClassTemplates.reduce((acc, cur) => {
263             acc[cur.egIdlClass] = cur.template;
264             return acc;
265         }, {});
266     }
267
268     onClick($event) {
269         this.click$.next($event.target.value);
270     }
271
272     getOrgShortname(ou: any) {
273         if (typeof ou === 'object') {
274             return ou.shortname();
275         } else {
276             return this.org.get(ou).shortname();
277         }
278     }
279
280     openMe($event) {
281         // Give the input a chance to focus then fire the click
282         // handler to force open the typeahead
283         this.elm.nativeElement.getElementsByTagName('input')[0].focus();
284         setTimeout(() => this.click$.next(''));
285     }
286
287     // Returns true if the 2 entries are equivalent.
288     entriesMatch(e1: ComboboxEntry, e2: ComboboxEntry): boolean {
289         return (
290             e1 && e2 &&
291             e1.id === e2.id &&
292             e1.label === e2.label &&
293             e1.freetext === e2.freetext
294         );
295     }
296
297     // Returns true if the 2 lists are equivalent.
298     entrylistMatches(el: ComboboxEntry[]): boolean {
299         if (el.length === 0 && this.entrylist.length === 0) {
300             // Empty arrays are only equivalent if they are the same array,
301             // since the caller may provide an array that starts empty, but
302             // is later populated.
303             return el === this.entrylist;
304         }
305         if (el.length !== this.entrylist.length) {
306             return false;
307         }
308         for (let i = 0; i < el.length; i++) {
309             const mine = this.entrylist[i];
310             if (!mine || !this.entriesMatch(mine, el[i])) {
311                 return false;
312             }
313         }
314         return true;
315     }
316
317     // Apply a default selection where needed
318     applySelection() {
319
320         if (this.startId !== null &&
321             this.entrylist && !this.defaultSelectionApplied) {
322
323             const entry =
324                 this.entrylist.filter(e => e.id === this.startId)[0];
325
326             if (entry) {
327                 this.selected = entry;
328                 this.defaultSelectionApplied = true;
329                 if (this.startIdFiresOnChange) {
330                     this.selectorChanged(
331                         {item: this.selected, preventDefault: () => true});
332                 }
333             }
334         }
335     }
336
337     // Called by combobox-entry.component
338     addEntry(entry: ComboboxEntry) {
339         this.entrylist.push(entry);
340         this.applySelection();
341     }
342
343     // Manually set the selected value by ID.
344     // This does NOT fire the onChange handler.
345     // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
346     applyEntryId(entryId: any) {
347         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
348     }
349
350     addAsyncEntry(entry: ComboboxEntry) {
351         // Avoid duplicate async entries
352         if (!this.asyncIds['' + entry.id]) {
353             this.asyncIds['' + entry.id] = true;
354             this.addEntry(entry);
355         }
356     }
357
358     hasEntry(entryId: any): boolean {
359         return this.entrylist.filter(e => e.id === entryId)[0] !== undefined;
360     }
361
362     onBlur() {
363         // When the selected value is a string it means we have either
364         // no value (user cleared the input) or a free-text value.
365
366         if (typeof this.selected === 'string') {
367
368             if (this.allowFreeText && this.selected !== '') {
369                 // Free text entered which does not match a known entry
370                 // translate it into a dummy ComboboxEntry
371                 this.selected = {
372                     id: null,
373                     label: this.selected,
374                     freetext: true
375                 };
376
377             } else {
378
379                 this.selected = null;
380             }
381
382             // Manually fire the onchange since NgbTypeahead fails
383             // to fire the onchange when the value is cleared.
384             this.selectorChanged(
385                 {item: this.selected, preventDefault: () => true});
386         }
387         this.propagateTouch();
388     }
389
390     // Fired by the typeahead to inform us of a change.
391     selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
392         this.onChange.emit(selEvent.item);
393         this.propagateChange(selEvent.item);
394     }
395
396     // Adds matching async entries to the entry list
397     // and propagates the search term for pipelining.
398     addAsyncEntries(term: string): Observable<string> {
399
400         if (!term || !this.asyncDataSource) {
401             return of(term);
402         }
403
404         let searchTerm: string;
405         searchTerm = term;
406         if (searchTerm === '_CLICK_') {
407             if (this.asyncSupportsEmptyTermClick) {
408                 searchTerm = '';
409             } else {
410                 return of();
411             }
412         }
413
414         return new Observable(observer => {
415             this.asyncDataSource(searchTerm).subscribe(
416                 (entry: ComboboxEntry) => this.addAsyncEntry(entry),
417                 err => {},
418                 ()  => {
419                     observer.next(searchTerm);
420                     observer.complete();
421                 }
422             );
423         });
424     }
425
426     filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
427         return text$.pipe(
428             debounceTime(200),
429             distinctUntilChanged(),
430
431             // Merge click actions in with the stream of text entry
432             merge(
433                 // Inject a specifier indicating the source of the
434                 // action is a user click instead of a text entry.
435                 // This tells the filter to show all values in sync mode.
436                 this.click$.pipe(filter(() =>
437                     !this.instance.isPopupOpen()
438                 )).pipe(mapTo('_CLICK_'))
439             ),
440
441             // mergeMap coalesces an observable into our stream.
442             mergeMap(term => this.addAsyncEntries(term)),
443             map((term: string) => {
444
445                 // Display no values when the input is empty and no
446                 // click action occurred.
447                 if (term === '') { return []; }
448
449                 // In sync-data mode, a click displays the full list.
450                 if (term === '_CLICK_' && !this.asyncDataSource) {
451                     return this.entrylist;
452                 }
453
454                 // Filter entrylist whose labels substring-match the
455                 // text entered.
456                 return this.entrylist.filter(entry => {
457                     const label = entry.label || entry.id;
458                     return label.toLowerCase().indexOf(term.toLowerCase()) > -1;
459                 });
460             })
461         );
462     }
463
464     writeValue(value: ComboboxEntry) {
465         if (value !== undefined && value !== null) {
466             this.startId = value.id;
467             this.applySelection();
468         }
469     }
470
471     registerOnChange(fn) {
472         this.propagateChange = fn;
473     }
474
475     registerOnTouched(fn) {
476         this.propagateTouch = fn;
477     }
478
479 }
480
481