import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, QueryList, ViewChild, Renderer2, Output } from '@angular/core'; import { ControlValueAccessor, FormControl } from '@angular/forms'; import { MatOption } from '@angular/material/core'; import { MatSelect } from '@angular/material/select'; import { take, takeUntil, startWith, map, debounceTime, filter, tap, switchMap } from 'rxjs/operators'; import { Subject, ReplaySubject, Observable, forkJoin, of } from 'rxjs'; import { LatinisePipe } from 'ngx-pipes'; import { TranslateService } from '@ngx-translate/core'; import { AppService } from '../../service/app.service'; import { SortPipe } from '../sorting.pipe'; import { FunctionsService } from '../../service/functions.service'; import { HttpClient } from '@angular/common/http'; @Component({ selector: 'plugin-select-autocomplete-search', templateUrl: 'plugin-select-autocomplete-search.component.html', styleUrls: ['plugin-select-autocomplete-search.component.scss', '../../app/indexation/indexing-form/indexing-form.component.scss'], providers: [SortPipe] }) export class PluginSelectAutocompleteSearchComponent implements OnInit, OnDestroy, AfterViewInit, ControlValueAccessor { /** Label of the search placeholder */ @Input() placeholderLabel = this.translate.instant('lang.chooseValue'); @Input() formControlSelect: FormControl = new FormControl(); @Input() datas: any = []; @Input() returnValue: 'id' | 'object' = 'id'; @Input() label: string; @Input() id: string = ''; @Input() showResetOption: boolean; @Input() showLabel: boolean = false; @Input() required: boolean = false; @Input() hideErrorDesc: boolean = true; @Input() multiple: boolean = false; @Input() optGroupTarget: string = null; /** * Route datas used in async autocomplete. Incompatible with @datas */ @Input() routeDatas: string[]; /** * ex : [ { id : 'group1' , label: 'Group 1'} ] */ @Input() optGroupList: any = null; /** * ex : {class:'fa-circle', color:'#fffff', title: 'foo'} */ @Input() suffixIcon: any = null; @Input() class: string = 'input-form'; /** * Catch external event after select an element in autocomplete */ @Output() afterSelected = new EventEmitter(); @Output() afterOpened = new EventEmitter(); /** Reference to the search input field */ @ViewChild('searchSelectInput', { read: ElementRef, static: true }) searchSelectInput: ElementRef; @ViewChild('test', { static: true }) matSelect: MatSelect; public filteredDatas: Observable<string[]>; public filteredDatasMulti: ReplaySubject<any[]> = new ReplaySubject<any[]>(1); /** Reference to the MatSelect options */ public _options: QueryList<MatOption>; /** Previously selected values when using <mat-select [multiple]="true">*/ private previousSelectedValues: any[]; /** Whether the backdrop class has been set */ private overlayClassSet = false; /** Event that emits when the current value changes */ private change = new EventEmitter<string>(); /** Subject that emits when the component has been destroyed. */ private _onDestroy = new Subject<void>(); formControlSearch = new FormControl(); selecteded: any = []; /** Current search value */ get value(): string { return this._value; } private _value: string; onChange: Function = (_: any) => { }; onTouched: Function = (_: any) => { }; constructor( public http: HttpClient, public translate: TranslateService, private latinisePipe: LatinisePipe, private changeDetectorRef: ChangeDetectorRef, private renderer: Renderer2, public appService: AppService, public functions: FunctionsService, private sortPipe: SortPipe) { } ngOnInit() { if (this.optGroupList !== null) { this.initOptGroups(); } // set custom panel class const panelClass = 'mat-select-search-panel'; if (this.matSelect.panelClass) { if (Array.isArray(this.matSelect.panelClass)) { this.matSelect.panelClass.push(panelClass); } else if (typeof this.matSelect.panelClass === 'string') { this.matSelect.panelClass = [this.matSelect.panelClass, panelClass]; } else if (typeof this.matSelect.panelClass === 'object') { this.matSelect.panelClass[panelClass] = true; } } else { this.matSelect.panelClass = panelClass; } // when the select dropdown panel is opened or closed this.matSelect.openedChange .pipe(takeUntil(this._onDestroy)) .subscribe((opened) => { if (opened) { // focus the search field when opening if (!this.appService.getViewMode()) { this._focus(); } } else { // clear it when closing // this._reset(); this.formControlSearch.reset(); } }); // set the first item active after the options changed this.matSelect.openedChange .pipe(take(1)) .pipe(takeUntil(this._onDestroy)) .subscribe(() => { this._options = this.matSelect.options; this._options.changes .pipe(takeUntil(this._onDestroy)) .subscribe(() => { const keyManager = this.matSelect._keyManager; if (keyManager && this.matSelect.panelOpen) { // avoid "expression has been changed" error setTimeout(() => { keyManager.setFirstItemActive(); }); } }); }); this.formControlSearch.valueChanges .pipe( debounceTime(300), filter(value => value !== null && value.length > 2), // distinctUntilChanged(), // tap(() => this.loading = true), switchMap((data: any) => this.getDatas(data)), tap((data: any) => { /*if (data.length === 0) { if (this.manageDatas !== undefined) { this.listInfo = this.translate.instant('lang.noAvailableValue') + ' <div>' + this.translate.instant('lang.typeEnterToCreate') + '</div>'; } else { this.listInfo = this.translate.instant('lang.noAvailableValue'); } } else { this.listInfo = ''; }*/ this.datas = this.datas.filter((val: any) => this.formControlSelect.value.indexOf(val.id) > -1).concat(data.filter((val: any) => this.formControlSelect.value.indexOf(val.id) === -1)); this.filteredDatas = of(this.datas); // this.loading = false; }) ).subscribe(); // this.initMultipleHandling(); } resetACDatas() { this.datas = this.datas.filter((val: any) => this.formControlSelect.value.indexOf(val.id) > -1); this.filteredDatas = of(this.datas); } initOptGroups() { this.datas.unshift({ id: 0, label: 'toto', disabled: true }); let tmpArr = []; this.optGroupList = this.sortPipe.transform(this.optGroupList, 'label'); this.optGroupList.forEach(group => { tmpArr.push({ id: group.id, label: group.label, disabled: true }); tmpArr = tmpArr.concat(this.datas.filter(data => data[this.optGroupTarget] === group.id).map(data => { return { ...data, title: data.label, label: '  ' + data.label }; })); }); this.datas = tmpArr; } ngOnDestroy() { this._onDestroy.next(); this._onDestroy.complete(); } ngAfterViewInit() { if (this.datas.length > 5) { this.setOverlayClass(); } } /** * Handles the key down event with MatSelect. * Allows e.g. selecting with enter key, navigation with arrow keys, etc. * @param {KeyboardEvent} event * @private */ _handleKeydown(event: KeyboardEvent) { if (event.keyCode === 32) { // do not propagate spaces to MatSelect, as this would select the currently active option event.stopPropagation(); } } writeValue(value: string) { const valueChanged = value !== this._value; if (valueChanged) { this._value = value; this.change.emit(value); } } onInputChange(value: any) { const valueChanged = value !== this._value; if (valueChanged) { this._value = value; this.onChange(value); this.change.emit(value); } } onBlur(value: string) { this.writeValue(value); this.onTouched(); } registerOnChange(fn: Function) { this.onChange = fn; } registerOnTouched(fn: Function) { this.onTouched = fn; } /** * Focuses the search input field * @private */ public _focus() { // save and restore scrollTop of panel, since it will be reset by focus() // note: this is hacky const panel = this.matSelect.panel.nativeElement; const scrollTop = panel.scrollTop; // focus this.renderer.selectRootElement('#searchSelectInput').focus(); panel.scrollTop = scrollTop; } /** * Resets the current search value * @param {boolean} focus whether to focus after resetting * @private */ public _reset(focus?: boolean) { this.formControlSearch.reset(); this.resetACDatas(); this.renderer.selectRootElement('#searchSelectInput').focus(); } /** * Sets the overlay class to correct offsetY * so that the selected option is at the position of the select box when opening */ private setOverlayClass() { if (this.overlayClassSet) { return; } const overlayClass = 'cdk-overlay-pane-select-search'; this.matSelect.overlayDir.attach .pipe(takeUntil(this._onDestroy)) .subscribe(() => { // note: this is hacky, but currently there is no better way to do this if (this.searchSelectInput !== undefined) { this.searchSelectInput.nativeElement.parentElement.parentElement .parentElement.parentElement.parentElement.classList.add(overlayClass); } }); this.overlayClassSet = true; } /** * Initializes handling <mat-select [multiple]="true"> * Note: to improve this code, mat-select should be extended to allow disabling resetting the selection while filtering. */ private initMultipleHandling() { // if <mat-select [multiple]="true"> // store previously selected values and restore them when they are deselected // because the option is not available while we are currently filtering this.matSelect.valueChange .pipe(takeUntil(this._onDestroy)) .subscribe((values) => { if (this.matSelect.multiple) { let restoreSelectedValues = false; if (this._value && this._value.length && this.previousSelectedValues && Array.isArray(this.previousSelectedValues)) { if (!values || !Array.isArray(values)) { values = []; } const optionValues = this.matSelect.options.map(option => option.value); this.previousSelectedValues.forEach(previousValue => { if (values.indexOf(previousValue) === -1 && optionValues.indexOf(previousValue) === -1) { // if a value that was selected before is deselected and not found in the options, it was deselected // due to the filtering, so we restore it. values.push(previousValue); restoreSelectedValues = true; } }); } if (restoreSelectedValues) { this.matSelect._onChange(values); } this.previousSelectedValues = values; } }); } private _filter(value: string, showSelectedValues: boolean = false): string[] { if (value === '__SELECTED') { return this.datas.filter((option: any) => this.formControlSelect.value.indexOf(option['id']) > -1); } else if (typeof value === 'string' && value !== '') { const filterValue = this.latinisePipe.transform(value.toLowerCase()); return this.datas.filter((option: any) => !option['disabled'] && this.latinisePipe.transform(option['label'].toLowerCase()).includes(filterValue)); } else { return this.datas; } } launchEvent(ev: any) { if (this.afterSelected !== undefined) { this.afterSelected.emit(ev.value); } } getErrorMsg(error: any) { if (error.required !== undefined) { return this.translate.instant('lang.requiredField'); } else if (error.pattern !== undefined || error.email !== undefined) { return this.translate.instant('lang.badFormat'); } else { return 'unknow validator'; } } getDatas(data: string) { const arrayObs: any = []; const test: any = []; this.routeDatas.forEach(element => { arrayObs.push(this.http.get('..' + element, { params: { 'search': data } })); }); return forkJoin(arrayObs).pipe( map(items => { items.forEach((element: any) => { element.forEach((element2: any) => { test.push({ id: element2.id, label: element2.idToDisplay }); }); }); return test; }) ); } }