opf/openproject

View on GitHub
frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts

Summary

Maintainability
D
1 day
Test Coverage
/* We just forward the ng-select outputs without renaming */
/* eslint-disable @angular-eslint/no-output-native */
import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChild,
  ElementRef,
  EventEmitter,
  forwardRef,
  HostBinding,
  Injector,
  Input,
  NgZone,
  OnChanges,
  OnInit,
  Output,
  SimpleChanges,
  TemplateRef,
  Type,
  ViewChild,
  ViewContainerRef,
} from '@angular/core';
import { DropdownPosition, NgSelectComponent } from '@ng-select/ng-select';
import { BehaviorSubject, merge, NEVER, Observable, of, Subject, timer } from 'rxjs';
import { debounce, distinctUntilChanged, filter, switchMap, tap } from 'rxjs/operators';
import { AddTagFn, GroupValueFn } from '@ng-select/ng-select/lib/ng-select.component';

import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import {
  Highlighting,
} from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import {
  OpAutocompleterFooterTemplateDirective,
} from 'core-app/shared/components/autocompleter/autocompleter-footer-template/op-autocompleter-footer-template.directive';

import { OpAutocompleterService } from './services/op-autocompleter.service';
import { OpAutocompleterHeaderTemplateDirective } from './directives/op-autocompleter-header-template.directive';
import { OpAutocompleterLabelTemplateDirective } from './directives/op-autocompleter-label-template.directive';
import { OpAutocompleterOptionTemplateDirective } from './directives/op-autocompleter-option-template.directive';
import {
  repositionDropdownBugfix,
} from 'core-app/shared/components/autocompleter/op-autocompleter/autocompleter.helper';
import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ID } from '@datorama/akita';
import { HttpClient } from '@angular/common/http';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';

export interface IAutocompleteItem {
  id:ID;
  href:string|null;
}

export interface IAutocompleterTemplateComponent {
  optionTemplate?:TemplateRef<Element>;
  headerTemplate?:TemplateRef<Element>;
  labelTemplate?:TemplateRef<Element>;
  footerTemplate?:TemplateRef<Element>;
}

@Component({
  selector: 'op-autocompleter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './op-autocompleter.component.html',
  styleUrls: ['./op-autocompleter.component.sass'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => OpAutocompleterComponent),
      multi: true,
    },
  ],
})
// It is component that you can use whenever you need an autocompleter
// it has all inputs and outputs of ng-select
// in order to use it, you only need to pass the data type and its filters
// you also can change the value of ng-select default options by changing @inputs and @outputs
export class OpAutocompleterComponent<T extends IAutocompleteItem = IAutocompleteItem>
  extends UntilDestroyedMixin
  implements OnInit, AfterViewInit, OnChanges, ControlValueAccessor {
  @HostBinding('class.op-autocompleter') className = true;

  @Input() public filters?:IAPIFilter[] = [];

  @Input() public resource:TOpAutocompleterResource;

  @Input() public model?:T|T[]|null;

  @Input() public searchKey?:string = '';

  @Input() public defaultData?:boolean = false;

  @Input() public focusDirectly?:boolean = true;

  @Input() public fetchDataDirectly?:boolean = false;

  @Input() public labelRequired?:boolean = true;

  @Input() public name?:string;

  @Input() public inputName?:string;

  @Input() public inputValue?:string|string[];

  @Input() public multipleAsSeparateInputs = true;

  @Input() public inputBindValue = 'id';

  @Input() public hiddenFieldAction = '';

  @Input() public required?:boolean = false;

  @Input() public disabled?:string;

  @Input() public searchable?:boolean = true;

  @Input() public clearable?:boolean = true;

  @Input() set addTag(val:boolean|AddTagFn) {
    this._addTag = val === true ? this.addNewObjectFn.bind(this) : val;
    this.cdRef.detectChanges();
  }

  get addTag():boolean|AddTagFn {
    return this._addTag;
  }

  private _addTag:boolean|AddTagFn = false;

  @Input() public id = '';

  @Input() public accesskey?:number;

  @Input() public items?:IOPAutocompleterOption[]|HalResource[];

  private items$ = new BehaviorSubject(null);

  @Input() public clearSearchOnAdd?:boolean = true;

  @Input() public classes?:string;

  @Input() public multiple = false;

  @Input() public openDirectly?:boolean = false;

  @Input() public bindLabel?:string;

  @Input() public bindValue?:string;

  @Input() public markFirst ? = true;

  @Input() public placeholder:string = this.I18n.t('js.autocompleter.placeholder');
  @Input() public notFoundText:string = this.I18n.t('js.autocompleter.notFoundText');
  @Input() public addTagText?:string;

  @Input() public loadingText:string = this.I18n.t('js.ajax.loading');

  @Input() public clearAllText?:string;

  @Input() public appearance?:string;

  @Input() public dropdownPosition?:DropdownPosition = 'auto';

  @Input() public appendTo?:string;

  @Input() public closeOnSelect?:boolean = true;

  @Input() public hideSelected?:boolean = false;

  @Input() public selectOnTab?:boolean = false;

  @Input() public openOnEnter?:boolean = true;

  @Input() public maxSelectedItems?:number;

  @Input() public groupBy?:string|(() => string);

  @Input() public groupValue?:GroupValueFn;

  @Input() public bufferAmount ? = 4;

  @Input() public virtualScroll = true;

  @Input() public selectableGroup?:boolean = false;

  @Input() public selectableGroupAsModel?:boolean = true;

  @Input() public searchFn:(term:string, item:any) => boolean;

  @Input() public trackByFn = this.defaultTrackByFunction();

  @Input() public compareWith = this.defaultCompareWithFunction();

  @Input() public clearOnBackspace?:boolean = true;

  @Input() public labelForId?:string;

  @Input() public inputAttrs?:{ [key:string]:string } = {};

  @Input() public tabIndex?:number;

  @Input() public readonly?:boolean = false;

  @Input() public searchWhileComposing?:boolean = true;

  @Input() public minTermLength ? = 0;

  @Input() public editableSearchTerm?:boolean = false;

  @Input() public keyDownFn ? = ():boolean => true;

  @Input() public typeahead:BehaviorSubject<string>|null = null;

  @Input() public resetOnChange?:boolean = false;

  // a function for setting the options of ng-select
  @Input() public getOptionsFn:(searchTerm:string) => Observable<unknown>;

  @Input() public url:string;

  @Output() public open = new EventEmitter<unknown>();

  @Output() public close = new EventEmitter<unknown>();

  @Output() public cancel = new EventEmitter<unknown>();

  @Output() public change = new EventEmitter<unknown>();

  @Output() public focus = new EventEmitter<unknown>();

  @Output() public blur = new EventEmitter<unknown>();

  @Output() public search = new EventEmitter<{ term:string, items:unknown[] }>();

  @Output() public keydown = new EventEmitter<unknown>();

  @Output() public clear = new EventEmitter<unknown>();

  @Output() public add = new EventEmitter();

  @Output() public remove = new EventEmitter();

  @Output() public scroll = new EventEmitter<{ start:number; end:number }>();

  @Output() public scrollToEnd = new EventEmitter();

  public active:Set<string>;

  public results$:Observable<unknown>;

  public loading$ = new Subject<boolean>();

  @ViewChild('ngSelectInstance') ngSelectInstance:NgSelectComponent;

  @ViewChild('syncedInput') syncedInput:ElementRef<HTMLInputElement>;

  @ContentChild(OpAutocompleterOptionTemplateDirective, { read: TemplateRef })
  projectedOptionTemplate:TemplateRef<Element>;

  optionTemplate:TemplateRef<Element>;

  @ContentChild(OpAutocompleterLabelTemplateDirective, { read: TemplateRef })
  projectedLabelTemplate:TemplateRef<Element>;

  labelTemplate:TemplateRef<Element>;

  @ContentChild(OpAutocompleterHeaderTemplateDirective, { read: TemplateRef })
  projectedHeaderTemplate:TemplateRef<Element>;

  headerTemplate:TemplateRef<Element>;

  @ContentChild(OpAutocompleterFooterTemplateDirective, { read: TemplateRef })
  projectedFooterTemplate:TemplateRef<Element>;

  footerTemplate:TemplateRef<Element>;

  initialDebounce = true;

  private opAutocompleterService = new OpAutocompleterService(this.apiV3Service);

  constructor(
    readonly injector:Injector,
    readonly elementRef:ElementRef,
    readonly http:HttpClient,
    readonly apiV3Service:ApiV3Service,
    readonly cdRef:ChangeDetectorRef,
    readonly ngZone:NgZone,
    readonly vcRef:ViewContainerRef,
    readonly I18n:I18nService,
  ) {
    super();
  }

  ngOnInit() {
    populateInputsFromDataset(this);

    if (!!this.getOptionsFn || this.defaultData) {
      this.typeahead = new BehaviorSubject<string>('');
    }

    if (this.inputValue && !this.model) {
      this
        .opAutocompleterService
        .loadValue(this.inputValue, this.resource, this.multiple)
        .subscribe((resource) => {
          this.model = resource as unknown as T;
          this.syncHiddenField(this.mappedInputValue);
          this.cdRef.detectChanges();
        });
    }
  }

  ngOnChanges(changes:SimpleChanges):void {
    if (changes.items) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      this.items$.next(changes.items.currentValue);
    }
  }

  ngAfterViewInit():void {
    if (this.inputName && this.model) {
      this.syncHiddenField(this.mappedInputValue);
    }

    this.ngZone.runOutsideAngular(() => {
      setTimeout(() => {
        this.results$ = merge(
          this.items$,
          this.autocompleteInputStream(),
        );

        if (this.fetchDataDirectly) {
          this.typeahead?.next('');
        }

        if (this.openDirectly) {
          this.ngSelectInstance.open();
          this.ngSelectInstance.focus();
        } else if (this.focusDirectly) {
          this.ngSelectInstance.focus();
        }
      }, 25);
    });
  }

  public get mappedInputValue():string|string[] {
    if (!this.model) {
      return '';
    }

    if (Array.isArray(this.model)) {
      return this.model.map((el) => el[this.inputBindValue as 'id'] as string);
    }

    return this.model[this.inputBindValue as 'id'] as string;
  }

  public repositionDropdown() {
    repositionDropdownBugfix(this.ngSelectInstance);
  }

  public opened():void { // eslint-disable-line no-unused-vars
    this.repositionDropdown();
    this.open.emit();
  }

  public getOptionsItems(searchKey:string):Observable<any> {
    return of((this.items as IOPAutocompleterOption[])?.filter((element) => element.name.includes(searchKey)));
  }

  public closeSelect():void {
    this.ngSelectInstance?.close();
  }

  public openSelect():void {
    this.ngSelectInstance?.open();
  }

  public focusSelect():void {
    this.ngZone.runOutsideAngular(() => {
      setTimeout(() => {
        this.ngSelectInstance.focus();
      }, 25);
    });
  }

  public closed():void {
    this.close.emit();
  }

  public changed(val:T|T[]|null):void {
    this.writeValue(val);
    this.onTouched(val);
    this.onChange(val);
    this.syncHiddenField(this.mappedInputValue);
    this.change.emit(val);

    if (this.resetOnChange) {
      this.ngSelectInstance.clearModel();
    }

    this.cdRef.detectChanges();
  }

  public searched(val:{ term:string, items:unknown[] }):void {
    this.search.emit(val);
  }

  public blured(val:unknown):void {
    this.blur.emit(val);
  }

  public focused(val:unknown):void {
    this.focus.emit(val);
  }

  public cleared(val:unknown):void {
    this.clear.emit(val);
  }

  public keydowned(val:unknown):void {
    this.keydown.emit(val);
  }

  public added(val:unknown):void {
    this.add.emit(val);
  }

  public canceled(val:unknown):void {
    this.cancel.emit(val);
  }

  public removed(val:unknown):void {
    this.remove.emit(val);
  }

  public scrolled(val:{ start:number; end:number }):void {
    this.scroll.emit(val);
  }

  public scrolledToEnd(val:unknown):void {
    this.scrollToEnd.emit(val);
  }

  public highlighting(property:string, id:string):string {
    return Highlighting.inlineClass(property, id);
  }

  private autocompleteInputStream():Observable<unknown> {
    if (!this.typeahead) {
      return NEVER;
    }

    return this.typeahead.pipe(
      filter(() => !!(this.defaultData || this.getOptionsFn)),
      distinctUntilChanged(),
      tap(() => this.loading$.next(true)),
      // tap(() => console.log('Debounce is ', this.getDebounceTimeout())),
      debounce(() => timer(this.getDebounceTimeout())),
      switchMap((queryString:string) => {
        if (this.defaultData) {
          return this.opAutocompleterService.loadData(queryString, this.resource, this.filters, this.searchKey);
        }

        if (this.getOptionsFn) {
          return this.getOptionsFn(queryString);
        }

        return NEVER;
      }),
      tap(
        () => this.loading$.next(false),
        () => this.loading$.next(false),
      ),
    );
  }

  private getDebounceTimeout():number {
    if (this.initialDebounce) {
      this.initialDebounce = false;
      return 0;
    }
    return 50;
  }

  writeValue(value:T|T[]|null):void {
    this.model = value;
  }

  onChange = (_:T|T[]|null):void => {
  };

  onTouched = (_:T|T[]|null):void => {
  };

  registerOnChange(fn:(_:T|T[]|null) => void):void {
    this.onChange = fn;
  }

  registerOnTouched(fn:(_:T|T[]|null) => void):void {
    this.onTouched = fn;
  }

  /**
   * Instantiate the given template component and apply any given TemplateRef to this component
   * so they can be passed to ng-select.
   *
   * @param component A templating component defining any combination of the header, option, label, or footer templates.
   * @param inputs Initial inputs to the templating component
   * @protected
   */
  protected applyTemplates(component:Type<IAutocompleterTemplateComponent>, inputs:{ [key:string]:unknown } = {}) {
    const componentRef = this.vcRef.createComponent(component, { injector: this.templateInjector });
    Object.keys(inputs).forEach((key) => {
      const value = inputs[key];
      componentRef.setInput(key, value);
    });

    componentRef.changeDetectorRef.detectChanges();

    ['optionTemplate', 'headerTemplate', 'labelTemplate', 'footerTemplate'].forEach((name:keyof IAutocompleterTemplateComponent) => {
      const template = componentRef.instance[name];
      if (template) {
        this[name] = template;
      }
    });
  }

  protected get templateInjector() {
    return Injector.create(
      {
        providers: [{ provide: OpAutocompleterComponent, useValue: this }],
        parent: this.injector,
      },
    );
  }

  protected syncHiddenField(mappedInputValue:string|string[]) {
    const input = this.syncedInput?.nativeElement;
    if (input) {
      input.value = Array.isArray(mappedInputValue) ? mappedInputValue.join(',') : mappedInputValue;
      const event = new Event('change');
      input.dispatchEvent(event);
    }
  }

  public addNewObjectFn(searchTerm:string):unknown {
    return this.bindLabel ? { [this.bindLabel]: searchTerm } : searchTerm;
  }

  protected defaultTrackByFunction():((x:unknown) => unknown)|null {
    return null;
  }

  protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean {
    return (a, b) => a === b;
  }
}