opf/openproject

View on GitHub
frontend/src/app/features/work-packages/components/wp-query/url-params-helper.ts

Summary

Maintainability
D
1 day
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 { QueryResource } from 'core-app/features/hal/resources/query-resource';
import { QuerySortByResource } from 'core-app/features/hal/resources/query-sort-by-resource';
import { HalLink } from 'core-app/features/hal/hal-link/hal-link';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import isPersistedResource from 'core-app/features/hal/helpers/is-persisted-resource';
import { Injectable } from '@angular/core';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
import { ApiV3Filter, ApiV3FilterBuilder, FilterOperator } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import { PaginationService } from 'core-app/shared/components/table-pagination/pagination-service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { QueryFilterResource } from 'core-app/features/hal/resources/query-filter-resource';

export interface QueryPropsFilter {
  n:string;
  o:string
  v:unknown[];
}

export interface QueryProps {
  // Columns
  c:string[];
  // Sums enabled?
  s?:boolean;
  // Sort by criteria
  t?:string;
  // Group by criteria
  g:string|null;
  // Filters
  f:QueryPropsFilter[];
  // Hierarchies
  hi:boolean;
  // Highlighting mode
  hl?:string;
  // Highlighted attributes
  hla?:string[];
  // Display representation
  dr?:string;
  // Include subprojects
  is?:boolean;
  // Pagination
  pa?:string|number;
  pp?:string|number;

  // Timeline options
  tv?:boolean;
  tzl?:string;
  tll?:string;

  // Timestamps options
  ts?:string;
}

export interface QueryRequestParams {
  page?:string|number;
  pageSize:string|number;
  offset:string|number;
  'columns[]':string[];
  showSums:boolean;
  timelineVisible:boolean;
  timelineLabels:string;
  timelineZoomLevel:string;
  displayRepresentation:string;
  includeSubprojects:boolean;
  highlightingMode:string;
  'highlightedAttributes[]':string[];
  showHierarchies:boolean;
  groupBy:string|null;
  filters:string;
  sortBy:string;
  timestamps:string;
  valid_subset?:boolean;
}

@Injectable({ providedIn: 'root' })
export class UrlParamsHelperService {
  public constructor(public paginationService:PaginationService) {
  }

  // copied more or less from angular buildUrl
  public buildQueryString(params:any) {
    if (!params) {
      return undefined;
    }

    const parts:string[] = [];
    _.each(params, (value, key) => {
      if (!value) {
        return;
      }
      if (!Array.isArray(value)) {
        value = [value];
      }

      _.each(value, (v) => {
        if (v !== null && typeof v === 'object') {
          v = JSON.stringify(v);
        }
        parts.push(`${encodeURIComponent(key)}=${
          encodeURIComponent(v)}`);
      });
    });

    return parts.join('&');
  }

  public encodeQueryJsonParams(
    query:QueryResource,
    extender?:Partial<QueryProps>|((paramsData:QueryProps) => QueryProps),
  ):string {
    const paramsData:QueryProps = {
      c: query.columns.map((column) => column.id),
      hi: !!query.showHierarchies,
      g: _.get(query.groupBy, 'id', ''),
      dr: query.displayRepresentation,
      is: query.includeSubprojects,
      ...this.encodeSums(query),
      ...this.encodeTimelineVisible(query),
      ...this.encodeHighlightingMode(query),
      ...this.encodeHighlightedAttributes(query),
      ...this.encodeSortBy(query),
      ...this.encodeFilters(query.filters),
      ...this.encodeTimestamps(query),
    };

    if (typeof extender === 'function') {
      return JSON.stringify(extender(paramsData));
    }

    if (typeof extender === 'object') {
      return JSON.stringify(_.merge(paramsData, extender));
    }

    return JSON.stringify(paramsData);
  }

  private encodeSums(query:QueryResource):Partial<QueryProps> {
    if (query.sums) {
      return { s: query.sums };
    }

    return {};
  }

  private encodeHighlightingMode(query:QueryResource):Partial<QueryProps> {
    if (query.highlightingMode && (isPersistedResource(query) || query.highlightingMode !== 'inline')) {
      return { hl: query.highlightingMode };
    }

    return {};
  }

  private encodeHighlightedAttributes(query:QueryResource):Partial<QueryProps> {
    if (query.highlightingMode === 'inline') {
      if (Array.isArray(query.highlightedAttributes) && query.highlightedAttributes.length > 0) {
        return { hla: query.highlightedAttributes.map((el) => idFromLink(el.href)) };
      }
    }

    return {};
  }

  private encodeSortBy(query:QueryResource):Partial<QueryProps> {
    if (query.sortBy) {
      return {
        t: query
          .sortBy
          .map((sort:QuerySortByResource) => (sort.id as string).replace('-', ':'))
          .join(),
      };
    }

    return {};
  }

  private encodeTimestamps(query:QueryResource):Partial<QueryProps> {
    if (query.timestamps) {
      return { ts: query.timestamps.join(',') };
    }

    return {};
  }

  public encodeFilters(filters:QueryFilterInstanceResource[]):Pick<QueryProps, 'f'> {
    if (filters && filters.length > 0) {
      const filterProps:QueryPropsFilter[] = filters.map((filter) => ({
        n: filter.id,
        o: filter.operator.id,
        v: filter.values.map((value:HalResource|string) => this.queryFilterValueToParam(value)),
      }));

      return { f: filterProps };
    }

    return { f: [] };
  }

  private encodeTimelineVisible(query:QueryResource):Partial<QueryProps> {
    const paramsData:Partial<QueryProps> = {};

    if (query.timelineVisible) {
      paramsData.tv = query.timelineVisible;

      if (!_.isEmpty(query.timelineLabels)) {
        paramsData.tll = JSON.stringify(query.timelineLabels);
      }

      paramsData.tzl = query.timelineZoomLevel;
    } else {
      paramsData.tv = false;
    }

    return paramsData;
  }

  public buildV3GetQueryFromJsonParams(updateJson:string|null) {
    const queryData:Partial<QueryRequestParams> = {
      pageSize: this.paginationService.getPerPage(),
    };

    if (!updateJson) {
      return queryData;
    }

    const properties = JSON.parse(updateJson) as QueryProps;

    if (properties.c) {
      queryData['columns[]'] = properties.c.map((column:any) => column);
    }
    if (properties.s) {
      queryData.showSums = properties.s;
    }

    queryData.timelineVisible = properties.tv;

    if (properties.tv) {
      if (properties.tll) {
        queryData.timelineLabels = properties.tll;
      }

      if (properties.tzl) {
        queryData.timelineZoomLevel = properties.tzl;
      }
    }

    if (properties.dr) {
      queryData.displayRepresentation = properties.dr;
    }

    if (properties.is !== undefined) {
      queryData.includeSubprojects = properties.is;
    }

    if (properties.hl) {
      queryData.highlightingMode = properties.hl;
    }

    if (properties.hla) {
      queryData['highlightedAttributes[]'] = properties.hla.map((column:any) => column);
    }

    if (properties.hi !== undefined) {
      queryData.showHierarchies = properties.hi;
    }

    queryData.groupBy = _.get(properties, 'g', '');

    // Filters
    if (properties.f) {
      const filters = properties.f.map((urlFilter:any) => {
        const attributes = {
          operator: decodeURIComponent(urlFilter.o),
        };
        if (urlFilter.v) {
          // the array check is only there for backwards compatibility reasons.
          // Nowadays, it will always be an array;
          const vs = Array.isArray(urlFilter.v) ? urlFilter.v : [urlFilter.v];
          _.extend(attributes, { values: vs });
        }
        const filterData:any = {};
        filterData[urlFilter.n] = attributes;

        return filterData;
      });

      queryData.filters = JSON.stringify(filters);
    }

    // Sortation
    if (properties.t) {
      queryData.sortBy = JSON.stringify(properties.t.split(',').map((sort:any) => sort.split(':')));
    }

    if (properties.ts) {
      queryData.timestamps = properties.ts;
    }

    // Pagination
    if (properties.pa) {
      queryData.offset = properties.pa;
    }
    if (properties.pp) {
      queryData.pageSize = properties.pp;
    }

    return queryData;
  }

  public buildV3GetQueryFromQueryResource(
    query:QueryResource,
    additionalParams:object = {},
    contextual:object = {},
  ):Partial<QueryRequestParams> {
    const queryData:Partial<QueryRequestParams> = {};

    queryData['columns[]'] = this.buildV3GetColumnsFromQueryResource(query);
    queryData.showSums = query.sums;
    queryData.timelineVisible = !!query.timelineVisible;

    if (query.timelineVisible) {
      queryData.timelineZoomLevel = query.timelineZoomLevel;
      queryData.timelineLabels = JSON.stringify(query.timelineLabels);
    }

    if (query.highlightingMode) {
      queryData.highlightingMode = query.highlightingMode;
    }

    if (query.highlightedAttributes && query.highlightingMode === 'inline') {
      queryData['highlightedAttributes[]'] = query.highlightedAttributes.map((el) => el.href as string);
    }

    if (query.displayRepresentation) {
      queryData.displayRepresentation = query.displayRepresentation;
    }

    queryData.includeSubprojects = !!query.includeSubprojects;
    queryData.showHierarchies = !!query.showHierarchies;
    queryData.groupBy = _.get(query.groupBy, 'id', '');

    // Filters
    queryData.filters = this.buildV3GetFiltersAsJson(query.filters, contextual);

    // Sortation
    queryData.sortBy = this.buildV3GetSortByFromQuery(query);
    queryData.timestamps = query.timestamps.join(',');

    return _.extend(additionalParams, queryData) as Partial<QueryRequestParams>;
  }

  public queryFilterValueToParam(value:HalResource|string|boolean):string {
    if (typeof (value) === 'boolean') {
      return value ? 't' : 'f';
    }

    if (!value) {
      return '';
    }

    const halValue = value as HalResource;
    if (halValue.id) {
      return halValue.id.toString();
    }
    if (halValue.href) {
      return halValue.href.split('/').pop() as string;
    }

    return value.toString();
  }

  private buildV3GetColumnsFromQueryResource(query:QueryResource):string[] {
    if (query.columns) {
      return query.columns.map((column:any) => column.id || idFromLink(column.href)) as string[];
    }
    if (query._links.columns) {
      return query._links.columns.map((column:HalLink) => idFromLink(column.href as string)) as string[];
    }

    return [];
  }

  public buildV3GetFilters(filters:QueryFilterInstanceResource[], replacements = {}):ApiV3Filter[] {
    const newFilters = filters.map((filter:QueryFilterInstanceResource) => {
      const id = this.buildV3GetFilterIdFromFilter(filter);
      const operator = this.buildV3GetOperatorIdFromFilter(filter);
      const values = this.buildV3GetValuesFromFilter(filter).map((value) => {
        _.each(replacements, (val:string, key:string) => {
          value = value.replace(`{${key}}`, val);
        });

        return value;
      });

      const filterHash:ApiV3Filter = {};
      filterHash[id] = { operator: operator as FilterOperator, values };

      return filterHash;
    });

    return newFilters;
  }

  public filterBuilderFrom(filters:QueryFilterInstanceResource[]) {
    const builder:ApiV3FilterBuilder = new ApiV3FilterBuilder();

    filters.forEach((filter:QueryFilterInstanceResource) => {
      const id = this.buildV3GetFilterIdFromFilter(filter);
      const operator = this.buildV3GetOperatorIdFromFilter(filter) as FilterOperator;
      const values = this.buildV3GetValuesFromFilter(filter);

      builder.add(id, operator, values);
    });

    return builder;
  }

  public buildV3GetFiltersAsJson(filter:QueryFilterInstanceResource[], contextual = {}) {
    return JSON.stringify(this.buildV3GetFilters(filter, contextual));
  }

  public buildV3GetFilterIdFromFilter(filter:QueryFilterInstanceResource) {
    const href = filter.filter ? filter.filter.href : filter._links.filter.href;

    return idFromLink(href as string);
  }

  public buildV3GetValuesFromFilter(filter:QueryFilterInstanceResource|QueryFilterResource) {
    if (filter.values) {
      return _.map(filter.values, (v:any) => this.queryFilterValueToParam(v));
    }
    return _.map(filter._links.values, (v:any) => idFromLink(v.href as string));
  }

  private buildV3GetOperatorIdFromFilter(filter:QueryFilterInstanceResource) {
    if (filter.operator) {
      return filter.operator.id || idFromLink(filter.operator.href);
    }
    const { href } = filter._links.operator;

    return idFromLink(href as string);
  }

  private buildV3GetSortByFromQuery(query:QueryResource) {
    const sortBys = query.sortBy ? query.sortBy : query._links.sortBy;
    const sortByIds = sortBys.map((sort:QuerySortByResource) => {
      if (sort.id) {
        return sort.id;
      }
      return idFromLink(sort.href);
    });

    return JSON.stringify(sortByIds.map((id:string) => id.split('-')));
  }
}