opf/openproject

View on GitHub
frontend/src/app/shared/components/grids/grid/area.service.ts

Summary

Maintainability
F
6 days
Test Coverage
import { Injectable } from '@angular/core';
import { GridWidgetArea } from 'core-app/shared/components/grids/areas/grid-widget-area';
import { GridArea } from 'core-app/shared/components/grids/areas/grid-area';
import { GridGap } from 'core-app/shared/components/grids/areas/grid-gap';
import { GridResource } from 'core-app/features/hal/resources/grid-resource';
import { GridWidgetResource } from 'core-app/features/hal/resources/grid-widget-resource';
import { SchemaResource } from 'core-app/features/hal/resources/schema-resource';
import { WidgetChangeset } from 'core-app/shared/components/grids/widgets/widget-changeset';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { BehaviorSubject } from 'rxjs';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { ApiV3GridForm } from 'core-app/core/apiv3/endpoints/grids/apiv3-grid-form';

@Injectable()
export class GridAreaService {
  private resource:GridResource;

  public schema:SchemaResource;

  public numColumns = 0;

  public numRows = 0;

  public gridAreas:GridArea[];

  public gridGaps:GridArea[];

  public widgetAreas:GridWidgetArea[];

  public gridAreaIds:string[];

  public mousedOverArea:GridArea|null = null;

  public $mousedOverArea = new BehaviorSubject(this.mousedOverArea);

  public helpMode = false;

  constructor(private apiV3Service:ApiV3Service,
    private toastService:ToastService,
    private i18n:I18nService) { }

  public set gridResource(value:GridResource) {
    this.resource = value;
    this.fetchSchema();

    this.numRows = this.resource.rowCount;
    this.numColumns = this.resource.columnCount;

    this.buildAreas(true);
  }

  public get gridResource() {
    return this.resource;
  }

  public setMousedOverArea(area:GridArea|null) {
    this.mousedOverArea = area;

    this.$mousedOverArea.next(area);
  }

  public cleanupUnusedAreas() {
    // array containing Numbers from this.numRows to 1
    let unusedRows = _.range(this.numRows, 0, -1);

    this.widgetAreas.forEach((widget) => {
      unusedRows = unusedRows.filter((item) => item !== widget.startRow);
    });

    unusedRows.forEach((number) => {
      if (this.numRows > 1) {
        this.removeRow(number);
      }
    });

    let unusedColumns = _.range(this.numColumns, 0, -1);

    this.widgetAreas.forEach((widget) => {
      unusedColumns = unusedColumns.filter((item) => item !== widget.startColumn);
    });

    unusedColumns.forEach((number) => {
      if (this.numColumns > 1) {
        this.removeColumn(number);
      }
    });
  }

  public buildAreas(widgets = false) {
    this.gridAreas = this.buildGridAreas();
    this.gridGaps = this.buildGridGaps();
    this.gridAreaIds = this.buildGridAreaIds();
    if (widgets) {
      this.widgetAreas = this.buildGridWidgetAreas();
    }
  }

  public rebuildAndPersist() {
    this.persist();
    this.buildAreas(false);
  }

  public persist() {
    this.resource.rowCount = this.numRows = (this.widgetAreas.map((area) => area.endRow).sort((a, b) => a - b).pop() || 2) - 1;
    this.resource.columnCount = this.numColumns;

    this.writeAreaChangesToWidgets();

    this.saveGrid(this.resource, this.schema);
  }

  public saveWidgetChangeset(changeset:WidgetChangeset) {
    const payload:any = ApiV3GridForm.extractPayload(this.resource, this.schema);

    const payloadWidget = payload.widgets.find((w:any) => w.id === changeset.pristineResource.id);
    Object.assign(payloadWidget, changeset.changes);

    // Adding the id so that the url can be deduced
    payload.id = this.resource.id;

    this.saveGrid(payload);
  }

  public isGap(area:GridArea) {
    return area instanceof GridGap;
  }

  public get isSingleCell() {
    return this.numRows === 1 && this.numColumns === 1 && this.widgetResources.length === 0;
  }

  public get inHelpMode() {
    return this.helpMode || this.isSingleCell;
  }

  public toggleHelpMode() {
    this.helpMode = !this.helpMode;
  }

  // This is a hacky way to have the placeholder in the viewport.
  // It is a noop for firefox and edge as both do not support scrollIntoViewIfNeeded.
  // But as scrollIntoView will always readjust the viewport, the result would be an unbearable flicker
  // which causes e.g. dragging to be impossible.
  public scrollPlaceholderIntoView() {
    const placeholder = jQuery('.grid--area.-placeholder');

    if ((placeholder[0] as any).scrollIntoViewIfNeeded) {
      setTimeout(() => (placeholder[0] as any).scrollIntoViewIfNeeded());
    }
  }

  private saveGrid(resource:GridWidgetResource|any, schema?:SchemaResource) {
    this
      .apiV3Service
      .grids
      .id(resource)
      .patch(resource, schema)
      .subscribe((updatedGrid) => {
        this.assignAreasWidget(updatedGrid);
        this.toastService.addSuccess(this.i18n.t('js.notice_successful_update'));
      });
  }

  private assignAreasWidget(newGrid:GridResource) {
    this.resource.widgets = newGrid.widgets;

    const takenIds = this.widgetAreas.map((a) => a.widget.id);
    this.widgetAreas.forEach((area) => {
      let newWidget:GridWidgetResource;

      // identify the right resource for the area. Typically that means fetching them by id.
      // But new areas have unpersisted resources at first. Unpersisted resources have no id.
      // In those cases, we find the one resource which is not claimed by any other area.
      if (area.widget.id) {
        newWidget = newGrid.widgets.find((widget) => widget.id === area.widget.id)!;
      } else {
        newWidget = newGrid.widgets.find((widget) => !takenIds.includes(widget.id) && widget.identifier === area.widget.identifier && widget.startRow === area.widget.startRow && widget.startColumn === area.widget.startColumn)!;
      }

      area.widget = newWidget!;
    });
  }

  private buildGridAreas() {
    const cells:GridArea[] = [];

    // the one extra row is added in case the user wants to drag a widget to the very bottom
    for (let row = 1; row <= this.numRows + 1; row++) {
      cells.push(...this.buildGridAreasRow(row));
    }

    return cells;
  }

  private buildGridGaps() {
    const cells:GridArea[] = [];

    // special case where we want no gaps
    if (this.isSingleCell) {
      return cells;
    }

    for (let row = 1; row <= this.numRows + 1; row++) {
      cells.push(...this.buildGridGapRow(row));
    }

    return cells;
  }

  private buildGridAreasRow(row:number) {
    const cells:GridArea[] = [];

    for (let column = 1; column <= this.numColumns; column++) {
      const cell = new GridArea(row,
        row + 1,
        column,
        column + 1);

      cells.push(cell);
    }

    return cells;
  }

  private buildGridGapRow(row:number) {
    const cells:GridGap[] = [];

    for (let column = 1; column <= this.numColumns; column++) {
      cells.push(new GridGap(row,
        row + 1,
        column,
        column + 1,
        'row'));
    }

    if (row <= this.numRows) {
      for (let column = 1; column <= this.numColumns + 1; column++) {
        cells.push(new GridGap(row,
          row + 1,
          column,
          column + 1,
          'column'));
      }
    }

    return cells;
  }

  private buildGridWidgetAreas() {
    return this.widgetResources.map((widget) => new GridWidgetArea(widget));
  }

  // persist all changes to the areas caused by dragging/resizing
  // to the widget
  public writeAreaChangesToWidgets() {
    this.widgetAreas.forEach((area) => {
      area.writeAreaChangeToWidget();
    });
  }

  public addColumn(column:number, excludeRow:number) {
    this.numColumns++;

    const movedWidgets:GridWidgetArea[] = [];

    for (let row = 1; row <= this.numRows; row++) {
      if (row === excludeRow) {
        continue;
      }

      const widget = this
        .rowWidgets(row)
        .sort((a, b) => a.startColumn - b.startColumn)
        .find((widget) => !(widget.startRow < excludeRow && widget.endRow > excludeRow)
                     && (widget.startColumn === column + 1
                      || widget.endColumn === column + 1
                      || widget.startColumn <= column && widget.endColumn > column));

      if (widget) {
        movedWidgets.push(widget);
        widget.endColumn++;
      }
    }

    this.moveSubsequentRowWidgets(this.widgetAreas.filter((widget) => !movedWidgets.includes(widget)),
      column);
  }

  public addRow(row:number, excludeColumn:number) {
    this.numRows++;

    const movedWidgets:GridWidgetArea[] = [];

    for (let column = 1; column <= this.numColumns; column++) {
      if (column === excludeColumn) {
        continue;
      }

      const widget = this
        .columnWidgets(column)
        .sort((a, b) => a.startRow - b.startRow)
        .find((widget) => !(widget.startColumn < excludeColumn && widget.endColumn > excludeColumn)
                     && (widget.startRow === row + 1
                       || widget.endRow === row + 1
                       || widget.startRow <= row && widget.endRow > row));

      if (widget) {
        movedWidgets.push(widget);
        widget.endRow++;
      }
    }

    this.moveSubsequentColumnWidgets(this.widgetAreas.filter((widget) => !movedWidgets.includes(widget)),
      row);
  }

  public removeColumn(column:number) {
    this.numColumns--;

    // shrink widgets that span more than the removed column
    this.widgetAreas.forEach((widget) => {
      if (widget.startColumn <= column && widget.endColumn >= column + 1) {
        // shrink widgets that span more than the removed column
        widget.endColumn--;
      }
    });

    // move all widgets that are after the removed column
    // so that they appear to keep their place.
    this.widgetAreas.filter((widget) => widget.startColumn > column).forEach((widget) => {
      widget.startColumn--;
      widget.endColumn--;
    });
  }

  public removeRow(row:number) {
    this.numRows--;

    // shrink widgets that span more than the removed row
    this.widgetAreas.forEach((widget) => {
      if (widget.startRow <= row && widget.endRow >= row + 1) {
        // shrink widgets that span more than the removed row
        widget.endRow--;
      }
    });

    // move all widgets that are after the removed row
    // so that they appear to keep their place.
    this.widgetAreas.filter((widget) => widget.startRow > row).forEach((widget) => {
      widget.startRow--;
      widget.endRow--;
    });
  }

  public resetAreas(ignoredArea:GridWidgetArea|null = null) {
    this.widgetAreas.filter((area) => !ignoredArea || area.guid !== ignoredArea.guid).forEach((area) => area.reset());

    this.numRows = this.resource.rowCount;
    this.numColumns = this.resource.columnCount;
  }

  public get isEditable() {
    return this.gridResource.updateImmediately !== undefined;
  }

  private buildGridAreaIds() {
    return this
      .gridAreas
      .filter((area) => !this.isGap(area))
      .map((area) => area.guid);
  }

  private fetchSchema() {
    this
      .apiV3Service
      .grids
      .id(this.resource)
      .form
      .post({})
      .subscribe((form) => this.schema = form.schema);
  }

  public removeWidget(removedWidget:GridWidgetResource) {
    let index = this.resource.widgets.findIndex((widget) => widget.id === removedWidget.id);
    this.resource.widgets.splice(index, 1);

    index = this.widgetAreas.findIndex((area) => area.widget.id === removedWidget.id);
    this.widgetAreas.splice(index, 1);
    this.cleanupUnusedAreas();

    this.rebuildAndPersist();
  }

  public get widgetResources() {
    return (this.resource && this.resource.widgets) || [];
  }

  private rowWidgets(row:number) {
    return this.widgetAreas.filter((widget) => widget.startRow === row);
  }

  private moveSubsequentRowWidgets(rowWidgets:GridWidgetArea[], column:number) {
    rowWidgets.forEach((subsequentWidget) => {
      if (subsequentWidget.startColumn > column) {
        subsequentWidget.startColumn++;
        subsequentWidget.endColumn++;
      }
    });
  }

  private columnWidgets(column:number) {
    return this.widgetAreas.filter((widget) => widget.startColumn === column);
  }

  private moveSubsequentColumnWidgets(columnWidgets:GridWidgetArea[], row:number) {
    columnWidgets.forEach((subsequentWidget) => {
      if (subsequentWidget.startRow > row) {
        subsequentWidget.startRow++;
        subsequentWidget.endRow++;
      }
    });
  }
}