opf/openproject

View on GitHub
frontend/src/app/shared/components/op-view-select/op-view-select.component.ts

Summary

Maintainability
B
6 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 {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  Input,
  OnInit,
} from '@angular/core';
import { map } from 'rxjs/operators';
import {
  BehaviorSubject,
  combineLatest,
} from 'rxjs';
import { States } from 'core-app/core/states/states.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs';
import { MainMenuNavigationService } from 'core-app/core/main-menu/main-menu-navigation.service';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { IOpSidemenuItem } from 'core-app/shared/components/sidemenu/sidemenu.component';
import { StaticQueriesService } from 'core-app/shared/components/op-view-select/op-static-queries.service';
import { ViewsResourceService } from 'core-app/core/state/views/views.service';
import { IView } from 'core-app/core/state/views/view.model';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { ApiV3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface';
import { MAGIC_PAGE_NUMBER } from 'core-app/core/apiv3/helpers/get-paginated-results';

export type ViewType = 'WorkPackagesTable'|'Bim'|'TeamPlanner'|'WorkPackagesCalendar'|'Gantt';

export const opViewSelectSelector = 'op-view-select';

@Component({
  selector: opViewSelectSelector,
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './op-view-select.template.html',
})
export class ViewSelectComponent extends UntilDestroyedMixin implements OnInit {
  public text = {
    search: this.I18n.t('js.global_search.search'),
    label: this.I18n.t('js.toolbar.search_query_label'),
    scope_default: this.I18n.t('js.label_default_queries'),
    scope_starred: this.I18n.t('js.label_starred_queries'),
    scope_global: this.I18n.t('js.label_global_queries'),
    scope_private: this.I18n.t('js.label_custom_queries'),
    no_results: this.I18n.t('js.autocompleter.notFoundText'),
  };

  @Input() menuItems:string[] = [];

  @Input() projectId:string|undefined;

  @Input() baseRoute:string;

  @Input() viewType:ViewType;

  public items:IOpSidemenuItem[] = [];

  private apiViewType:string;

  private viewCategories$ = new BehaviorSubject<IOpSidemenuItem[]>([]);

  private search$ = new BehaviorSubject<string>('');

  private initialized = false;

  constructor(
    readonly elementRef:ElementRef,
    readonly apiV3Service:ApiV3Service,
    readonly I18n:I18nService,
    readonly states:States,
    readonly opStaticQueries:StaticQueriesService,
    readonly mainMenuService:MainMenuNavigationService,
    readonly cdRef:ChangeDetectorRef,
    readonly viewsService:ViewsResourceService,
  ) {
    super();

    populateInputsFromDataset(this);
  }

  public set search(input:string) {
    if (this.search$.value !== input) {
      this.search$.next(input);
    }
  }

  ngOnInit():void {
    this.apiViewType = `Views::${this.viewType}`;

    // When activating the work packages submenu,
    // either initially or through click on the toggle, load the results
    this.mainMenuService
      .onActivate(...this.menuItems)
      .subscribe(() => this.initializeAutocomplete());

    combineLatest([
      this.search$,
      this.viewCategories$,
    ]).pipe(
      map(([searchText, categories]) => {
        // We go the way of assigning the variable instead of using the observable directly with the async pipe.
        // For whatever the reason Angular's change detection does not catch the changes made here.
        // Thus, the sidemenu items were only updated with the next global change event (e.g. notifications push or some user interaction).
        this.items = categories
          .map((category) => {
            if (ViewSelectComponent.matchesText(category.title, searchText)) {
              return category;
            }
            const filteredChildren = category.children
              ?.filter((query) => ViewSelectComponent.matchesText(query.title, searchText));

            return { title: category.title, children: filteredChildren, collapsible: true };
          })
          .filter((category) => category.children && category.children.length > 0);

        this.cdRef.detectChanges();

        return this.items;
      }),
    ).subscribe();
  }

  private initializeAutocomplete():void {
    if (this.initialized) {
      return;
    }

    // Set focus on collapsible menu's back button.
    // This improves accessibility for blind users to tell them their current location.
    const buttonArrowLeft = document.getElementById('main-menu-work-packages-wrapper')?.parentElement
      ?.getElementsByClassName('main-menu--arrow-left-to-project')[0] as HTMLElement;
    if (buttonArrowLeft) {
      buttonArrowLeft.focus();
    }

    this.updateMenuOnChanges();
    this.initializeViews();
    this.initialized = true;
  }

  private static matchesText(text:string, searchText:string):boolean {
    return text.toLowerCase().includes(searchText.toLowerCase());
  }

  private initializeViews():void {
    const categories:{ [category:string]:IOpSidemenuItem[] } = {
      starred: [],
      default: [],
      public: [],
      private: [],
      createNew: [],
    };

    const params:ApiV3ListParameters = {
      filters: [
        ['type', '=', [this.apiViewType]],
      ],
      pageSize: MAGIC_PAGE_NUMBER,
    };

    if (this.projectId) {
      params.filters?.push(
        ['project', '=', [this.projectId]],
      );
    } else {
      params.filters?.push(
        ['project', '!*', []],
      );
    }

    this.viewsService.fetchResults(params)
      .pipe(this.untilDestroyed())
      .subscribe((views) => {
        views
          .sort((a, b) => a._links.query.title.localeCompare(b._links.query.title))
          .forEach((view) => {
            let cat = 'private';
            if (view.public) {
              cat = 'public';
            }
            if (view.starred) {
              cat = 'starred';
            }

            categories[cat].push(this.toOpSideMenuItem(view));
          });

        const staticQueries = this.opStaticQueries.getStaticQueriesForView(this.viewType);
        const viewCategories = [
          { title: this.text.scope_starred, children: categories.starred, collapsible: true },
          { title: this.text.scope_default, children: staticQueries, collapsible: true },
          { title: this.text.scope_global, children: categories.public, collapsible: true },
          { title: this.text.scope_private, children: categories.private, collapsible: true },
        ];

        this.viewCategories$.next(viewCategories);
      });
  }

  private toOpSideMenuItem(view:IView):IOpSidemenuItem {
    const { query } = view._links;
    return {
      title: query.title,
      uiSref: this.baseRoute,
      uiParams: { query_id: idFromLink(query.href), query_props: undefined },
      uiOptions: { reload: true },
    };
  }

  // Listens on all changes of queries (via an observable in the service), e.g. delete, create, rename, toggle starred
  private updateMenuOnChanges() {
    this.states.changes.queries
      .pipe(this.untilDestroyed())
      .subscribe(() => this.initializeViews());
  }
}