opf/openproject

View on GitHub
frontend/src/app/features/boards/board/board-list/board-list.component.ts

Summary

Maintainability
D
1 day
Test Coverage
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  Injector,
  Input,
  OnDestroy,
  OnInit,
  Output,
  ViewChild,
} from '@angular/core';
import {
  LoadingIndicatorService,
  withLoadingIndicator,
} from 'core-app/core/loading-indicator/loading-indicator.service';
import {
  WorkPackageInlineCreateService,
} from 'core-app/features/work-packages/components/wp-inline-create/wp-inline-create.service';
import { BoardInlineCreateService } from 'core-app/features/boards/board/board-list/board-inline-create.service';
import { AbstractWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-widget.component';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { Board } from 'core-app/features/boards/board/board';
import { AuthorisationService } from 'core-app/core/model-auth/model-auth.service';
import {
  Highlighting,
} from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions';
import {
  WorkPackageCardViewComponent,
} from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component';
import {
  WorkPackageStatesInitializationService,
} from 'core-app/features/work-packages/components/wp-list/wp-states-initialization.service';
import { BoardService } from 'core-app/features/boards/board/board.service';
import {
  HalResourceEditingService,
} from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import {
  BoardActionsRegistryService,
} from 'core-app/features/boards/board/board-actions/board-actions-registry.service';
import { BoardActionService } from 'core-app/features/boards/board/board-actions/board-action.service';
import { ComponentType } from '@angular/cdk/portal';
import { CausedUpdatesService } from 'core-app/features/boards/board/caused-updates/caused-updates.service';
import { BoardListMenuComponent } from 'core-app/features/boards/board/board-list/board-list-menu.component';
import { debugLog } from 'core-app/shared/helpers/debug_output';
import {
  WorkPackageCardDragAndDropService,
} from 'core-app/features/work-packages/components/wp-card-view/services/wp-card-drag-and-drop.service';
import { BoardFiltersService } from 'core-app/features/boards/board/board-filter/board-filters.service';
import { StateService, TransitionService } from '@uirouter/core';
import {
  WorkPackageViewFocusService,
} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service';
import {
  WorkPackageViewSelectionService,
} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service';
import {
  BoardListCrossSelectionService,
} from 'core-app/features/boards/board/board-list/board-list-cross-selection.service';
import { debounceTime, filter, map } from 'rxjs/operators';
import { ChangeItem } from 'core-app/shared/components/fields/changeset/changeset';
import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { ApiV3Filter } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import {
  KeepTabService,
} from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { QueryResource } from 'core-app/features/hal/resources/query-resource';
import { HalEvent, HalEventsService } from 'core-app/features/hal/services/hal-events.service';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { firstValueFrom } from 'rxjs';
import {
  WorkPackageIsolatedQuerySpaceDirective,
} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive';

export interface DisabledButtonPlaceholder {
  text:string;
  icon:string;
}

@Component({
  selector: 'board-list',
  templateUrl: './board-list.component.html',
  styleUrls: ['./board-list.component.sass'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  hostDirectives: [WorkPackageIsolatedQuerySpaceDirective],
  providers: [
    { provide: WorkPackageInlineCreateService, useClass: BoardInlineCreateService },
    BoardListMenuComponent,
    WorkPackageCardDragAndDropService,
  ],
})
export class BoardListComponent extends AbstractWidgetComponent implements OnInit, OnDestroy {
  /** Output fired upon query removal */
  @Output() onRemove = new EventEmitter<void>();

  /* Output fired after it is assured whether a user has the right to see the list */
  @Output() visibilityChange = new EventEmitter<boolean>();

  /** Access to the board resource */
  @Input() public board:Board;

  /** Access to the loading indicator element */
  @ViewChild('loadingIndicator', { static: true }) indicator:ElementRef;

  /** Access to the card view */
  @ViewChild(WorkPackageCardViewComponent) cardView:WorkPackageCardViewComponent;

  /** The query resource being loaded */
  public query:QueryResource;

  /** Query loading error, if present */
  public loadingError:string|undefined;

  /** The action attribute resource if any */
  public actionResource:HalResource|undefined;

  public actionResourceClass = '';

  public headerComponent:ComponentType<unknown>|undefined;

  /** Rename inFlight */
  public inFlight:boolean;

  /** Whether the add button should be shown */
  public showAddButton = false;

  private canAdd = firstValueFrom(this.wpInlineCreate.canAdd);

  public columnsQueryProps:any;

  public text = {
    addCard: this.I18n.t('js.boards.add_card'),
    updateSuccessful: this.I18n.t('js.notice_successful_update'),
    areYouSure: this.I18n.t('js.text_are_you_sure'),
    unnamed_list: this.I18n.t('js.boards.label_unnamed_list'),
    click_to_remove: this.I18n.t('js.boards.click_to_remove_list'),
  };

  /** Are we allowed to remove and drag & drop elements ? */
  public canDragInto = false;

  /** Initially focus the list */
  public initiallyFocused = false;

  /** Editing handler to be passed into card component */
  public workPackageAddedHandler = (workPackage:WorkPackageResource) => this.addWorkPackage(workPackage);

  /** Move check to be passed into card component */
  public canDragOutOf = false;

  public canDragOutOfHandler = (workPackage:WorkPackageResource) => this.canMove(workPackage);

  public buttonPlaceholder:DisabledButtonPlaceholder|undefined;

  constructor(readonly apiv3Service:ApiV3Service,
    readonly I18n:I18nService,
    readonly state:StateService,
    readonly cdRef:ChangeDetectorRef,
    readonly transitions:TransitionService,
    readonly boardFilters:BoardFiltersService,
    readonly toastService:ToastService,
    readonly querySpace:IsolatedQuerySpace,
    readonly halNotification:HalResourceNotificationService,
    readonly halEvents:HalEventsService,
    readonly wpStatesInitialization:WorkPackageStatesInitializationService,
    readonly wpViewFocusService:WorkPackageViewFocusService,
    readonly wpViewSelectionService:WorkPackageViewSelectionService,
    readonly boardListCrossSelectionService:BoardListCrossSelectionService,
    readonly authorisationService:AuthorisationService,
    readonly wpInlineCreate:WorkPackageInlineCreateService,
    readonly injector:Injector,
    readonly halEditing:HalResourceEditingService,
    readonly loadingIndicator:LoadingIndicatorService,
    readonly schemaCache:SchemaCacheService,
    readonly boardService:BoardService,
    readonly boardActionRegistry:BoardActionsRegistryService,
    readonly causedUpdates:CausedUpdatesService,
    readonly keepTab:KeepTabService,
    readonly $state:StateService) {
    super(I18n, injector);
  }

  ngOnInit():void {
    // Unset the isNew flag
    this.initiallyFocused = this.resource.isNewWidget;
    this.resource.isNewWidget = false;

    // Set initial selection if split view open
    if (this.state.includes(`${this.state.current.data.baseRoute}.details`)) {
      const wpId = this.state.params.workPackageId;
      this.wpViewSelectionService.initializeSelection([wpId]);
    }

    // If this query space changes its focused or selected
    // work packages, update the board cross selection
    this
      .wpViewSelectionService
      .updates$()
      .pipe(
        debounceTime(100),
        this.untilDestroyed(),
      )
      .subscribe((selectionState) => {
        const selected = Object.keys(_.pickBy(selectionState.selected, (option, _) => option === true));

        const focused = this.wpViewFocusService.focusedWorkPackage;

        this.boardListCrossSelectionService.updateSelection({
          withinQuery: this.queryId,
          focusedWorkPackage: focused,
          allSelected: selected,
        });
      });

    // Apply focus and selection when changed in cross service
    this.boardListCrossSelectionService
      .selectionsForQuery(this.queryId)
      .pipe(
        this.untilDestroyed(),
      )
      .subscribe((selection) => {
        this.wpViewSelectionService.initializeSelection(selection.allSelected);
      });

    // Update query on filter change
    this.boardFilters
      .filters
      .values$()
      .pipe(
        this.untilDestroyed(),
      )
      .subscribe(() => this.updateQuery(true));

    // Listen to changes to action attribute
    this.listenToActionAttributeChanges();

    this.querySpace.query
      .values$()
      .pipe(
        this.untilDestroyed(),
      )
      // eslint-disable-next-line @typescript-eslint/no-misused-promises
      .subscribe(async (query) => {
        this.query = query;
        this.canDragOutOf = !!this.query.updateOrderedWorkPackages;
        await this.loadActionAttribute(query);
        this.cdRef.detectChanges();
      });
  }

  ngOnDestroy() {
    super.ngOnDestroy();
  }

  public get errorMessage() {
    return this.I18n.t('js.boards.error_loading_the_list', { error_message: this.loadingError });
  }

  public canMove(workPackage:WorkPackageResource) {
    return this.canDragOutOf && (!this.actionService || this.actionService.canMove(workPackage));
  }

  public get canManage() {
    return this.boardService.canManage(this.board);
  }

  public get canRename() {
    return this.canManage
      && !!this.query.updateImmediately
      && this.board.isFree;
  }

  public addReferenceCard() {
    this.cardView.setReferenceMode(true);
  }

  public addNewCard() {
    this.cardView.addNewCard();
  }

  public deleteList(query?:QueryResource) {
    query = query || this.query;

    if (!window.confirm(this.text.areYouSure)) {
      return;
    }

    this
      .apiv3Service
      .queries
      .id(query)
      .delete()
      .subscribe(() => this.onRemove.emit());
  }

  public renameQuery(query:QueryResource, value:string) {
    this.inFlight = true;
    this.query.name = value;
    this
      .apiv3Service
      .queries
      .id(this.query)
      .patch({ name: value })
      .subscribe(
        () => {
          this.inFlight = false;
          this.toastService.addSuccess(this.text.updateSuccessful);
        },
        (_error) => this.inFlight = false,
      );
  }

  private boardListActionColorClass(value?:HalResource):string {
    const attribute = this.board.actionAttribute!;
    if (value && value.id) {
      return Highlighting.backgroundClass(attribute, value.id);
    }
    return '';
  }

  public get listName() {
    return this.query && this.query.name;
  }

  public showCardStatusButton() {
    return this.board.showStatusButton();
  }

  public refreshQueryUnlessCaused(query:QueryResource, visibly = true) {
    if (!this.causedUpdates.includes(query)) {
      debugLog(`Refreshing ${query.name} visibly due to external changes`);
      this.updateQuery(visibly);
    }
  }

  public updateQuery(visibly = true) {
    this.setQueryProps(this.boardFilters.current);
    this.loadQuery(visibly);
  }

  private async loadActionAttribute(query:QueryResource):Promise<void> {
    if (!this.board.isAction) {
      this.actionResource = undefined;
      this.headerComponent = undefined;
      this.canDragInto = !!query.updateOrderedWorkPackages;
      const canAdd = await this.canAdd;
      this.showAddButton = this.canDragInto && canAdd;
      return;
    }

    const actionService = this.actionService!;
    const id = actionService.getActionValueId(query);

    // Test if we loaded the resource already
    if (this.actionResource && id === this.actionResource.href) {
      return;
    }

    // Load the resource
    // eslint-disable-next-line consistent-return
    return actionService
      .getLoadedActionValue(query)
      .then(async (resource) => {
        this.actionResource = resource;
        this.headerComponent = actionService.headerComponent();
        this.buttonPlaceholder = actionService.disabledAddButtonPlaceholder(resource);
        this.actionResourceClass = this.boardListActionColorClass(resource);
        this.canDragInto = actionService.dragIntoAllowed(query, resource);

        const canWriteAttribute = await actionService.canAddToQuery(query);
        const canAdd = await this.canAdd;
        this.showAddButton = this.canDragInto && canAdd && canWriteAttribute;
        this.cdRef.detectChanges();
      });
  }

  /**
   * Return the linked action service
   */
  private get actionService():BoardActionService|undefined {
    if (this.board.actionAttribute) {
      return this.boardActionRegistry.get(this.board.actionAttribute);
    }

    return undefined;
  }

  /**
   * Handler to properly update the work package, when
   * adding to this query requires saving a changeset.
   * @param workPackage
   */
  private addWorkPackage(workPackage:WorkPackageResource) {
    const query = this.querySpace.query.value!;
    const changeset:WorkPackageChangeset = this.halEditing.changeFor(workPackage);

    // Assign to the action attribute if this is an action board
    this.actionService?.assignToWorkPackage(changeset, query);

    if (changeset.isEmpty()) {
      // Ensure work package and its schema is loaded
      return this.apiv3Service.work_packages.cache.updateWorkPackage(workPackage);
    }
    // Save changes to the work package, which reloads it as well
    return this.halEditing.save(changeset);
  }

  private get queryId():string {
    return (this.resource.options.queryId as number|string).toString();
  }

  private loadQuery(visibly = true) {
    let observable = this
      .apiv3Service
      .queries
      .find(this.columnsQueryProps, this.queryId);

    // Spread arguments on pipe does not work:
    // https://github.com/ReactiveX/rxjs/issues/3989
    if (visibly) {
      observable = observable.pipe(withLoadingIndicator(this.indicatorInstance, 50));
    }

    observable
      .subscribe(
        (query) => {
          this.wpStatesInitialization.updateQuerySpace(query, query.results);
        },
        (error) => {
          const userIsNotAllowedToSeeSubprojectError = 'urn:openproject-org:api:v3:errors:InvalidQuery';
          // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
          if (error.errorIdentifier === userIsNotAllowedToSeeSubprojectError) {
            this.visibilityChange.emit(false);
          }
          this.loadingError = this.halNotification.retrieveErrorMessage(error);
          this.cdRef.detectChanges();
        },
      );
  }

  private get indicatorInstance() {
    return this.loadingIndicator.indicator(jQuery(this.indicator.nativeElement));
  }

  private setQueryProps(filters:ApiV3Filter[]) {
    const existingFilters = (this.resource.options.filters || []) as ApiV3Filter[];

    const newFilters = existingFilters.concat(filters);
    const newColumnsQueryProps:any = {
      'columns[]': ['id', 'subject'],
      showHierarchies: false,
      pageSize: 500,
      filters: JSON.stringify(newFilters),
    };

    this.columnsQueryProps = newColumnsQueryProps;
  }

  private listenToActionAttributeChanges() {
    // If we don't have an action attribute
    // nothing to do
    if (!this.board.actionAttribute) {
      return;
    }

    // Listen to hal events to detect changes to an action attribute
    this.halEvents
      .events$
      .pipe(
        filter((event) => event.resourceType === 'WorkPackage'),
        // Only allow updates, otherwise this causes an error reloading the list
        // before the work package can be added to the query order
        filter((event) => event.eventType === 'updated'),
        map((event:HalEvent) => event.commit?.changes[this.actionService!.filterName]),
        filter((value) => !!value),
        filter((value:ChangeItem) => {
          // Compare the from and to values from the committed changes
          // with the current actionResource
          const current = this.actionResource?.href;
          const to = (value.to as HalResource|undefined)?.href;
          const from = (value.from as HalResource|undefined)?.href;

          return !!current && (current === to || current === from);
        }),
      )
      .subscribe(() => {
        this.updateQuery(true);
      });
  }

  openFullViewOnDoubleClick(event:{ workPackageId:string, double:boolean }) {
    if (event.double) {
      this.state.go(
        'work-packages.show',
        { workPackageId: event.workPackageId },
      );
    }
  }

  openStateLink(event:{ workPackageId:string; requestedState:string }) {
    const params = { workPackageId: event.workPackageId };

    if (event.requestedState === 'split') {
      this.keepTab.goCurrentDetailsState(params);
    } else {
      this.keepTab.goCurrentShowState(params);
    }
  }

  private schema(workPackage:WorkPackageResource) {
    return this.schemaCache.of(workPackage);
  }
}