opf/openproject

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

Summary

Maintainability
A
2 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 { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { input } from '@openproject/reactivestates';
import { WorkPackageTimelineState } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-table-timeline';
import { zoomLevelOrder } from 'core-app/features/work-packages/components/wp-table/timeline/wp-timeline';
import { QueryResource, TimelineLabels, TimelineZoomLevel } from 'core-app/features/hal/resources/query-resource';
import { WorkPackageQueryStateService } from './wp-view-base.service';

@Injectable()
export class WorkPackageViewTimelineService extends WorkPackageQueryStateService<WorkPackageTimelineState> {
  /** Remember the computed zoom level to correct zooming after leaving autozoom */
  public appliedZoomLevel$ = input<TimelineZoomLevel>('auto');

  public constructor(protected readonly querySpace:IsolatedQuerySpace) {
    super(querySpace);
  }

  public valueFromQuery(query:QueryResource) {
    return {
      ...this.defaultState,
      visible: query.timelineVisible,
      zoomLevel: query.timelineZoomLevel,
      labels: query.timelineLabels,
    };
  }

  public set appliedZoomLevel(val:TimelineZoomLevel) {
    this.appliedZoomLevel$.putValue(val);
  }

  public get appliedZoomLevel() {
    return this.appliedZoomLevel$.value!;
  }

  public hasChanged(query:QueryResource) {
    const visibilityChanged = this.isVisible !== query.timelineVisible;
    const zoomLevelChanged = this.zoomLevel !== query.timelineZoomLevel;
    const labelsChanged = !_.isEqual(this.current.labels, query.timelineLabels);

    return visibilityChanged || zoomLevelChanged || labelsChanged;
  }

  public applyToQuery(query:QueryResource) {
    query.timelineVisible = this.isVisible;
    query.timelineZoomLevel = this.zoomLevel;
    query.timelineLabels = this.current.labels;

    return false;
  }

  public toggle() {
    const currentState = this.current;
    this.setVisible(!currentState.visible);
  }

  public setVisible(value:boolean) {
    this.updatesState.putValue({ ...this.current, visible: value });
  }

  public get isVisible() {
    return this.current.visible;
  }

  public get zoomLevel() {
    return this.current.zoomLevel;
  }

  public get labels() {
    if (_.isEmpty(this.current.labels)) {
      return this.defaultLabels;
    }

    return this.current.labels;
  }

  public updateLabels(labels:TimelineLabels) {
    this.modify({ labels });
  }

  public getNormalizedLabels(workPackage:WorkPackageResource) {
    const labels:TimelineLabels = this.defaultLabels;

    _.each(this.current.labels, (attribute:string | null, positionAsString:string) => {
      // RR: Lodash typings declare the position as string. However, it is save to cast
      // to `keyof TimelineLabels` because `this.current.labels` is of type TimelineLabels.
      const position:keyof TimelineLabels = positionAsString as keyof TimelineLabels;

      // Set to null to explicitly disable
      if (attribute === '') {
        labels[position] = null;
      } else {
        labels[position] = attribute;
      }
    });

    return labels;
  }

  public setZoomLevel(level:TimelineZoomLevel) {
    this.modify({ zoomLevel: level });
  }

  public updateZoomWithDelta(delta:number):void {
    const level = this.current.zoomLevel;
    if (level !== 'auto') {
      return this.applyZoomLevel(level, delta);
    }

    const applied = this.appliedZoomLevel;
    if (applied && applied !== 'auto') {
      // When we have a real zoom value, use delta on that one
      this.applyZoomLevel(applied, delta);
    } else {
      // Use the maximum zoom value
      const target = delta < 0 ? 'days' : 'years';
      this.setZoomLevel(target);
    }
  }

  public isAutoZoom():boolean {
    return this.current.zoomLevel === 'auto';
  }

  public enableAutozoom() {
    this.modify({ zoomLevel: 'auto' });
  }

  public get current():WorkPackageTimelineState {
    return this.lastUpdatedState.getValueOr(this.defaultState);
  }

  /**
   * Modify the state, updating with parts of properties
   * @param update
   */
  private modify(update:Partial<WorkPackageTimelineState>) {
    this.update({ ...this.current, ...update } as WorkPackageTimelineState);
  }

  /**
   * Apply a zoom level
   *
   * @param level Any zoom level except auto.
   * @param delta The delta (e.g., 1, -1) to apply.
   */
  private applyZoomLevel(level:Exclude<TimelineZoomLevel, 'auto'>, delta:number) {
    let idx = zoomLevelOrder.indexOf(level);
    idx += delta;

    if (idx >= 0 && idx < zoomLevelOrder.length) {
      this.setZoomLevel(zoomLevelOrder[idx]);
    }
  }

  private get defaultLabels():TimelineLabels {
    return {
      left: '',
      right: '',
      farRight: 'subject',
    };
  }

  private get defaultState():WorkPackageTimelineState {
    return {
      zoomLevel: 'auto',
      visible: false,
      labels: this.defaultLabels,
    };
  }
}