opf/openproject

View on GitHub
frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service.ts

Summary

Maintainability
B
5 hrs
Test Coverage
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2024 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import { Injectable } from '@angular/core';
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { combine, input, InputState } from '@openproject/reactivestates';
import { States } from 'core-app/core/states/states.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { QuerySchemaResource } from 'core-app/features/hal/resources/query-schema-resource';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
import { QueryResource } from 'core-app/features/hal/resources/query-resource';
import { cloneHalResourceCollection } from 'core-app/features/hal/helpers/hal-resource-builder';
import { QueryFilterInstanceSchemaResource } from 'core-app/features/hal/resources/query-filter-instance-schema-resource';
import { QueryFilterResource } from 'core-app/features/hal/resources/query-filter-resource';
import { WorkPackageQueryStateService } from './wp-view-base.service';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<QueryFilterInstanceResource[]> {
  public hidden:string[] = [
    'datesInterval',
    'precedes',
    'follows',
    'relates',
    'duplicates',
    'duplicated',
    'blocks',
    'blocked',
    'partof',
    'includes',
    'requires',
    'required',
    'search',
    // The filter should be named subjectOrId but for some reason
    // it is only named subjectOr
    'subjectOrId',
    'subjectOr',
    'manualSort',
    'typeahead',
  ];

  /** Flag state to determine whether the filters are incomplete */
  private incomplete = input<boolean>(false);

  constructor(
    protected readonly states:States,
    readonly querySpace:IsolatedQuerySpace,
  ) {
    super(querySpace);
  }

  /**
   * Load all schemas for the current filters and fill respective states
   * @param query
   * @param schema
   */
  public initializeFilters(query:QueryResource, schema:QuerySchemaResource) {
    const filters = cloneHalResourceCollection<QueryFilterInstanceResource>(query.filters);

    this.availableState.putValue(schema.filtersSchemas.elements);
    this.pristineState.putValue(filters);
  }

  /**
   * Return whether the filters are empty
   */
  public get isEmpty() {
    const { value } = this.lastUpdatedState;
    return !value || value.length === 0;
  }

  public get availableState():InputState<QueryFilterInstanceSchemaResource[]> {
    return this.querySpace.available.filters;
  }

  /** Return whether the filters the user is working on are incomplete */
  public get incomplete$() {
    return this.incomplete.values$();
  }

  /**
   * Add a filter instantiation from the set of available filter schemas
   *
   * @param filter
   */
  public add(filter:QueryFilterInstanceResource) {
    this.updatesState.putValue([...this.rawFilters, filter]);
  }

  /**
   * Replace a filter, or add a new one
   */
  public replace(id:string, modifier:(filter:QueryFilterInstanceResource) => void):void {
    const filter:QueryFilterInstanceResource = this.instantiate(id);

    const newFilters = [...this.rawFilters];
    modifier(filter);

    const index = this.findIndex(id);
    if (index === -1) {
      newFilters.push(filter);
    } else {
      newFilters.splice(index, 1, filter);
    }

    this.update(newFilters);
  }

  /**
   * Modify a live filter and push it to the state.
   * Avoids copying the resource.
   *
   * Returns whether the filter was found and modified
   */
  public modify(id:string, modifier:(filter:QueryFilterInstanceResource) => void):boolean {
    const index = this.findIndex(id);

    if (index === -1) {
      return false;
    }

    const filters = [...this.rawFilters];
    modifier(filters[index]!);
    this.update(filters);

    return true;
  }

  /**
   * Get an instantiated filter without adding it to the current state
   * @param filterOrId The query filter or id to instantiate
   */
  public instantiate(filterOrId:QueryFilterResource|string):QueryFilterInstanceResource {
    const id = (filterOrId instanceof QueryFilterResource) ? filterOrId.id : filterOrId;

    const schema = _.find(
      this.availableSchemas,
      (schema) => (schema.filter.allowedValues as HalResource)[0].id === id,
    )!;

    return schema.getFilter();
  }

  /**
   * Remove one or more filters from the live state of filters.
   * @param filters Filters to be removed
   */
  public remove(...filters:(QueryFilterInstanceResource|string)[]) {
    const mapper = (f:QueryFilterInstanceResource|string) => ((f instanceof QueryFilterInstanceResource) ? f.id : f);
    const set = new Set<string>(filters.map(mapper));

    this.update(
      this.rawFilters.filter((f) => !set.has(mapper(f))),
    );
  }

  /**
   * Return the remaining visible filters from the given filters set.
   * @param filters Array of active filters, defaults to the current live state.
   */
  public remainingVisibleFilters(filters = this.current) {
    return this
      .remainingFilters(filters)
      .filter((filter) => this.hidden.indexOf(filter.id) === -1);
  }

  /**
   * Return all available filter resources.
   * They need to be instantiated before using them in this service.
   */
  public get availableFilters():QueryFilterResource[] {
    return this.availableSchemas.map((schema) => schema.allowedFilterValue);
  }

  private get availableSchemas():QueryFilterInstanceSchemaResource[] {
    return this.availableState.getValueOr([]);
  }

  /**
   * Determine whether all given filters are completely defined.
   * @param filters
   */
  public isComplete(filters:QueryFilterInstanceResource[]):boolean {
    return _.every(filters, (filter) => filter.isCompletelyDefined());
  }

  /**
   * Compare the current set of filters to the given query.
   * @param query
   */
  public hasChanged(query:QueryResource) {
    const comparer = (filter:HalResource[]) => filter.map((el) => el.$source);

    return !_.isEqual(
      comparer(query.filters),
      comparer(this.rawFilters),
    );
  }

  public valueFromQuery(query:QueryResource) {
    return undefined;
  }

  update(value:QueryFilterInstanceResource[]) {
    super.update(value);
    this.incomplete.putValue(false);
  }

  /**
   * Returns the live filter instance for the given ID, or undefined
   * if it does not exist.
   *
   * @param id Identifier of the filter
   */
  public find(id:string):QueryFilterInstanceResource|undefined {
    const index = this.findIndex(id);

    if (index === -1) {
      return;
    }

    return this.rawFilters[index];
  }

  /**
   * Returns the index of the filter, or -1 if it does not exist
   * @param id Identifier of the filter
   */
  public findIndex(id:string):number {
    return _.findIndex(this.current, (f) => f.id === id);
  }

  public applyToQuery(query:QueryResource):boolean {
    query.filters = this.cloneFilters();
    return true;
  }

  /**
   * Returns a shallow copy of the current filters.
   * Modifications to filters themselves will still
   */
  public get current():QueryFilterInstanceResource[] {
    return [...this.rawFilters];
  }

  /**
   * Returns a deep clone of the current filters set, may be used
   * to modify the filters without altering this state.
   */
  public cloneFilters():QueryFilterInstanceResource[] {
    return cloneHalResourceCollection<QueryFilterInstanceResource>(this.rawFilters);
  }

  /**
   * Returns the live state array, used for inspection of the filters
   * without modification.
   */
  protected get rawFilters():QueryFilterInstanceResource[] {
    return this.lastUpdatedState.value || [];
  }

  public get currentlyVisibleFilters():QueryFilterInstanceResource[] {
    const invisibleFilters = new Set(this.hidden);
    invisibleFilters.delete('search');

    return _.reject(this.current, (filter) => invisibleFilters.has(filter.id));
  }

  /**
   * Replace this filter state, but only if the given filters are complete
   * @param newState
   */
  public replaceIfComplete(newState:QueryFilterInstanceResource[]):void {
    if (this.isComplete(newState)) {
      this.update(newState);
    } else {
      this.incomplete.putValue(true);
    }
  }

  /**
   * Filters service depends on two states
   */
  public onReady():Promise<null> {
    return firstValueFrom(
      combine(this.pristineState, this.availableState)
        .values$()
        .pipe(map(() => null)),
    );
  }

  /**
   * Get all filters that are not in the current active set
   */
  private remainingFilters(filters = this.rawFilters) {
    return _.differenceBy(this.availableFilters, filters, (filter) => filter.id);
  }

  isAvailable(el:QueryFilterInstanceResource):boolean {
    return !!this.availableFilters.find((available) => available.id === el.id);
  }
}