opf/openproject

View on GitHub
frontend/src/app/features/work-packages/components/filters/filter-searchable-multiselect-value/filter-searchable-multiselect-value.component.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import { NgSelectComponent } from '@ng-select/ng-select';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import {
  Observable,
  of,
} from 'rxjs';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import {
  map,
  shareReplay,
  switchMap,
  withLatestFrom,
} from 'rxjs/operators';
import { ApiV3ResourceCollection } from 'core-app/core/apiv3/paths/apiv3-resource';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { ApiV3Resource } from 'core-app/core/apiv3/cache/cachable-apiv3-resource';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  EventEmitter,
  Input,
  NgZone,
  OnInit,
  Output,
  ViewChild,
} from '@angular/core';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
import { take } from 'rxjs/internal/operators/take';
import { CollectionResource } from 'core-app/features/hal/resources/collection-resource';
import { compareByHref } from 'core-app/shared/helpers/angular/tracking-functions';

@Component({
  selector: 'op-filter-searchable-multiselect-value',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './filter-searchable-multiselect-value.component.html',
})
export class FilterSearchableMultiselectValueComponent extends UntilDestroyedMixin implements OnInit {
  @Input() public filter:QueryFilterInstanceResource;

  @Input() public shouldFocus = false;

  @Output() public filterChanged = new EventEmitter<QueryFilterInstanceResource>();

  private meValue = this.halResourceService.createHalResource(
    {
      _links: {
        self: {
          href: this.apiV3Service.users.me.path,
          title: this.I18n.t('js.label_me'),
        },
      },
    }, true,
  );

  autocompleterFn = (searchTerm:string):Observable<HalResource[]> => this.autocomplete(searchTerm);

  initialRequest$:Observable<CollectionResource>;

  itemTracker = (item:HalResource):string => item.href || item.id || item.name;

  compareByHref = compareByHref;

  resourceType:string|null = null;

  readonly text = {
    placeholder: this.I18n.t('js.placeholders.selection'),
  };

  public get value():string[]|HalResource[] {
    return this.filter.values;
  }

  @ViewChild('ngSelectInstance', { static: true }) ngSelectInstance:NgSelectComponent;

  constructor(readonly halResourceService:HalResourceService,
    readonly apiV3Service:ApiV3Service,
    readonly cdRef:ChangeDetectorRef,
    readonly I18n:I18nService,
    protected currentProject:CurrentProjectService,
    protected currentUser:CurrentUserService,
    readonly halNotification:HalResourceNotificationService,
    readonly ngZone:NgZone) {
    super();
  }

  ngOnInit():void {
    if (this.filter.id === 'id') {
      this.resourceType = 'work_packages';
    }

    this.initialRequest$ = this
      .loadCollection('')
      .pipe(
        shareReplay(1),
      );
  }

  private autocomplete(matching:string):Observable<HalResource[]> {
    return this
      .initialRequest$
      .pipe(
        switchMap((initialLoad) => {
          // If we already loaded all values, just compare in the frontend
          if (initialLoad.count === initialLoad.total) {
            return this.matchingItems(initialLoad.elements, matching);
          }

          // Otherwise, request the matching API call
          return this
            .loadCollection(matching)
            .pipe(
              switchMap((collection) => this.withMeValue(matching, collection.elements)),
            );
        }),
      );
  }

  matchingItems(elements:HalResource[], matching:string):Observable<HalResource[]> {
    let filtered:HalResource[];

    if (matching === '' || !matching) {
      filtered = elements;
    } else {
      const lowered = matching.toLowerCase();
      filtered = elements.filter((el) => (el.id as string).includes(lowered) || el.name.toLowerCase().includes(lowered));
    }

    return this.withMeValue(matching, filtered);
  }

  private loadCollection(matching:string):Observable<CollectionResource> {
    const filters:ApiV3FilterBuilder = this.createFilters(matching);

    return (this.apiV3Service.collectionFromString(this.allowedValuesLink) as
      ApiV3ResourceCollection<HalResource, ApiV3Resource>)
      .filtered(filters, { pageSize: '-1' })
      .get();
  }

  private createFilters(matching:string):ApiV3FilterBuilder {
    const filters = new ApiV3FilterBuilder();

    if (matching) {
      filters.add('typeahead', '**', [matching]);
    }

    return filters;
  }

  public setValues(val:any) {
    this.filter.values = val.length > 0 ? (Array.isArray(val) ? val : [val]) : [] as HalResource[];
    this.filterChanged.emit(this.filter);
    this.cdRef.detectChanges();
  }

  private get allowedValuesLink():string {
    /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
    const { href } = this.filter.currentSchema!.values!.allowedValues as { href:string };

    return href;
  }

  private withMeValue(matching:string, elements:HalResource[]):Observable<HalResource[]> {
    if (!this.isUserResource || (!!matching && matching !== 'me')) {
      return of(elements);
    }

    return this
      .currentUser
      .isLoggedIn$
      .pipe(
        take(1),
        withLatestFrom(this.currentUser.user$),
        map(([logged, user]) => {
          if (logged && user) {
            return [this.meValue].concat(elements);
          }

          return elements;
        }),
      );
  }

  private get isUserResource() {
    const type = _.get(this.filter.currentSchema, 'values.type', null) as string;
    return type && type.indexOf('User') > 0;
  }
}