diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 22d67a30f1..9f31426e61 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -308,6 +308,8 @@ module ApplicationHelper css << "ee-banners-#{EnterpriseToken.show_banners? ? 'visible' : 'hidden'}" + css << "env-#{Rails.env}" + # Add browser specific classes to aid css fixes css += browser_specific_classes diff --git a/docker-compose.yml b/docker-compose.yml index 9204582884..5200b4dd02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,6 +167,7 @@ services: - "opdata:/var/openproject/assets" - "bundle:/usr/local/bundle" - "tmp-test:/home/dev/openproject/tmp" + - "./tmp/capybara:/home/dev/openproject/tmp/capybara" # https://vitobotta.com/2019/09/04/rails-parallel-system-tests-selenium-docker/ selenium-hub: diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 3404c42710..2b04057084 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -71,10 +71,10 @@ export class PathHelperService { } public bimDetailsPath(projectIdentifier:string, workPackageId:string, viewpoint:number|string|null = null) { - let path = `${this.projectPath(projectIdentifier)}/bcf/split/details/${workPackageId}`; + let path = `${this.projectPath(projectIdentifier)}/bcf/details/${workPackageId}`; if (viewpoint !== null) { - path += `?query_props=%7B"t"%3A"id%3Adesc"%7D&viewpoint=${viewpoint}`; + path += `?query_props=%7B"t"%3A"id%3Adesc"%2C"dr"%3A"splitCards"%7D&viewpoint=${viewpoint}`; } return path; diff --git a/frontend/src/app/core/state/collection-store.ts b/frontend/src/app/core/state/collection-store.ts index 5f3f351a4b..cbc08aa4bc 100644 --- a/frontend/src/app/core/state/collection-store.ts +++ b/frontend/src/app/core/state/collection-store.ts @@ -93,7 +93,8 @@ export function selectEntitiesFromIDCollection(service * @param state * @param params */ -export function selectCollectionAsEntities$(service:CollectionService, state:CollectionState, params:Apiv3ListParameters):T[] { +export function selectCollectionAsEntities$(service:CollectionService, params:Apiv3ListParameters):T[] { + const state = service.query.getValue(); const key = collectionKey(params); const collection = state.collections[key]; diff --git a/frontend/src/app/core/state/openproject-state.module.ts b/frontend/src/app/core/state/openproject-state.module.ts index b508ea908c..391137755b 100644 --- a/frontend/src/app/core/state/openproject-state.module.ts +++ b/frontend/src/app/core/state/openproject-state.module.ts @@ -31,11 +31,13 @@ import { } from '@angular/core'; import { InAppNotificationsResourceService } from './in-app-notifications/in-app-notifications.service'; import { ProjectsResourceService } from './projects/projects.service'; +import { PrincipalsResourceService } from './principals/principals.service'; @NgModule({ providers: [ InAppNotificationsResourceService, ProjectsResourceService, + PrincipalsResourceService, ], }) export class OpenProjectStateModule { diff --git a/frontend/src/app/core/state/principals/group.model.ts b/frontend/src/app/core/state/principals/group.model.ts new file mode 100644 index 0000000000..9fd2628ff2 --- /dev/null +++ b/frontend/src/app/core/state/principals/group.model.ts @@ -0,0 +1,13 @@ +import { ID } from '@datorama/akita'; +import { HalResourceLinks } from 'core-app/core/state/hal-resource'; + +export interface GroupHalResourceLinks extends HalResourceLinks { } + +export interface Group { + id:ID; + name:string; + createdAt:string; + updatedAt:string; + + _links:GroupHalResourceLinks; +} diff --git a/frontend/src/app/core/state/principals/placeholder-user.model.ts b/frontend/src/app/core/state/principals/placeholder-user.model.ts new file mode 100644 index 0000000000..3dc91212b6 --- /dev/null +++ b/frontend/src/app/core/state/principals/placeholder-user.model.ts @@ -0,0 +1,17 @@ +import { ID } from '@datorama/akita'; +import { HalResourceLink, HalResourceLinks } from 'core-app/core/state/hal-resource'; + +export interface PlaceholderUserHalResourceLinks extends HalResourceLinks { + updateImmediately:HalResourceLink; + delete:HalResourceLink; + showUser:HalResourceLink; +} + +export interface PlaceholderUser { + id:ID; + name:string; + createdAt:string; + updatedAt:string; + + _links:PlaceholderUserHalResourceLinks; +} diff --git a/frontend/src/app/core/state/principals/principal.model.ts b/frontend/src/app/core/state/principals/principal.model.ts new file mode 100644 index 0000000000..41baf6dcd6 --- /dev/null +++ b/frontend/src/app/core/state/principals/principal.model.ts @@ -0,0 +1,5 @@ +import { User } from './user.model'; +import { Group } from './group.model'; +import { PlaceholderUser } from './placeholder-user.model'; + +export type Principal = User|Group|PlaceholderUser; diff --git a/frontend/src/app/core/state/principals/principals.query.ts b/frontend/src/app/core/state/principals/principals.query.ts new file mode 100644 index 0000000000..ec5d89563e --- /dev/null +++ b/frontend/src/app/core/state/principals/principals.query.ts @@ -0,0 +1,10 @@ +import { QueryEntity } from '@datorama/akita'; +import { Observable } from 'rxjs'; +import { Principal } from './principal.model'; +import { PrincipalsState } from './principals.store'; + +export class PrincipalsQuery extends QueryEntity { + public byIds(ids:string[]):Observable { + return this.selectMany(ids); + } +} diff --git a/frontend/src/app/core/state/principals/principals.service.ts b/frontend/src/app/core/state/principals/principals.service.ts new file mode 100644 index 0000000000..59603bdb7e --- /dev/null +++ b/frontend/src/app/core/state/principals/principals.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@angular/core'; +import { + catchError, + tap, +} from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { + applyTransaction, + ID, +} from '@datorama/akita'; +import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { ToastService } from 'core-app/shared/components/toaster/toast.service'; +import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; +import { HttpClient } from '@angular/common/http'; +import { PrincipalsQuery } from 'core-app/core/state/principals/principals.query'; +import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; +import { collectionKey } from 'core-app/core/state/collection-store'; +import { + EffectHandler, +} from 'core-app/core/state/effects/effect-handler.decorator'; +import { ActionsService } from 'core-app/core/state/actions/actions.service'; +import { PrincipalsStore } from './principals.store'; +import { Principal } from './principal.model'; + +@EffectHandler +@Injectable() +export class PrincipalsResourceService { + protected store = new PrincipalsStore(); + + readonly query = new PrincipalsQuery(this.store); + + private get principalsPath():string { + return this + .apiV3Service + .principals + .path; + } + + constructor( + readonly actions$:ActionsService, + private http:HttpClient, + private apiV3Service:APIV3Service, + private toastService:ToastService, + ) { + } + + fetchPrincipals(params:Apiv3ListParameters):Observable> { + const collectionURL = collectionKey(params); + + return this + .http + .get>(this.principalsPath + collectionURL) + .pipe( + tap((events) => { + applyTransaction(() => { + this.store.add(events._embedded.elements); + this.store.update(({ collections }) => ( + { + collections: { + ...collections, + [collectionURL]: { + ...collections[collectionURL], + ids: events._embedded.elements.map((el) => el.id), + }, + }, + } + )); + }); + }), + catchError((error) => { + this.toastService.addError(error); + throw error; + }), + ); + } + + update(id:ID, principal:Partial):void { + this.store.update(id, principal); + } + + modifyCollection(params:Apiv3ListParameters, callback:(collection:ID[]) => ID[]):void { + const key = collectionKey(params); + this.store.update(({ collections }) => ( + { + collections: { + ...collections, + [key]: { + ...collections[key], + ids: [...callback(collections[key]?.ids || [])], + }, + }, + } + )); + } + + removeFromCollection(params:Apiv3ListParameters, ids:ID[]):void { + const key = collectionKey(params); + this.store.update(({ collections }) => ( + { + collections: { + ...collections, + [key]: { + ...collections[key], + ids: (collections[key]?.ids || []).filter((id) => !ids.includes(id)), + }, + }, + } + )); + } +} diff --git a/frontend/src/app/core/state/principals/principals.store.ts b/frontend/src/app/core/state/principals/principals.store.ts new file mode 100644 index 0000000000..19b8fae8b2 --- /dev/null +++ b/frontend/src/app/core/state/principals/principals.store.ts @@ -0,0 +1,13 @@ +import { EntityStore, StoreConfig } from '@datorama/akita'; +import { Principal } from './principal.model'; +import { CollectionState, createInitialCollectionState } from 'core-app/core/state/collection-store'; + +export interface PrincipalsState extends CollectionState { +} + +@StoreConfig({ name: 'principals' }) +export class PrincipalsStore extends EntityStore { + constructor() { + super(createInitialCollectionState()); + } +} diff --git a/frontend/src/app/core/state/principals/user.model.ts b/frontend/src/app/core/state/principals/user.model.ts new file mode 100644 index 0000000000..60f6282d61 --- /dev/null +++ b/frontend/src/app/core/state/principals/user.model.ts @@ -0,0 +1,31 @@ +import { ID } from '@datorama/akita'; +import { HalResourceLink, HalResourceLinks } from 'core-app/core/state/hal-resource'; + +export interface UserHalResourceLinks extends HalResourceLinks { + lock:HalResourceLink; + unlock:HalResourceLink; + delete:HalResourceLink; + showUser:HalResourceLink; +} + +export interface User { + id:ID; + name:string; + createdAt:string; + updatedAt:string; + + // Properties + login:string; + + firstName:string; + + lastName:string; + + email:string; + + avatar:string; + + status:string; + + _links:UserHalResourceLinks; +} diff --git a/frontend/src/app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service.ts b/frontend/src/app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service.ts index 7d1974d11e..8bf75d3e58 100644 --- a/frontend/src/app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service.ts +++ b/frontend/src/app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service.ts @@ -10,20 +10,11 @@ export abstract class ViewerBridgeService { @InjectField() state:StateService; /** - * Determine whether a viewer should be shown, - * wether 'bim.partitioned.split' state/route should be activated + * Determine whether a viewer should be shown */ abstract shouldShowViewer:boolean; - /** - * Check if we are on a router state where there is a place - * where the viewer could be shown - */ - get routeWithViewer():boolean { - return this.state.includes('bim.partitioned.split'); - } - - constructor(readonly injector:Injector) {} + protected constructor(readonly injector:Injector) {} /** * Get a viewpoint from the viewer @@ -32,7 +23,6 @@ export abstract class ViewerBridgeService { /** * Show the given viewpoint JSON in the viewer - * @param viewpoint */ abstract showViewpoint(workPackage:WorkPackageResource, index:number):void; diff --git a/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts b/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts index 380cff069d..c2e56f704c 100644 --- a/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts +++ b/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts @@ -1,3 +1,31 @@ +// -- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 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 { AfterViewInit, ChangeDetectionStrategy, @@ -6,6 +34,7 @@ import { Input, OnDestroy, OnInit, + Optional, ViewChild, } from '@angular/core'; import { StateService } from '@uirouter/core'; @@ -22,6 +51,8 @@ import { BcfAuthorizationService } from 'core-app/features/bim/bcf/api/bcf-autho import { ViewpointsService } from 'core-app/features/bim/bcf/helper/viewpoints.service'; import { BcfViewpointItem } from 'core-app/features/bim/bcf/api/viewpoints/bcf-viewpoint-item.interface'; import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { BcfViewService } from 'core-app/features/bim/ifc_models/pages/viewer/bcf-view.service'; +import { filter, take } from 'rxjs/operators'; @Component({ templateUrl: './bcf-wp-attribute-group.component.html', @@ -120,6 +151,7 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements readonly apiV3Service:APIV3Service, readonly wpCreate:WorkPackageCreateService, readonly toastService:ToastService, + @Optional() readonly bcfViewer:BcfViewService, readonly cdRef:ChangeDetectorRef, readonly I18n:I18nService, readonly viewpointsService:ViewpointsService) { @@ -131,7 +163,7 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements this.observeChanges(); } - ngOnInit() { + ngOnInit():void { this.viewerBridge.viewerVisible$.subscribe((visible:boolean) => { if (visible) { this.viewerVisible = true; @@ -171,24 +203,47 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements this.cdRef.detectChanges(); } - refreshViewpoints(viewpoints:HalLink[]) { + refreshViewpoints(viewpoints:HalLink[]):void { this.viewpoints = viewpoints.map((el:HalLink) => ({ href: el.href, snapshotURL: `${el.href}/snapshot` })); this.setViewpointsOnGallery(this.viewpoints); } - protected showViewpoint(workPackage:WorkPackageResource, index:number) { - this.viewerBridge.showViewpoint(workPackage, index); + protected showViewpoint(workPackage:WorkPackageResource, index:number):void { + if (this.bcfViewer) { + // FIXME: This component shouldn't know about the state of the BCF module. bcfViewer is null, when outside of + // BCF module. Inside BCF module, we try to avoid hard transition, with sending an update to the bcf view + // state before showing a viewpoint. + switch (this.bcfViewer.currentViewerState()) { + case 'table': + this.bcfViewer.update('splitTable'); + break; + case 'cards': + this.bcfViewer.update('splitCards'); + break; + default: + } + + // wait until viewer is visible after view state update before showing viewpoint + this.viewerBridge.viewerVisible$ + .pipe( + filter((visible) => visible), + take(1), + ) + .subscribe(() => this.viewerBridge.showViewpoint(workPackage, index)); + } else { + this.viewerBridge.showViewpoint(workPackage, index); + } } - protected deleteViewpoint(workPackage:WorkPackageResource, index:number) { + protected deleteViewpoint(workPackage:WorkPackageResource, index:number):void { if (!window.confirm(this.text.text_are_you_sure)) { return; } this.viewpointsService .deleteViewPoint$(workPackage, index) - .subscribe((data) => { + .subscribe(() => { this.toastService.addSuccess(this.text.notice_successful_delete); this.gallery.preview.close(); }); @@ -197,7 +252,7 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements public saveViewpoint(workPackage:WorkPackageResource) { this.viewpointsService .saveViewpoint$(workPackage) - .subscribe((viewpoint) => { + .subscribe(() => { this.toastService.addSuccess(this.text.notice_successful_create); this.showIndex = this.viewpoints.length; }); @@ -209,7 +264,7 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements this.showViewpoint(workPackage, index); this.showIndex = index; this.selectViewpointInGallery(); - this.state.go('.', { ...this.state.params, viewpoint: undefined }, { reload: false }); + void this.state.go('.', { ...this.state.params, viewpoint: undefined }, { reload: false }); } } diff --git a/frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.html b/frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.html deleted file mode 100644 index 394ef5c4ec..0000000000 --- a/frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.html +++ /dev/null @@ -1,37 +0,0 @@ -
- - -
- - - - - -
- - -
- - -
- - - \ No newline at end of file diff --git a/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.html b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.html new file mode 100644 index 0000000000..31daa0e8a7 --- /dev/null +++ b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.html @@ -0,0 +1,38 @@ +
+ + +
+ + + + + +
+ + +
+ + +
+ + + \ No newline at end of file diff --git a/frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.sass b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.sass similarity index 94% rename from frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.sass rename to frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.sass index cfe0b13010..6fe87f9e13 100644 --- a/frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.sass +++ b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.sass @@ -1,6 +1,6 @@ @import "src/assets/sass/helpers" -.op-bcf-list-container +.op-bcf-list &--result-overlay @include overlay-background background-color: #FFFFFF diff --git a/frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.ts b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts similarity index 59% rename from frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.ts rename to frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts index f28c949bdc..51a406a458 100644 --- a/frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.ts +++ b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts @@ -1,39 +1,66 @@ +// -- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 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, Component, NgZone, OnInit, + ChangeDetectionStrategy, Component, Input, NgZone, OnInit, } from '@angular/core'; import { WorkPackageListViewComponent } from 'core-app/features/work-packages/routing/wp-list-view/wp-list-view.component'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; import { DragAndDropService } from 'core-app/shared/helpers/drag-and-drop/drag-and-drop.service'; import { CausedUpdatesService } from 'core-app/features/boards/board/caused-updates/caused-updates.service'; -import { - bimSplitViewCardsIdentifier, - bimSplitViewListIdentifier, - BimViewService, -} from 'core-app/features/bim/ifc_models/pages/viewer/bim-view.service'; +import { BcfViewService } from 'core-app/features/bim/ifc_models/pages/viewer/bcf-view.service'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { IfcModelsDataService } from 'core-app/features/bim/ifc_models/pages/viewer/ifc-models-data.service'; import { WorkPackageViewColumnsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-columns.service'; import { UIRouterGlobals } from '@uirouter/core'; -import { distinctUntilChanged, pluck } from 'rxjs/operators'; import { States } from 'core-app/core/states/states.service'; import { BcfApiService } from 'core-app/features/bim/bcf/api/bcf-api.service'; import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; import { ViewerBridgeService } from 'core-app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service'; +import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { QueryResource } from 'core-app/features/hal/resources/query-resource'; @Component({ - templateUrl: './bcf-list-container.component.html', - styleUrls: ['./bcf-list-container.component.sass'], + templateUrl: './bcf-list.component.html', + styleUrls: ['./bcf-list.component.sass'], providers: [ { provide: HalResourceNotificationService, useClass: WorkPackageNotificationService }, DragAndDropService, CausedUpdatesService, ], changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'op-bcf-list', }) -export class BcfListContainerComponent extends WorkPackageListViewComponent implements OnInit { - @InjectField() bimView:BimViewService; +export class BcfListComponent extends WorkPackageListViewComponent implements UntilDestroyedMixin, OnInit { + @Input() showResizer = false; + + @InjectField() bcfView:BcfViewService; @InjectField() ifcModelsService:IfcModelsDataService; @@ -55,54 +82,33 @@ export class BcfListContainerComponent extends WorkPackageListViewComponent impl public showViewPointInFlight:boolean; - ngOnInit() { + ngOnInit():void { super.ngOnInit(); - - // Ensure we add a bcf thumbnail column - // until we can load the initial query - this.wpTableColumns - .onReady() - .then(() => this.wpTableColumns.addColumn('bcfThumbnail', 2)); - - this.uIRouterGlobals - .params$! - .pipe( - this.untilDestroyed(), - pluck('cards'), - distinctUntilChanged(), - ) - .subscribe((cards:boolean) => { - if (cards == null || cards || this.deviceService.isMobile) { - this.showTableView = false; - } else { - this.showTableView = true; - } - - this.cdRef.detectChanges(); - }); } - protected updateViewRepresentation(query:QueryResource) { - // Overwrite the parent method because we are setting the view - // above through the cards parameter (showTableView) + protected updateViewRepresentation(query:QueryResource):void { + const viewerState = this.bcfView.valueFromQuery(query); + this.showTableView = !this.deviceService.isMobile + && (viewerState === 'table' || viewerState === 'splitTable'); } public showResizerInCardView():boolean { if (this.noResults && this.ifcModelsService.models.length === 0) { return false; } - return this.bimView.currentViewerState() === bimSplitViewCardsIdentifier - || this.bimView.currentViewerState() === bimSplitViewListIdentifier; + + return this.bcfView.currentViewerState() === 'splitCards' + || this.bcfView.currentViewerState() === 'splitTable'; } - handleWorkPackageClicked(event:{ workPackageId:string; double:boolean }) { + handleWorkPackageClicked(event:{ workPackageId:string; double:boolean }):void { const { workPackageId, double } = event; if (!this.showViewPointInFlight) { this.showViewPointInFlight = true; this.zone.runOutsideAngular(() => { - setTimeout(() => this.showViewPointInFlight = false, 500); + setTimeout(() => { this.showViewPointInFlight = false; }, 500); }); const wp = this.states.workPackages.get(workPackageId).value; @@ -117,11 +123,11 @@ export class BcfListContainerComponent extends WorkPackageListViewComponent impl } } - openStateLink(event:{ workPackageId:string; requestedState:string }) { + openStateLink(event:{ workPackageId:string; requestedState:string }):void { this.goToWpDetailState(event.workPackageId, this.uIRouterGlobals.params.cards, true); } - goToWpDetailState(workPackageId:string, cards:boolean, focus?:boolean) { + goToWpDetailState(workPackageId:string, cards:boolean, focus?:boolean):void { // Show the split view when there is a viewer (browser) // Show only wp details when there is no viewer, plugin environment (ie: Revit) const stateToGo = this.viewer.shouldShowViewer @@ -131,6 +137,6 @@ export class BcfListContainerComponent extends WorkPackageListViewComponent impl // it when going to 'bim.partitioned.show' const params = { workPackageId, cards, focus }; - this.$state.go(stateToGo, params); + void this.$state.go(stateToGo, params); } } diff --git a/frontend/src/app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component.html b/frontend/src/app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component.html deleted file mode 100644 index 5c089af78c..0000000000 --- a/frontend/src/app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component.html +++ /dev/null @@ -1,24 +0,0 @@ -
- -
-
- -
- - -
- -
- - -
-
-
diff --git a/frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.html b/frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.html new file mode 100644 index 0000000000..711ad18b51 --- /dev/null +++ b/frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.html @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component.ts b/frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.ts similarity index 61% rename from frontend/src/app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component.ts rename to frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.ts index 40fca7aac3..d32fed1c10 100644 --- a/frontend/src/app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component.ts +++ b/frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.ts @@ -26,23 +26,29 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { WorkPackageCreateComponent } from 'core-app/features/work-packages/components/wp-new/wp-create.component'; -import { Component } from '@angular/core'; -import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; -import { IFCViewerService } from 'core-app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service'; +import { + ChangeDetectionStrategy, + Component, + OnInit, +} from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { BcfViewService } from 'core-app/features/bim/ifc_models/pages/viewer/bcf-view.service'; @Component({ - selector: 'bcf-new-split', - templateUrl: './bcf-new-split.component.html', + templateUrl: './bcf-split-left.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'op-bcf-content-left', }) -export class BCFNewSplitComponent extends WorkPackageCreateComponent { - public cancelState = '^'; +export class BcfSplitLeftComponent implements OnInit { + showViewer$:Observable; - @InjectField() - readonly viewer:IFCViewerService; + constructor(private readonly bcfView:BcfViewService) {} - public onSaved(params:{ savedResource:WorkPackageResource, isInitial:boolean }) { - super.onSaved(params); + ngOnInit():void { + this.showViewer$ = this.bcfView.live$() + .pipe( + map((state) => state !== 'cards' && state !== 'table'), + ); } } diff --git a/frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.html b/frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.html new file mode 100644 index 0000000000..feb95468c7 --- /dev/null +++ b/frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.html @@ -0,0 +1,4 @@ + diff --git a/frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.ts b/frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.ts new file mode 100644 index 0000000000..4634184061 --- /dev/null +++ b/frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.ts @@ -0,0 +1,54 @@ +// -- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 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, + Component, + OnInit, +} from '@angular/core'; +import { Observable } from 'rxjs'; +import { BcfViewService } from 'core-app/features/bim/ifc_models/pages/viewer/bcf-view.service'; +import { map } from 'rxjs/operators'; + +@Component({ + templateUrl: './bcf-split-right.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'op-bcf-content-right', +}) +export class BcfSplitRightComponent implements OnInit { + showWorkPackages$:Observable; + + constructor(private readonly bcfView:BcfViewService) {} + + ngOnInit():void { + this.showWorkPackages$ = this.bcfView.live$() + .pipe( + map((state) => state === 'splitTable' || state === 'splitCards'), + ); + } +} diff --git a/frontend/src/app/features/bim/ifc_models/empty/empty-component.ts b/frontend/src/app/features/bim/ifc_models/empty/empty-component.ts deleted file mode 100644 index b687c04660..0000000000 --- a/frontend/src/app/features/bim/ifc_models/empty/empty-component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - template: '
', -}) -export class EmptyComponent { -} diff --git a/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts b/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts index f66e07a2b9..d899ecadf5 100644 --- a/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts +++ b/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts @@ -37,11 +37,11 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora import { ViewpointsService } from 'core-app/features/bim/bcf/helper/viewpoints.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; import { HttpClient } from '@angular/common/http'; -import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import { IfcProjectDefinition } from 'core-app/features/bim/ifc_models/pages/viewer/ifc-models-data.service'; import { BIMViewer } from '@xeokit/xeokit-bim-viewer/dist/xeokit-bim-viewer.es'; import { BcfViewpointData, CreateBcfViewpointData } from 'core-app/features/bim/bcf/api/bcf-api.model'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import idFromLink from 'core-app/features/hal/helpers/id-from-link'; export interface XeokitElements { canvasElement:HTMLElement; @@ -88,7 +88,7 @@ type Controller = { /** * Wrapping type from xeokit module. Can be removed after we get a real type package. */ -type BimViewer = Controller&{ +type XeokitBimViewer = Controller&{ loadProject:(projectId:string) => void, saveBCFViewpoint:(options:BCFCreationOptions) => CreateBcfViewpointData, loadBCFViewpoint:(bcfViewpoint:BcfViewpointData, options:BCFLoadOptions) => void, @@ -104,7 +104,7 @@ export class IFCViewerService extends ViewerBridgeService { public inspectorVisible$ = new BehaviorSubject(false); - private bimViewer:BimViewer|undefined; + private xeokitViewer:XeokitBimViewer|undefined; @InjectField() pathHelper:PathHelperService; @@ -123,7 +123,7 @@ export class IFCViewerService extends ViewerBridgeService { public newViewer(elements:XeokitElements, projects:IfcProjectDefinition[]):void { const server = new XeokitServer(this.pathHelper); // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const viewerUI = new BIMViewer(server, elements) as BimViewer; + const viewerUI = new BIMViewer(server, elements) as XeokitBimViewer; viewerUI.on('modelLoaded', () => this.viewerVisible$.next(true)); @@ -172,7 +172,7 @@ export class IFCViewerService extends ViewerBridgeService { } public destroy():void { - this.viewerVisible$.complete(); + this.viewerVisible$.next(false); if (!this.viewer) { return; @@ -182,12 +182,12 @@ export class IFCViewerService extends ViewerBridgeService { this.viewer = undefined; } - public get viewer():BimViewer|undefined { - return this.bimViewer; + public get viewer():XeokitBimViewer|undefined { + return this.xeokitViewer; } - public set viewer(viewer:BimViewer|undefined) { - this.bimViewer = viewer; + public set viewer(viewer:XeokitBimViewer|undefined) { + this.xeokitViewer = viewer; } public setKeyboardEnabled(val:boolean):void { @@ -209,26 +209,20 @@ export class IFCViewerService extends ViewerBridgeService { } public showViewpoint(workPackage:WorkPackageResource, index:number):void { - // Avoid reload the app when there is a place to show the viewer - // ('bim.partitioned.split') - if (this.routeWithViewer) { - if (this.viewer) { - const opts:BCFLoadOptions = { updateCompositeObjects: true, reverseClippingPlanes: true }; - this.viewpointsService - .getViewPoint$(workPackage, index) - .subscribe((viewpoint) => this.viewer?.loadBCFViewpoint(viewpoint, opts)); - } + if (this.viewerVisible()) { + const opts:BCFLoadOptions = { updateCompositeObjects: true, reverseClippingPlanes: true }; + this.viewpointsService + .getViewPoint$(workPackage, index) + .subscribe((viewpoint) => { + this.viewer?.loadBCFViewpoint(viewpoint, opts); + }); } else { - if (!workPackage.id) { - return; - } - - // Reload the whole app to get the correct menus and GON data - // and redirect to a route with a place to show viewer - // ('bim.partitioned.split') + // FIXME: When triggering showViewpoint from anywhere outside BCF module, there is no viewer shown and we have + // no means of setting it from here. Hence we must make a hard transition to bcf details route of the + // current work package. window.location.href = this.pathHelper.bimDetailsPath( idFromLink((workPackage.project as HalResource).href), - workPackage.id, + workPackage.id || '', index, ); } diff --git a/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.module.ts b/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.module.ts index 730e0fba14..f7613ad3aa 100644 --- a/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.module.ts +++ b/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.module.ts @@ -31,19 +31,18 @@ import { UIRouterModule } from '@uirouter/angular'; import { OPSharedModule } from 'core-app/shared/shared.module'; import { IFC_ROUTES } from 'core-app/features/bim/ifc_models/openproject-ifc-models.routes'; import { IFCViewerPageComponent } from 'core-app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component'; -import { EmptyComponent } from 'core-app/features/bim/ifc_models/empty/empty-component'; -import { BimViewToggleButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-button.component'; -import { BimViewToggleDropdownDirective } from 'core-app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-dropdown.directive'; +import { BcfViewToggleButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-button.component'; +import { BcfViewToggleDropdownDirective } from 'core-app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive'; import { BimManageIfcModelsButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/manage-ifc-models-button/bim-manage-ifc-models-button.component'; import { IFCViewerService } from 'core-app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service'; import { OpenprojectFieldsModule } from 'core-app/shared/components/fields/openproject-fields.module'; -import { BCFNewSplitComponent } from 'core-app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component'; -import { BcfListContainerComponent } from 'core-app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component'; -import { BimViewService } from 'core-app/features/bim/ifc_models/pages/viewer/bim-view.service'; +import { BcfListComponent } from 'core-app/features/bim/ifc_models/bcf/list/bcf-list.component'; import { IfcModelsDataService } from 'core-app/features/bim/ifc_models/pages/viewer/ifc-models-data.service'; import { OpenprojectBcfModule } from 'core-app/features/bim/bcf/openproject-bcf.module'; import { OpenprojectHalModule } from 'core-app/features/hal/openproject-hal.module'; import { IFCViewerComponent } from './ifc-viewer/ifc-viewer.component'; +import { BcfSplitLeftComponent } from 'core-app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component'; +import { BcfSplitRightComponent } from 'core-app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component'; @NgModule({ imports: [ @@ -58,7 +57,6 @@ import { IFCViewerComponent } from './ifc-viewer/ifc-viewer.component'; ], providers: [ IFCViewerService, - BimViewService, IfcModelsDataService, ], declarations: [ @@ -66,15 +64,15 @@ import { IFCViewerComponent } from './ifc-viewer/ifc-viewer.component'; IFCViewerPageComponent, // Regions of pages - EmptyComponent, - BcfListContainerComponent, + BcfSplitLeftComponent, + BcfSplitRightComponent, + BcfListComponent, // Toolbar BimManageIfcModelsButtonComponent, - BimViewToggleButtonComponent, - BimViewToggleDropdownDirective, + BcfViewToggleButtonComponent, + BcfViewToggleDropdownDirective, - BCFNewSplitComponent, IFCViewerComponent, ], }) diff --git a/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts b/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts index eb105fcd56..3a2575b95b 100644 --- a/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts +++ b/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts @@ -25,16 +25,15 @@ // // See COPYRIGHT and LICENSE files for more details. //++ + import { Ng2StateDeclaration } from '@uirouter/angular'; import { IFCViewerPageComponent } from 'core-app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component'; -import { IFCViewerComponent } from 'core-app/features/bim/ifc_models/ifc-viewer/ifc-viewer.component'; -import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component'; -import { EmptyComponent } from 'core-app/features/bim/ifc_models/empty/empty-component'; import { makeSplitViewRoutes } from 'core-app/features/work-packages/routing/split-view-routes.template'; -import { BcfListContainerComponent } from 'core-app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component'; import { WorkPackageSplitViewComponent } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view.component'; -import { ViewerBridgeService } from 'core-app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service'; import { WorkPackageNewFullViewComponent } from 'core-app/features/work-packages/components/wp-new/wp-new-full-view.component'; +import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component'; +import { BcfSplitLeftComponent } from 'core-app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component'; +import { BcfSplitRightComponent } from 'core-app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component'; export const IFC_ROUTES:Ng2StateDeclaration[] = [ { @@ -43,7 +42,7 @@ export const IFC_ROUTES:Ng2StateDeclaration[] = [ url: '/bcf?query_id&query_props&models&viewpoint', abstract: true, component: WorkPackagesBaseComponent, - redirectTo: 'bim.partitioned', + redirectTo: 'bim.partitioned.list', params: { // Use custom encoder/decoder that ensures validity of URL string query_id: { type: 'query', dynamic: true }, @@ -54,63 +53,22 @@ export const IFC_ROUTES:Ng2StateDeclaration[] = [ }, { name: 'bim.partitioned', + redirectTo: 'bim.partitioned.list', url: '', component: IFCViewerPageComponent, - redirectTo: (transition) => { - const viewerBridgeService = transition.injector().get(ViewerBridgeService); - - return viewerBridgeService.shouldShowViewer - ? 'bim.partitioned.split' - : 'bim.partitioned.list'; - }, }, { name: 'bim.partitioned.list', - url: '/list?{cards:bool}', - params: { - cards: true, - }, + url: '', data: { baseRoute: 'bim.partitioned.list', newRoute: 'bim.partitioned.list.new', - partition: '-left-only', - }, - reloadOnSearch: false, - views: { - 'content-left': { component: BcfListContainerComponent }, - }, - }, - { - name: 'bim.partitioned.split', - url: '/split?{cards:bool}', - params: { - cards: true, - }, - data: { - baseRoute: 'bim.partitioned.split', partition: '-split', - newRoute: 'bim.partitioned.split.new', - bodyClasses: 'router--work-packages-partitioned-split-view', - }, - reloadOnSearch: false, - views: { - 'content-left': { component: IFCViewerComponent }, - 'content-right': { component: BcfListContainerComponent }, - }, - }, - { - name: 'bim.partitioned.model', - url: '/model', - data: { - partition: '-left-only', - newRoute: 'bim.partitioned.model.new', }, reloadOnSearch: false, views: { - // Retarget and by that override the grandparent views - // https://ui-router.github.io/guide/views#relative-parent-state{ - 'content-right': { component: EmptyComponent }, - 'content-left': { component: IFCViewerComponent }, + 'content-left': { component: BcfSplitLeftComponent }, + 'content-right': { component: BcfSplitRightComponent }, }, }, { @@ -121,7 +79,7 @@ export const IFC_ROUTES:Ng2StateDeclaration[] = [ baseRoute: 'bim.partitioned.list', allowMovingInEditMode: true, partition: '-left-only', - successState: 'bim.partitioned.show' + successState: 'bim.partitioned.show', }, views: { 'content-left': { component: WorkPackageNewFullViewComponent } }, }, @@ -150,16 +108,4 @@ export const IFC_ROUTES:Ng2StateDeclaration[] = [ undefined, WorkPackageSplitViewComponent, ), - // BCF single view for split - ...makeSplitViewRoutes( - 'bim.partitioned.split', - undefined, - WorkPackageSplitViewComponent, - ), - // BCF single view for model-only - ...makeSplitViewRoutes( - 'bim.partitioned.model', - undefined, - WorkPackageSplitViewComponent, - ), ]; diff --git a/frontend/src/app/features/bim/ifc_models/pages/viewer/bcf-view.service.ts b/frontend/src/app/features/bim/ifc_models/pages/viewer/bcf-view.service.ts new file mode 100644 index 0000000000..7e38116303 --- /dev/null +++ b/frontend/src/app/features/bim/ifc_models/pages/viewer/bcf-view.service.ts @@ -0,0 +1,98 @@ +// -- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 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 { I18nService } from 'core-app/core/i18n/i18n.service'; +import { WorkPackageQueryStateService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-base.service'; +import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; +import { QueryResource } from 'core-app/features/hal/resources/query-resource'; +import { ViewerBridgeService } from 'core-app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service'; + +export const bcfCardsViewIdentifier = 'cards'; +export const bcfViewerViewIdentifier = 'viewer'; +export const bcfSplitViewTableIdentifier = 'splitTable'; +export const bcfSplitViewCardsIdentifier = 'splitCards'; +export const bcfTableViewIdentifier = 'table'; + +export type BcfViewState = 'cards'|'viewer'|'splitTable'|'splitCards'|'table'; + +@Injectable() +export class BcfViewService extends WorkPackageQueryStateService { + public text:{ [key:string]:string } = { + cards: this.I18n.t('js.views.card'), + viewer: this.I18n.t('js.ifc_models.views.viewer'), + splitTable: this.I18n.t('js.ifc_models.views.split'), + splitCards: this.I18n.t('js.ifc_models.views.split_cards'), + table: this.I18n.t('js.views.list'), + }; + + public icon:{ [key:string]:string } = { + cards: 'icon-view-card', + viewer: 'icon-view-model', + splitTable: 'icon-view-split-viewer-table', + splitCards: 'icon-view-split2', + table: 'icon-view-list', + }; + + constructor( + private readonly I18n:I18nService, + private readonly viewerBridgeService:ViewerBridgeService, + protected readonly querySpace:IsolatedQuerySpace, + ) { + super(querySpace); + } + + hasChanged(query:QueryResource):boolean { + return this.current !== query.displayRepresentation; + } + + applyToQuery(query:QueryResource):boolean { + // eslint-disable-next-line no-param-reassign + query.displayRepresentation = this.current; + return true; + } + + public valueFromQuery(query:QueryResource):BcfViewState|undefined { + const dr = query.displayRepresentation; + + switch (dr) { + case 'splitCards': + case 'splitTable': + case 'cards': + case 'table': + case 'viewer': + return dr; + default: + return this.viewerBridgeService.shouldShowViewer ? 'splitCards' : 'cards'; + } + } + + public currentViewerState():BcfViewState|undefined { + return this.current; + } +} diff --git a/frontend/src/app/features/bim/ifc_models/pages/viewer/bim-view.service.ts b/frontend/src/app/features/bim/ifc_models/pages/viewer/bim-view.service.ts deleted file mode 100644 index 9dbe39ae7c..0000000000 --- a/frontend/src/app/features/bim/ifc_models/pages/viewer/bim-view.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -// -- copyright -// OpenProject is an open source project management software. -// Copyright (C) 2012-2021 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, OnDestroy } from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { Observable } from 'rxjs'; -import { StateService, TransitionService } from '@uirouter/core'; -import { input } from 'reactivestates'; -import { takeUntil } from 'rxjs/operators'; - -export const bimListViewIdentifier = 'list'; -export const bimTableViewIdentifier = 'table'; -export const bimSplitViewCardsIdentifier = 'splitCards'; -export const bimSplitViewListIdentifier = 'splitList'; -export const bimViewerViewIdentifier = 'viewer'; - -export type BimViewState = 'list'|'viewer'|'splitList'|'splitCards'|'table'; - -@Injectable() -export class BimViewService implements OnDestroy { - private _state = input(); - - public text:any = { - list: this.I18n.t('js.views.card'), - viewer: this.I18n.t('js.ifc_models.views.viewer'), - splitList: this.I18n.t('js.ifc_models.views.split'), - splitCards: this.I18n.t('js.ifc_models.views.split_cards'), - table: this.I18n.t('js.views.list'), - }; - - public icon:any = { - list: 'icon-view-card', - viewer: 'icon-view-model', - splitList: 'icon-view-split-viewer-table', - splitCards: 'icon-view-split2', - table: 'icon-view-list', - }; - - private transitionFn:Function; - - constructor(readonly I18n:I18nService, - readonly transitions:TransitionService, - readonly state:StateService) { - this.detectView(); - - this.transitionFn = this.transitions.onSuccess({}, (transition) => { - this.detectView(); - }); - } - - get view$():Observable { - return this._state.values$(); - } - - public observeUntil(unsubscribe:Observable) { - return this.view$.pipe(takeUntil(unsubscribe)); - } - - get current():BimViewState { - return this._state.getValueOr(bimSplitViewCardsIdentifier); - } - - public currentViewerState():BimViewState { - if (this.state.includes('bim.partitioned.list')) { - return this.state.params?.cards - ? bimListViewIdentifier - : bimTableViewIdentifier; - } if (this.state.includes('bim.**.model')) { - return bimViewerViewIdentifier; - } if (this.state.includes('bim.partitioned.show')) { - return this.state.params?.cards || this.state.params?.cards == null - ? bimListViewIdentifier - : bimTableViewIdentifier; - } - return this.state.params?.cards || this.state.params?.cards == null - ? bimSplitViewCardsIdentifier - : bimSplitViewListIdentifier; - } - - private detectView() { - this._state.putValue(this.currentViewerState()); - } - - ngOnDestroy() { - this.transitionFn(); - } -} diff --git a/frontend/src/app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component.ts b/frontend/src/app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component.ts index 0646d451b3..63034ad887 100644 --- a/frontend/src/app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component.ts +++ b/frontend/src/app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component.ts @@ -1,7 +1,38 @@ +// -- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 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, Component, Injector, ViewEncapsulation, + ChangeDetectionStrategy, + Component, + Injector, + OnInit, + ViewEncapsulation, } from '@angular/core'; -import { GonService } from 'core-app/core/gon/gon.service'; import { PartitionedQuerySpacePageComponent, ToolbarButtonComponentDefinition, @@ -9,23 +40,22 @@ import { import { WorkPackageFilterButtonComponent } from 'core-app/features/work-packages/components/wp-buttons/wp-filter-button/wp-filter-button.component'; import { ZenModeButtonComponent } from 'core-app/features/work-packages/components/wp-buttons/zen-mode-toggle-button/zen-mode-toggle-button.component'; import { - bimListViewIdentifier, - bimViewerViewIdentifier, - BimViewService, -} from 'core-app/features/bim/ifc_models/pages/viewer/bim-view.service'; -import { BimViewToggleButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-button.component'; + bcfSplitViewCardsIdentifier, + bcfViewerViewIdentifier, + BcfViewService, +} from 'core-app/features/bim/ifc_models/pages/viewer/bcf-view.service'; +import { BcfViewToggleButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-button.component'; import { IfcModelsDataService } from 'core-app/features/bim/ifc_models/pages/viewer/ifc-models-data.service'; import { QueryParamListenerService } from 'core-app/features/work-packages/components/wp-query/query-param-listener.service'; -import { QueryResource } from 'core-app/features/hal/resources/query-resource'; import { BimManageIfcModelsButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/manage-ifc-models-button/bim-manage-ifc-models-button.component'; import { WorkPackageCreateButtonComponent } from 'core-app/features/work-packages/components/wp-buttons/wp-create-button/wp-create-button.component'; -import { StateService, TransitionService } from '@uirouter/core'; -import { BehaviorSubject } from 'rxjs'; +import { of } from 'rxjs'; import { BcfImportButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/import-export-bcf/bcf-import-button.component'; import { BcfExportButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/import-export-bcf/bcf-export-button.component'; import { RefreshButtonComponent } from 'core-app/features/bim/ifc_models/toolbar/import-export-bcf/refresh-button.component'; -import { componentDestroyed } from '@w11k/ngx-componentdestroyed'; import { ViewerBridgeService } from 'core-app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service'; +import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; +import { QueryResource } from 'core-app/features/hal/resources/query-resource'; @Component({ templateUrl: '../../../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.html', @@ -36,10 +66,11 @@ import { ViewerBridgeService } from 'core-app/features/bim/bcf/bcf-viewer-bridge encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ + BcfViewService, QueryParamListenerService, ], }) -export class IFCViewerPageComponent extends PartitionedQuerySpacePageComponent { +export class IFCViewerPageComponent extends PartitionedQuerySpacePageComponent implements UntilDestroyedMixin, OnInit { text = { title: this.I18n.t('js.bcf.management'), delete: this.I18n.t('js.button_delete'), @@ -47,15 +78,15 @@ export class IFCViewerPageComponent extends PartitionedQuerySpacePageComponent { areYouSure: this.I18n.t('js.text_are_you_sure'), }; - newRoute$ = new BehaviorSubject(undefined); - - transitionUnsubscribeFn:Function; + private readonly newRoute = this.viewerBridgeService.shouldShowViewer + ? 'bim.partitioned.list.new' + : 'bim.partitioned.new'; toolbarButtonComponents:ToolbarButtonComponentDefinition[] = [ { component: WorkPackageCreateButtonComponent, inputs: { - stateName$: this.newRoute$, + stateName$: of(this.newRoute), allowed: ['work_packages.createWorkPackage', 'work_package.copy'], }, }, @@ -65,20 +96,20 @@ export class IFCViewerPageComponent extends PartitionedQuerySpacePageComponent { }, { component: BcfImportButtonComponent, - show: () => this.ifcData.allowed('manage_bcf'), + show: ():boolean => this.ifcData.allowed('manage_bcf'), containerClasses: 'hidden-for-mobile', }, { component: BcfExportButtonComponent, - show: () => this.ifcData.allowed('manage_bcf'), + show: ():boolean => this.ifcData.allowed('manage_bcf'), containerClasses: 'hidden-for-mobile', }, { component: WorkPackageFilterButtonComponent, - show: () => this.bimView.currentViewerState() !== bimViewerViewIdentifier, + show: ():boolean => this.bcfView.currentViewerState() !== 'viewer', }, { - component: BimViewToggleButtonComponent, + component: BcfViewToggleButtonComponent, containerClasses: 'hidden-for-mobile', }, { @@ -87,65 +118,48 @@ export class IFCViewerPageComponent extends PartitionedQuerySpacePageComponent { }, { component: BimManageIfcModelsButtonComponent, - show: () => - // Hide 'Manage models' toolbar button on plugin environment (ie: Revit) - this.viewerBridgeService.shouldShowViewer - && this.ifcData.allowed('manage_ifc_models'), - + // Hide 'Manage models' toolbar button on plugin environment (ie: Revit) + show: ():boolean => this.viewerBridgeService.shouldShowViewer + && this.ifcData.allowed('manage_ifc_models'), }, ]; - get newRoute() { - // Open new work packages in full view when there - // is no viewer (ie: Revit) - return this.viewerBridgeService.shouldShowViewer - ? this.state.current.data.newRoute - : 'bim.partitioned.new'; - } - constructor(readonly ifcData:IfcModelsDataService, - readonly state:StateService, - readonly bimView:BimViewService, - readonly transition:TransitionService, - readonly gon:GonService, + readonly bcfView:BcfViewService, readonly injector:Injector, readonly viewerBridgeService:ViewerBridgeService) { super(injector); } - ngOnInit() { + ngOnInit():void { super.ngOnInit(); - this.newRoute$.next(this.newRoute); - this - .bimView - .observeUntil(componentDestroyed(this)) - .subscribe((view) => { - this.filterAllowed = view !== bimViewerViewIdentifier; + this.setupChangeObserver(this.bcfView); + + // Add bcf thumbnail to wp table per default, once the columns are available + this.wpTableColumns.querySpace.available.columns.values$() + .pipe(this.untilDestroyed()) + .subscribe(() => { + this.wpTableColumns.addColumn('bcfThumbnail', 2); }); - // Keep the new route up to date depending on where we move to - this.transitionUnsubscribeFn = this.transition.onSuccess({}, () => { - this.newRoute$.next(this.newRoute); - }); + this.querySpace.query.values$() + .pipe(this.untilDestroyed()) + .subscribe((query) => { + const dr = query.displayRepresentation || bcfSplitViewCardsIdentifier; + this.filterAllowed = dr !== bcfViewerViewIdentifier; + this.cdRef.detectChanges(); + }); } /** - * We disable using the query title for now, - * but this might be useful later. - * - * To re-enable query titles, remove this function. - * - * @param query + * Initialize the BcfViewService when the query of the isolated space is loaded */ - updateTitle(query?:QueryResource) { - if (this.bimView.current === bimListViewIdentifier) { - super.updateTitle(query); - } else { - this.selectedTitle = this.I18n.t('js.bcf.management'); - } - - // For now, disable any editing - this.titleEditingEnabled = false; + public loadQuery(firstPage = false):Promise { + return super.loadQuery(firstPage) + .then((query) => { + this.bcfView.initialize(query, query.results); + return query; + }); } } diff --git a/frontend/src/app/features/bim/ifc_models/toolbar/manage-ifc-models-button/bim-manage-ifc-models-button.component.ts b/frontend/src/app/features/bim/ifc_models/toolbar/manage-ifc-models-button/bim-manage-ifc-models-button.component.ts index 56f0345668..49593d3c8d 100644 --- a/frontend/src/app/features/bim/ifc_models/toolbar/manage-ifc-models-button/bim-manage-ifc-models-button.component.ts +++ b/frontend/src/app/features/bim/ifc_models/toolbar/manage-ifc-models-button/bim-manage-ifc-models-button.component.ts @@ -43,7 +43,7 @@ import { IfcModelsDataService } from 'core-app/features/bim/ifc_models/pages/vie `, changeDetection: ChangeDetectionStrategy.OnPush, - selector: 'bim-view-toggle-button', + selector: 'op-bcf-manage-ifc-button', }) export class BimManageIfcModelsButtonComponent { text = { diff --git a/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-button.component.ts b/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-button.component.ts similarity index 78% rename from frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-button.component.ts rename to frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-button.component.ts index 7528bbcd30..507bb24517 100644 --- a/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-button.component.ts +++ b/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-button.component.ts @@ -28,30 +28,28 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { BimViewService } from 'core-app/features/bim/ifc_models/pages/viewer/bim-view.service'; +import { BcfViewService } from 'core-app/features/bim/ifc_models/pages/viewer/bcf-view.service'; @Component({ template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, - selector: 'bim-view-toggle-button', + selector: 'op-bcf-view-toggle-button', }) -export class BimViewToggleButtonComponent { - view$ = this.bimView.view$; +export class BcfViewToggleButtonComponent { + view$ = this.bcfView.live$(); - constructor(readonly I18n:I18nService, - readonly bimView:BimViewService) { - } + constructor(readonly I18n:I18nService, readonly bcfView:BcfViewService) { } } diff --git a/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-dropdown.directive.ts b/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts similarity index 51% rename from frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-dropdown.directive.ts rename to frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts index ecfecd43ab..7b7c927715 100644 --- a/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bim-view-toggle-dropdown.directive.ts +++ b/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts @@ -32,55 +32,53 @@ import { OpContextMenuTrigger } from 'core-app/shared/components/op-context-menu import { I18nService } from 'core-app/core/i18n/i18n.service'; import { StateService } from '@uirouter/core'; import { - bimListViewIdentifier, - bimSplitViewCardsIdentifier, - bimSplitViewListIdentifier, - bimTableViewIdentifier, - bimViewerViewIdentifier, - BimViewService, -} from 'core-app/features/bim/ifc_models/pages/viewer/bim-view.service'; + bcfCardsViewIdentifier, + bcfSplitViewCardsIdentifier, + bcfSplitViewTableIdentifier, + bcfTableViewIdentifier, + bcfViewerViewIdentifier, + BcfViewService, +} from 'core-app/features/bim/ifc_models/pages/viewer/bcf-view.service'; import { ViewerBridgeService } from 'core-app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service'; -import { WorkPackageViewDisplayRepresentationService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-display-representation.service'; import { WorkPackageFiltersService } from 'core-app/features/work-packages/components/filters/wp-filters/wp-filters.service'; +import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; @Directive({ - selector: '[bimViewDropdown]', + selector: '[opBcfViewDropdown]', }) -export class BimViewToggleDropdownDirective extends OpContextMenuTrigger { +export class BcfViewToggleDropdownDirective extends OpContextMenuTrigger { constructor(readonly elementRef:ElementRef, readonly opContextMenu:OPContextMenuService, - readonly bimView:BimViewService, + readonly bcfView:BcfViewService, readonly I18n:I18nService, readonly state:StateService, readonly wpFiltersService:WorkPackageFiltersService, - readonly viewerBridgeService:ViewerBridgeService, - readonly wpDisplayRepresentation:WorkPackageViewDisplayRepresentationService) { + readonly viewerBridgeService:ViewerBridgeService) { super(elementRef, opContextMenu); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:JQuery.TriggeredEvent):void { this.buildItems(); this.opContextMenu.show(this, evt); } - public get locals() { + public get locals():{ showAnchorRight?:boolean, contextMenuId?:string, items:OpContextMenuItem[] } { return { items: this.items, - contextMenuId: 'bim-view-context-menu', + contextMenuId: 'bcf-view-context-menu', }; } private buildItems() { - const { current } = this.bimView; const items = this.viewerBridgeService.shouldShowViewer - ? [bimViewerViewIdentifier, bimListViewIdentifier, bimSplitViewCardsIdentifier, bimSplitViewListIdentifier, bimTableViewIdentifier] - : [bimListViewIdentifier, bimTableViewIdentifier]; + ? [bcfViewerViewIdentifier, bcfCardsViewIdentifier, bcfSplitViewCardsIdentifier, bcfSplitViewTableIdentifier, bcfTableViewIdentifier] + : [bcfCardsViewIdentifier, bcfTableViewIdentifier]; this.items = items .map((key) => ({ - hidden: key === current, - linkText: this.bimView.text[key], - icon: this.bimView.icon[key], + hidden: key === this.bcfView.currentViewerState(), + linkText: this.bcfView.text[key], + icon: this.bcfView.icon[key], onClick: () => { // Close filter section if (this.wpFiltersService.visible) { @@ -88,32 +86,14 @@ export class BimViewToggleDropdownDirective extends OpContextMenuTrigger { } switch (key) { - // This project controls the view representation of the data through - // the wpDisplayRepresentation service that modifies the QuerySpace - // to inform the rest of the app about which display mode is currently - // active (this.querySpace.query.live$). - // Under the hood it is done by modifying the params of actual route. - // Because of that, it is not possible to call this.state.go and - // this.wpDisplayRepresentation.setDisplayRepresentation at the same - // time, it raises a route error (The transition has been superseded by - // a different transition...). To avoid this error, we are passing - // a cards params to inform the view about the display representation mode - // it has to show (cards or list). - case bimListViewIdentifier: - this.state.go('bim.partitioned.list', { cards: true }); - break; - case bimTableViewIdentifier: - this.state.go('bim.partitioned.list', { cards: false }); - break; - case bimViewerViewIdentifier: - this.state.go('bim.partitioned.model'); - break; - case bimSplitViewCardsIdentifier: - this.state.go('bim.partitioned.split', { cards: true }); - break; - case bimSplitViewListIdentifier: - this.state.go('bim.partitioned.split', { cards: false }); + case 'cards': + case 'table': + case 'viewer': + case 'splitCards': + case 'splitTable': + this.bcfView.update(key); break; + default: } return true; diff --git a/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts b/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts index c40417b191..37a3563ba3 100644 --- a/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts +++ b/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts @@ -137,7 +137,7 @@ export class IanCenterService extends UntilDestroyedMixin { } markAllAsRead():void { - const ids:ID[] = selectCollectionAsEntities$(this.resourceService, this.resourceService.query.getValue(), this.query.params) + const ids:ID[] = selectCollectionAsEntities$(this.resourceService, this.query.params) .filter((notification) => notification.readIAN === false) .map((notification) => notification.id); diff --git a/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html new file mode 100644 index 0000000000..f87631480d --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html @@ -0,0 +1,8 @@ + diff --git a/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts new file mode 100644 index 0000000000..2329aff784 --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts @@ -0,0 +1,96 @@ +// -- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 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 { + Component, + ElementRef, + EventEmitter, + Injector, + Input, + Output, + ChangeDetectionStrategy, +} from '@angular/core'; +import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; +import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; + +@Component({ + templateUrl: './add-assignee.component.html', + selector: 'op-tp-add-assignee', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddAssigneeComponent { + @Output() public selectAssignee = new EventEmitter(); + + @Input() alreadySelected:string[] = []; + + public getOptionsFn = (query:string):Observable => this.autocomplete(query); + + constructor( + protected elementRef:ElementRef, + protected halResourceService:HalResourceService, + protected I18n:I18nService, + protected halNotification:HalResourceNotificationService, + readonly pathHelper:PathHelperService, + readonly apiV3Service:APIV3Service, + readonly injector:Injector, + readonly currentProjectService:CurrentProjectService, + ) { } + + public autocomplete(term:string|null):Observable { + const filters = new ApiV3FilterBuilder(); + + filters.add('member', '=', [this.currentProjectService.id || '']); + + if (term) { + filters.add('name_and_identifier', '~', [term]); + } + + return this + .apiV3Service + .principals + .filtered(filters) + .get() + .pipe( + map((collection) => collection.elements.filter( + (user) => !this.alreadySelected.find((selected) => selected === user.id), + )), + ); + } + + public selectUser(user:HalResource):void { + this.selectAssignee.emit(user); + } +} diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html index 0d9fd43010..d66b1708b2 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html @@ -11,8 +11,45 @@ [textContent]="calendar.tooManyResultsText" class="op-wp-calendar--notification"> + - +
+ + +
+ +
+ +
+ +
diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass index 0bebb94376..302ab38e75 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass @@ -6,3 +6,6 @@ overflow: auto // Avoid empty space on the right since the styled scrollbar would only be visible on hover. @include no-visible-scroll-bar + +.tp-assignee-add-button + margin-top: 1rem diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts index c348ff2f3a..b05c4e3be9 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts @@ -11,27 +11,34 @@ import { CalendarOptions, EventInput, } from '@fullcalendar/core'; +import { + combineLatest, + Subject, +} from 'rxjs'; +import { + debounceTime, + mergeMap, + map, + filter, + distinctUntilChanged, +} from 'rxjs/operators'; +import { EventClickArg } from '@fullcalendar/common'; +import { StateService } from '@uirouter/angular'; import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; +import { FullCalendarComponent } from '@fullcalendar/angular'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; -import { FullCalendarComponent } from '@fullcalendar/angular'; import { EventViewLookupService } from 'core-app/features/team-planner/team-planner/planner/event-view-lookup.service'; -import { States } from 'core-app/core/states/states.service'; -import { StateService } from '@uirouter/angular'; -import { DomSanitizer } from '@angular/platform-browser'; import { WorkPackageViewFiltersService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service'; -import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service'; import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; -import { WorkPackagesListChecksumService } from 'core-app/features/work-packages/components/wp-list/wp-list-checksum.service'; -import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { OpTitleService } from 'core-app/core/html/op-title.service'; -import { Subject } from 'rxjs'; +import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; +import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource'; +import { PrincipalsResourceService } from 'core-app/core/state/principals/principals.service'; +import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { debounceTime } from 'rxjs/operators'; -import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; import { ResourceLabelContentArg } from '@fullcalendar/resource-common'; import { OpCalendarService } from 'core-app/shared/components/calendar/op-calendar.service'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; @@ -56,28 +63,58 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, @ViewChild('resourceContent') resourceContent:TemplateRef; + @ViewChild('assigneeAutocompleter') assigneeAutocompleter:TemplateRef; + + private resizeSubject = new Subject(); + calendarOptions$ = new Subject(); projectIdentifier:string|undefined = undefined; + showAddAssignee$ = new Subject(); + + private principalIds$ = this.wpTableFilters + .live$() + .pipe( + this.untilDestroyed(), + map((queryFilters) => { + const assigneeFilter = queryFilters.find((queryFilter) => queryFilter._type === 'AssigneeQueryFilter'); + return ((assigneeFilter?.values || []) as HalResource[]).map((p) => p.id); + }), + ); + + private params$ = this.principalIds$ + .pipe( + this.untilDestroyed(), + filter((ids) => ids.length > 0), + map((ids) => ({ + filters: [['id', '=', ids]], + }) as Apiv3ListParameters), + ); + + assignees:HalResource[] = []; + text = { assignees: this.I18n.t('js.team_planner.label_assignee_plural'), + add_assignee: this.I18n.t('js.team_planner.add_assignee'), + remove_assignee: this.I18n.t('js.team_planner.remove_assignee'), }; + principals$ = this.principalIds$ + .pipe( + this.untilDestroyed(), + mergeMap((ids:string[]) => this.principalsResourceService.query.byIds(ids)), + debounceTime(50), + distinctUntilChanged((prev, curr) => prev.length === curr.length && prev.length === 0), + ); + constructor( - private elementRef:ElementRef, - private states:States, private $state:StateService, - private sanitizer:DomSanitizer, private configuration:ConfigurationService, - private apiV3Service:APIV3Service, + private principalsResourceService:PrincipalsResourceService, private wpTableFilters:WorkPackageViewFiltersService, - private wpListService:WorkPackagesListService, private querySpace:IsolatedQuerySpace, - private wpListChecksumService:WorkPackagesListChecksumService, - private schemaCache:SchemaCacheService, private currentProject:CurrentProjectService, - private titleService:OpTitleService, private viewLookup:EventViewLookupService, private I18n:I18nService, readonly calendar:OpCalendarService, @@ -86,18 +123,64 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, } ngOnInit():void { - this.setupWorkPackagesListener(); this.initializeCalendar(); this.projectIdentifier = this.currentProject.identifier ? this.currentProject.identifier : undefined; - this.calendar.resize$ + this + .querySpace + .results + .values$() + .pipe(this.untilDestroyed()) + .subscribe(() => { + this.ucCalendar.getApi().refetchEvents(); + }); + + this.resizeSubject + .pipe(this.untilDestroyed()) + .subscribe(() => { + this.ucCalendar.getApi().updateSize(); + }); + + this.params$ + .pipe(this.untilDestroyed()) + .subscribe((params) => { + this.principalsResourceService.fetchPrincipals(params).subscribe(); + }); + + combineLatest([ + this.principals$, + this.showAddAssignee$, + ]) .pipe( this.untilDestroyed(), - debounceTime(50), + debounceTime(0), ) - .subscribe(() => { - this.ucCalendar.getApi().updateSize(); + .subscribe(([principals, showAddAssignee]) => { + const api = this.ucCalendar.getApi(); + + api.getResources().forEach((resource) => resource.remove()); + + principals.forEach((principal) => { + const { self } = principal._links; + const id = Array.isArray(self) ? self[0].href : self.href; + api.addResource({ + principal, + id, + title: principal.name, + }); + }); + + if (showAddAssignee) { + api.addResource({ + principal: null, + id: 'NEW', + title: '', + }); + } }); + + // This needs to be done after all the subscribers are set up + this.showAddAssignee$.next(false); } ngOnDestroy():void { @@ -105,40 +188,6 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, this.calendar.resizeObs?.disconnect(); } - public calendarResourcesFunction( - fetchInfo:{ start:Date, end:Date, timeZone:string }, - successCallback:(events:EventInput[]) => void, - failureCallback:(error:unknown) => void, - ):void|PromiseLike { - this - .calendar - .currentWorkPackages$ - .toPromise() - .then((workPackages:WorkPackageCollectionResource) => { - const resources = this.mapToCalendarResources(workPackages.elements); - successCallback(resources); - }) - .catch(failureCallback); - } - - public calendarEventsFunction( - fetchInfo:{ start:Date, end:Date, timeZone:string }, - successCallback:(events:EventInput[]) => void, - failureCallback:(error:unknown) => void, - ):void|PromiseLike { - this - .calendar - .currentWorkPackages$ - .toPromise() - .then((workPackages:WorkPackageCollectionResource) => { - const events = this.mapToCalendarEvents(workPackages.elements); - successCallback(events); - }) - .catch(failureCallback); - - this.calendar.updateTimeframe(fetchInfo, this.projectIdentifier); - } - private initializeCalendar() { void this.configuration.initialized .then(() => { @@ -174,7 +223,8 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, }, }, events: this.calendarEventsFunction.bind(this) as unknown, - resources: this.calendarResourcesFunction.bind(this) as unknown, + resources: [], + eventClick: this.openSplitView.bind(this) as unknown, resourceLabelContent: (data:ResourceLabelContentArg) => this.renderTemplate(this.resourceContent, data.resource.id, data), resourceLabelWillUnmount: (data:ResourceLabelContentArg) => this.unrenderTemplate(data.resource.id), } as CalendarOptions), @@ -182,6 +232,24 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, }); } + public calendarEventsFunction( + fetchInfo:{ start:Date, end:Date, timeZone:string }, + successCallback:(events:EventInput[]) => void, + failureCallback:(error:unknown) => void, + ):void|PromiseLike { + this + .calendar + .currentWorkPackages$ + .toPromise() + .then((workPackages:WorkPackageCollectionResource) => { + const events = this.mapToCalendarEvents(workPackages.elements); + successCallback(events); + }) + .catch(failureCallback); + + this.calendar.updateTimeframe(fetchInfo, this.projectIdentifier); + } + renderTemplate(template:TemplateRef, id:string, data:ResourceLabelContentArg):{ domNodes:unknown[] } { const ref = this.viewLookup.getView(template, id, data); return { domNodes: ref.rootNodes }; @@ -191,18 +259,50 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, this.viewLookup.destroyView(id); } - private setupWorkPackagesListener():void { - this.calendar.workPackagesListener$(() => { - this.renderCurrent(); - }); + public showAssigneeAddRow():void { + this.showAddAssignee$.next(true); + this.ucCalendar.getApi().refetchEvents(); } - /** - * Renders the currently loaded set of items - */ - private renderCurrent() { - this.ucCalendar.getApi().refetchEvents(); - this.ucCalendar.getApi().refetchResources(); + public addAssignee(principal:HalResource):void { + this.showAddAssignee$.next(false); + + const modifyFilter = (assigneeFilter:QueryFilterInstanceResource) => { + // eslint-disable-next-line no-param-reassign + assigneeFilter.values = [ + ...assigneeFilter.values as HalResource[], + principal, + ]; + }; + + if (this.wpTableFilters.findIndex('assignee') === -1) { + // Replace actually also instantiates if it does not exist, which is handy here + this.wpTableFilters.replace('assignee', modifyFilter.bind(this)); + } else { + this.wpTableFilters.modify('assignee', modifyFilter.bind(this)); + } + } + + public removeAssignee(href:string):void { + const numberOfAssignees = this.wpTableFilters.find('assignee')?.values?.length; + if (numberOfAssignees && numberOfAssignees <= 1) { + this.wpTableFilters.remove('assignee'); + } else { + this.wpTableFilters.modify('assignee', (assigneeFilter:QueryFilterInstanceResource) => { + // eslint-disable-next-line no-param-reassign + assigneeFilter.values = (assigneeFilter.values as HalResource[]) + .filter((filterValue) => filterValue.href !== href); + }); + } + } + + private openSplitView(event:EventClickArg):void { + const workPackage = event.event.extendedProps.workPackage as WorkPackageResource; + + void this.$state.go( + `${splitViewRoute(this.$state)}.tabs`, + { workPackageId: workPackage.id, tabIdentifier: 'overview' }, + ); } private mapToCalendarEvents(workPackages:WorkPackageResource[]):EventInput[] { @@ -231,23 +331,4 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, }) .filter((event) => !!event) as EventInput[]; } - - private mapToCalendarResources(workPackages:WorkPackageResource[]) { - const resources:{ id:string, title:string, user:HalResource }[] = []; - - workPackages.forEach((workPackage:WorkPackageResource) => { - const assignee = workPackage.assignee as HalResource|undefined; - if (!assignee) { - return; - } - - resources.push({ - id: assignee.href as string, - title: assignee.name, - user: assignee, - }); - }); - - return resources; - } } diff --git a/frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass b/frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass new file mode 100644 index 0000000000..da218add5e --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass @@ -0,0 +1,30 @@ +.tp-assignee + display: flex + max-width: 100% + + &--principal + max-width: 100% + min-width: 0 // See: https://css-tricks.com/flexbox-truncated-text/ + flex-grow: 1 + flex-shrink: 1 + + &--remove + background: white + border-radius: 50% + margin: 0 + padding: 0 + padding-left: 0.5rem + flex-grow: 0 + flex-shrink: 0 + width: 0 + border: 0 + background: transparent + cursor: normal + pointer-events: none + opacity: 0 + + @at-root &--remove:focus, &:hover &--remove, #{$spec-active-selector} &--remove + width: unset + cursor: pointer + opacity: 1 + pointer-events: all diff --git a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts index 7cf7a65894..05e9b073d4 100644 --- a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts +++ b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts @@ -4,10 +4,12 @@ import { UIRouterModule } from '@uirouter/angular'; import { DynamicModule } from 'ng-dynamic-component'; import { FullCalendarModule } from '@fullcalendar/angular'; import { IconModule } from 'core-app/shared/components/icon/icon.module'; +import { OpenprojectAutocompleterModule } from 'core-app/shared/components/autocompleter/openproject-autocompleter.module'; import { OpenprojectPrincipalRenderingModule } from 'core-app/shared/components/principal/principal-rendering.module'; import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; import { TEAM_PLANNER_ROUTES } from 'core-app/features/team-planner/team-planner/team-planner.routes'; import { TeamPlannerComponent } from 'core-app/features/team-planner/team-planner/planner/team-planner.component'; +import { AddAssigneeComponent } from 'core-app/features/team-planner/team-planner/planner/add-assignee.component'; import { TeamPlannerPageComponent } from 'core-app/features/team-planner/team-planner/page/team-planner-page.component'; import { OPSharedModule } from 'core-app/shared/shared.module'; @@ -15,6 +17,7 @@ import { OPSharedModule } from 'core-app/shared/shared.module'; declarations: [ TeamPlannerComponent, TeamPlannerPageComponent, + AddAssigneeComponent, ], imports: [ OPSharedModule, @@ -28,6 +31,8 @@ import { OPSharedModule } from 'core-app/shared/shared.module'; OpenprojectPrincipalRenderingModule, OpenprojectWorkPackagesModule, FullCalendarModule, + // Autocompleters + OpenprojectAutocompleterModule, ], }) export class TeamPlannerModule {} diff --git a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass index 0bedeaabb5..3551ef0057 100644 --- a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass +++ b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass @@ -12,6 +12,9 @@ .work-packages-partitioned-page--content-left display: none + .work-packages-partitioned-page--content-right + flex-grow: 1 + &.-left-only .work-packages-partitioned-page--content-right display: none diff --git a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts index b270b7e383..653357af7a 100644 --- a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts +++ b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts @@ -228,19 +228,21 @@ export class PartitionedQuerySpacePageComponent extends WorkPackagesViewBase imp } } - refresh(visibly = false, firstPage = false):Promise { - let promise = this.loadQuery(firstPage) as Promise; + refresh(visibly = false, firstPage = false):Promise { + let promise = this.loadQuery(firstPage); if (visibly) { promise = promise.then((loadedQuery:QueryResource) => { this.wpStatesInitialization.initialize(loadedQuery, loadedQuery.results); - return this.additionalLoadingTime(); + return this.additionalLoadingTime() + .then(() => loadedQuery); }); this.loadingIndicator = promise; } else { promise = promise.then((loadedQuery:QueryResource) => { this.wpStatesInitialization.initialize(loadedQuery, loadedQuery.results); + return loadedQuery; }); } diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts index 856be08025..6af224b645 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts @@ -19,7 +19,7 @@ export class WpSingleViewQuery extends Query { this.resourceService.query.select(), ]).pipe( filter((filters) => filters.length > 0), - map(([filters, state]) => selectCollectionAsEntities$(this.resourceService, state, { filters })), + map(([filters]) => selectCollectionAsEntities$(this.resourceService, { filters })), ); selectNotificationsCount$ = this diff --git a/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts index da43b7d005..eeba0894b1 100644 --- a/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts @@ -4,7 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Component } from '@angular/core'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { - UserAutocompleteItem, + IUserAutocompleteItem, UserAutocompleterComponent, } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component'; import { URLParamsEncoder } from 'core-app/features/hal/services/url-params-encoder'; @@ -20,9 +20,9 @@ export class MembersAutocompleterComponent extends UserAutocompleterComponent { @InjectField() pathHelper:PathHelperService; - protected getAvailableUsers(url:string, searchTerm:any):Observable { + protected getAvailableUsers(url:string, searchTerm:any):Observable { return this.http - .get(url, + .get(url, { params: new HttpParams({ encoder: new URLParamsEncoder(), fromObject: { q: searchTerm } }), responseType: 'json', diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts index 2605eb12cc..c02bc58e70 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts @@ -46,10 +46,11 @@ import { ApiV3FilterBuilder, FilterOperator } from 'core-app/shared/helpers/api- export const usersAutocompleterSelector = 'user-autocompleter'; -export interface UserAutocompleteItem { +export interface IUserAutocompleteItem { name:string; id:string|null; href:string|null; + avatar:string|null; } @Component({ @@ -61,7 +62,7 @@ export class UserAutocompleterComponent implements OnInit { @ViewChild(NgSelectComponent, { static: true }) public ngSelectComponent:NgSelectComponent; - @Output() public onChange = new EventEmitter(); + @Output() public onChange = new EventEmitter(); @Input() public clearAfterSelection = false; @@ -80,7 +81,7 @@ export class UserAutocompleterComponent implements OnInit { private updateInputField:HTMLInputElement|undefined; /** Keep a switchmap for search term and loading state */ - public requests = new DebouncedRequestSwitchmap( + public requests = new DebouncedRequestSwitchmap( (searchTerm:string) => this.getAvailableUsers(this.url, searchTerm), errorNotificationHandler(this.halNotification), ); @@ -157,7 +158,7 @@ export class UserAutocompleterComponent implements OnInit { } } - protected getAvailableUsers(url:string, searchTerm:any):Observable { + protected getAvailableUsers(url:string, searchTerm:any):Observable { // Need to clone the filters to not add additional filters on every // search term being processed. const searchFilters = this.inputFilters.clone(); diff --git a/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts b/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts index 0d065c1889..5f3605b32e 100644 --- a/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts +++ b/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts @@ -52,7 +52,7 @@ export abstract class OpContextMenuHandler extends UntilDestroyedMixin { /** * Open this context menu */ - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:JQuery.TriggeredEvent):void { this.opContextMenu.show(this, evt); } diff --git a/frontend/src/app/shared/components/principal/principal-helper.ts b/frontend/src/app/shared/components/principal/principal-helper.ts index 124e61822c..f0cf29f806 100644 --- a/frontend/src/app/shared/components/principal/principal-helper.ts +++ b/frontend/src/app/shared/components/principal/principal-helper.ts @@ -26,10 +26,31 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { PrincipalLike } from 'core-app/shared/components/principal/principal-types'; +import { Principal } from 'core-app/core/state/principals/principal.model'; +import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource'; + export namespace PrincipalHelper { export type PrincipalType = 'user'|'placeholder_user'|'group'; export type PrincipalPluralType = 'users'|'placeholder_users'|'groups'; + /* + * This function is a helper that wraps around the old HalResource based principal type and the new interface based one. + * + * TODO: Remove old HalResource stuff :P + */ + export function hrefFromPrincipal(p:Principal|PrincipalLike):string { + if ((p as PrincipalLike).href) { + return (p as PrincipalLike).href || ''; + } + + if ((p as Principal)._links) { + const self = (p as Principal)._links.self as HalSourceLink; + return self.href || ''; + } + + return ''; + } export function typeFromHref(href:string):PrincipalType|null { const match = /\/(user|group|placeholder_user)s\/\d+$/.exec(href); diff --git a/frontend/src/app/shared/components/principal/principal-renderer.service.ts b/frontend/src/app/shared/components/principal/principal-renderer.service.ts index 335720907c..91c8bf96c2 100644 --- a/frontend/src/app/shared/components/principal/principal-renderer.service.ts +++ b/frontend/src/app/shared/components/principal/principal-renderer.service.ts @@ -3,6 +3,7 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { ColorsService } from 'core-app/shared/components/colors/colors.service'; import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; +import { Principal } from 'core-app/core/state/principals/principal.model'; import { PrincipalLike } from './principal-types'; import { PrincipalHelper } from './principal-helper'; import PrincipalType = PrincipalHelper.PrincipalType; @@ -29,7 +30,7 @@ export class PrincipalRendererService { renderMultiple( container:HTMLElement, - users:PrincipalLike[], + users:PrincipalLike[]|Principal[], name:NameOptions = { hide: false, link: false }, avatar:AvatarOptions = { hide: false, size: 'default' }, multiLine = false, @@ -59,12 +60,12 @@ export class PrincipalRendererService { render( container:HTMLElement, - principal:PrincipalLike, + principal:PrincipalLike|Principal, name:NameOptions = { hide: false, link: true }, avatar:AvatarOptions = { hide: false, size: 'default' }, ):void { container.classList.add('op-principal'); - const type = PrincipalHelper.typeFromHref(principal.href || '') as PrincipalType; + const type = PrincipalHelper.typeFromHref(PrincipalHelper.hrefFromPrincipal(principal)) as PrincipalType; if (!avatar.hide) { const el = this.renderAvatar(principal, avatar, type); @@ -78,7 +79,7 @@ export class PrincipalRendererService { } private renderAvatar( - principal:PrincipalLike, + principal:PrincipalLike|Principal, options:AvatarOptions, type:PrincipalType, ) { @@ -86,6 +87,7 @@ export class PrincipalRendererService { const colorCode = this.colors.toHsl(principal.name); const fallback = document.createElement('div'); + fallback.classList.add('op-principal--avatar'); fallback.classList.add('op-avatar'); fallback.classList.add(`op-avatar_${options.size}`); fallback.classList.add(`op-avatar_${type.replace('_', '-')}`); @@ -108,7 +110,7 @@ export class PrincipalRendererService { return fallback; } - private renderUserAvatar(principal:PrincipalLike, fallback:HTMLElement, options:AvatarOptions):void { + private renderUserAvatar(principal:PrincipalLike|Principal, fallback:HTMLElement, options:AvatarOptions):void { const url = this.userAvatarUrl(principal); if (!url) { @@ -128,12 +130,12 @@ export class PrincipalRendererService { }; } - private userAvatarUrl(principal:PrincipalLike):string|null { - const id = principal.id || idFromLink(principal.href || ''); + private userAvatarUrl(principal:PrincipalLike|Principal):string|null { + const id = principal.id || idFromLink(PrincipalHelper.hrefFromPrincipal(principal)); return id ? this.apiV3Service.users.id(id).avatar.toString() : null; } - private renderName(principal:PrincipalLike, type:PrincipalType, asLink = true) { + private renderName(principal:PrincipalLike|Principal, type:PrincipalType, asLink = true) { if (asLink) { const link = document.createElement('a'); link.textContent = principal.name; @@ -150,8 +152,9 @@ export class PrincipalRendererService { return span; } - private principalURL(principal:PrincipalLike, type:PrincipalType):string { - const id = principal.id || (principal.href ? idFromLink(principal.href) : ''); + private principalURL(principal:PrincipalLike|Principal, type:PrincipalType):string { + const href = PrincipalHelper.hrefFromPrincipal(principal); + const id = principal.id || (href ? idFromLink(href) : ''); switch (type) { case 'group': diff --git a/frontend/src/assets/sass/_helpers.sass b/frontend/src/assets/sass/_helpers.sass index 0be97a8b3e..fad5fa09b5 100644 --- a/frontend/src/assets/sass/_helpers.sass +++ b/frontend/src/assets/sass/_helpers.sass @@ -4,4 +4,5 @@ * importing these helpers! */ @import "~global_styles/openproject/_mixins" +@import "~global_styles/openproject/_variables" @import "~global_styles/content/drag_and_drop" diff --git a/frontend/src/global_styles/content/_principal.sass b/frontend/src/global_styles/content/_principal.sass index 9454fb2258..f65c99f0bb 100644 --- a/frontend/src/global_styles/content/_principal.sass +++ b/frontend/src/global_styles/content/_principal.sass @@ -1,4 +1,17 @@ .op-principal + display: inline-flex + align-items: center + + &--avatar + flex-grow: 0 + flex-shrink: 0 + + &--name + @include text-shortener + flex-grow: 1 + flex-shrink: 1 + min-width: 0 // See: https://css-tricks.com/flexbox-truncated-text/ + &--multi-line display: block margin-bottom: 2px diff --git a/frontend/src/global_styles/content/modules/_team_planner.sass b/frontend/src/global_styles/content/modules/_team_planner.sass index 6eea31f8f1..096967199c 100644 --- a/frontend/src/global_styles/content/modules/_team_planner.sass +++ b/frontend/src/global_styles/content/modules/_team_planner.sass @@ -1,3 +1,5 @@ +@import "~app/features/team-planner/team-planner/planner/tp-assignee" + .router--team-planner #content height: 100% diff --git a/frontend/src/global_styles/openproject/_mixins.sass b/frontend/src/global_styles/openproject/_mixins.sass index 22c75b9980..e0ce0b5a14 100644 --- a/frontend/src/global_styles/openproject/_mixins.sass +++ b/frontend/src/global_styles/openproject/_mixins.sass @@ -273,3 +273,4 @@ $scrollbar-size: 10px &:focus right: 0 + diff --git a/frontend/src/global_styles/openproject/_variables.sass b/frontend/src/global_styles/openproject/_variables.sass new file mode 100644 index 0000000000..b870aca162 --- /dev/null +++ b/frontend/src/global_styles/openproject/_variables.sass @@ -0,0 +1,2 @@ +// A selector that checks whether we are running in a test environment +$spec-active-selector: '.env-test' diff --git a/modules/bim/app/views/bim/ifc_models/ifc_models/_panels.html.erb b/modules/bim/app/views/bim/ifc_models/ifc_models/_panels.html.erb index 62e64b4e9d..c01703a2c7 100644 --- a/modules/bim/app/views/bim/ifc_models/ifc_models/_panels.html.erb +++ b/modules/bim/app/views/bim/ifc_models/ifc_models/_panels.html.erb @@ -4,7 +4,7 @@ inputs: { projectId: (@project ? @project.id.to_s : ''), menuItems: [parent_name, name], - baseRoute: 'bim.partitioned.split', + baseRoute: 'bim.partitioned.list', viewType: 'Bim', } %> diff --git a/modules/bim/spec/features/bcf/create_spec.rb b/modules/bim/spec/features/bcf/create_spec.rb index 4be1a7611d..864eb87384 100644 --- a/modules/bim/spec/features/bcf/create_spec.rb +++ b/modules/bim/spec/features/bcf/create_spec.rb @@ -38,7 +38,6 @@ describe 'Create BCF', shared_examples 'bcf details creation' do |with_viewpoints| it "can create a new #{with_viewpoints ? 'bcf' : 'plain'} work package" do create_page = index_page.create_wp_by_button(type) - create_page.view_route = view_route create_page.expect_current_path @@ -74,8 +73,6 @@ describe 'Create BCF', create_page.save! - sleep 5 - index_page.expect_and_dismiss_toaster( message: 'Successful creation. Click here to open this work package in fullscreen view.' ) @@ -97,7 +94,7 @@ describe 'Create BCF', expect(work_package.bcf_issue.viewpoints.count).to eq 2 end - expect(page).to have_current_path /bcf\/#{Regexp.escape(view_route)}$/, ignore_query: true + expect(page).to have_current_path /bcf$/, ignore_query: true end end @@ -106,45 +103,50 @@ describe 'Create BCF', end context 'with all permissions' do - context 'on the split page' do - let(:view_route) { 'split' } + context 'when on default view' do + before do + index_page.visit_and_wait_until_finished_loading! + end + + it_behaves_like 'bcf details creation', true + end + + context 'when going to split table view first' do before do - index_page.visit! - index_page.finished_loading + index_page.visit_and_wait_until_finished_loading! + + index_page.switch_view 'Viewer and table' end it_behaves_like 'bcf details creation', true end - context 'on the split page switching to list' do - let(:view_route) { 'list' } + context 'when going to cards view first' do before do - index_page.visit! - index_page.finished_loading + index_page.visit_and_wait_until_finished_loading! index_page.switch_view 'Cards' - expect(page).to have_current_path /\/bcf\/list$/, ignore_query: true end it_behaves_like 'bcf details creation', false end - context 'starting on the list page' do - let(:view_route) { 'list' } + context 'when going to table view first' do before do - visit bcf_project_frontend_path(project, "list") - expect(page).to have_current_path /\/bcf\/list$/, ignore_query: true + index_page.visit_and_wait_until_finished_loading! + + index_page.switch_view 'Table' end it_behaves_like 'bcf details creation', false end - context 'starting on the details page of an existing work package' do + context 'when starting on the details page of an existing work package' do let(:work_package) { FactoryBot.create :work_package, project: project } - let(:view_route) { 'split' } + before do - visit bcf_project_frontend_path(project, "split/details/#{work_package.id}") - expect(page).to have_current_path /\/bcf\/split\/details/, ignore_query: true + visit bcf_project_frontend_path(project, "details/#{work_package.id}") + index_page.expect_details_path end it_behaves_like 'bcf details creation', true @@ -155,8 +157,7 @@ describe 'Create BCF', let(:permissions) { %i[view_ifc_models manage_bcf view_work_packages] } it 'has the create button disabled' do - index_page.visit! - index_page.finished_loading + index_page.visit_and_wait_until_finished_loading! index_page.expect_wp_create_button_disabled end @@ -165,11 +166,9 @@ describe 'Create BCF', context 'with add_work_packages but without manage_bcf permission' do let(:permissions) { %i[view_ifc_models view_work_packages add_work_packages] } - context 'on the split page' do - let(:view_route) { 'split' } + context 'when on default view' do before do - index_page.visit! - index_page.finished_loading + index_page.visit_and_wait_until_finished_loading! end it_behaves_like 'bcf details creation', false diff --git a/modules/bim/spec/features/model_viewer_spec.rb b/modules/bim/spec/features/model_viewer_spec.rb index 53b831e473..9835f2ed5e 100644 --- a/modules/bim/spec/features/model_viewer_spec.rb +++ b/modules/bim/spec/features/model_viewer_spec.rb @@ -103,7 +103,7 @@ describe 'model viewer', end context 'with only viewing permissions' do - let(:view_role) { FactoryBot.create(:role, permissions: %i[view_ifc_models]) } + let(:view_role) { FactoryBot.create(:role, permissions: %i[view_ifc_models view_work_packages view_linked_issues]) } let(:view_user) do FactoryBot.create :user, member_in_project: project, diff --git a/modules/bim/spec/features/revit_add_in/bim_revit_add_in_navigation_spec.rb b/modules/bim/spec/features/revit_add_in/bim_revit_add_in_navigation_spec.rb index 17efe27120..f8ab347110 100644 --- a/modules/bim/spec/features/revit_add_in/bim_revit_add_in_navigation_spec.rb +++ b/modules/bim/spec/features/revit_add_in/bim_revit_add_in_navigation_spec.rb @@ -85,14 +85,14 @@ describe 'BIM Revit Add-in navigation spec', find('.menu-item', text: 'NONE', wait: 10).click full_create.edit_field(:subject).expect_active! - expect(page).to have_selector('.work-packages-partitioned-page--content-right', visible: false) + expect(page).to have_selector('.work-packages-partitioned-page--content-right', visible: :all) end it 'shows work package details page in full view on Cards display mode' do model_page.click_info_icon(work_package) expect(page).to have_selector('.work-packages-partitioned-page--content-left', text: work_package.subject) - expect(page).to have_selector('.work-packages-partitioned-page--content-right', visible: false) + expect(page).to have_selector('.work-packages-partitioned-page--content-right', visible: :all) end context 'with the table display mode' do diff --git a/modules/bim/spec/features/viewer/show_viewpoint_spec.rb b/modules/bim/spec/features/viewer/show_viewpoint_spec.rb index 37c58ffb4a..2d0be4dff5 100644 --- a/modules/bim/spec/features/viewer/show_viewpoint_spec.rb +++ b/modules/bim/spec/features/viewer/show_viewpoint_spec.rb @@ -117,7 +117,7 @@ describe 'Show viewpoint in model viewer', bcf_details.expect_viewpoint_count(1) bcf_details.show_current_viewpoint - path = Regexp.escape("bcf/split/details/#{work_package.id}/overview") + path = Regexp.escape("bcf/details/#{work_package.id}/overview") expect(page).to have_current_path /#{path}/ expect(page).to have_current_path /#{project.id}/ diff --git a/modules/bim/spec/support/pages/bcf/create_split.rb b/modules/bim/spec/support/pages/bcf/create_split.rb index 2c4bb3749b..3e9cf01ddb 100644 --- a/modules/bim/spec/support/pages/bcf/create_split.rb +++ b/modules/bim/spec/support/pages/bcf/create_split.rb @@ -37,14 +37,12 @@ module Pages attr_accessor :project, :model_id, - :type_id, - :view_route + :type_id def initialize(project:, model_id: nil, type_id: nil) super(project: project) self.model_id = model_id self.type_id = type_id - self.view_route = :split end # Override delete viewpoint since we don't have confirm alert @@ -53,7 +51,7 @@ module Pages end def path - bcf_project_frontend_path(project, "#{view_route}/create_new") + bcf_project_frontend_path(project, "create_new") end def expect_current_path diff --git a/modules/bim/spec/support/pages/ifc_models/bcf_details_page.rb b/modules/bim/spec/support/pages/ifc_models/bcf_details_page.rb index c2293029e5..b7ce9521f7 100644 --- a/modules/bim/spec/support/pages/ifc_models/bcf_details_page.rb +++ b/modules/bim/spec/support/pages/ifc_models/bcf_details_page.rb @@ -36,7 +36,7 @@ module Pages protected def path(tab = 'overview') - bcf_project_frontend_path project, "split/details/#{work_package.id}/#{tab}" + bcf_project_frontend_path project, "details/#{work_package.id}/#{tab}" end end end diff --git a/modules/bim/spec/support/pages/ifc_models/show_default.rb b/modules/bim/spec/support/pages/ifc_models/show_default.rb index ceb50f60a5..879245dc75 100644 --- a/modules/bim/spec/support/pages/ifc_models/show_default.rb +++ b/modules/bim/spec/support/pages/ifc_models/show_default.rb @@ -49,8 +49,12 @@ module Pages finished_loading end + def expect_details_path + expect(page).to have_current_path /\/bcf\/details/, ignore_query: true + end + def finished_loading - expect(page).to have_selector('.xeokit-busy-modal', visible: false, wait: 30) + expect(page).to have_selector('.xeokit-busy-modal', visible: :all, wait: 30) end def model_viewer_visible(visible) @@ -98,14 +102,16 @@ module Pages end def switch_view(value) - page.find('#bim-view-toggle-button').click - within('#bim-view-context-menu') do - page.find('.menu-item', text: value, exact_text: true).click + retry_block do + page.find('#bcf-view-toggle-button').click + within('#bcf-view-context-menu') do + page.find('.menu-item', text: value, exact_text: true).click + end end end def expect_view_toggle_at(value) - expect(page).to have_selector('#bim-view-toggle-button', text: value) + expect(page).to have_selector('#bcf-view-toggle-button', text: value) end def has_no_menu_item_with_text?(value) diff --git a/modules/team_planner/config/locales/js-en.yml b/modules/team_planner/config/locales/js-en.yml index d440e05c9f..3e5d3a8e71 100644 --- a/modules/team_planner/config/locales/js-en.yml +++ b/modules/team_planner/config/locales/js-en.yml @@ -6,3 +6,5 @@ en: title: 'Team planner' unsaved_title: 'Unnamed team planner' label_assignee_plural: 'Assignees' + add_assignee: 'Add assignee' + remove_assignee: 'Remove assignee' diff --git a/modules/team_planner/spec/features/team_planner_spec.rb b/modules/team_planner/spec/features/team_planner_spec.rb index e4585eeb20..ffa98ff779 100644 --- a/modules/team_planner/spec/features/team_planner_spec.rb +++ b/modules/team_planner/spec/features/team_planner_spec.rb @@ -73,7 +73,16 @@ describe 'Team planner', type: :feature, js: true do end context 'with an assigned work package' do - let!(:other_user) { FactoryBot.create :user, firstname: 'Other', lastname: 'User' } + let!(:other_user) do + FactoryBot.create :user, + firstname: 'Other', + lastname: 'User', + member_in_project: project, + member_with_permissions: %w[ + view_work_packages edit_work_packages view_team_planner manage_team_planner + ] + end + let!(:user_outside_project) { FactoryBot.create :user, firstname: 'Not', lastname: 'In Project' } let(:type_task) { FactoryBot.create :type_task } let(:type_bug) { FactoryBot.create :type_bug } @@ -115,6 +124,21 @@ describe 'Team planner', type: :feature, js: true do team_planner.title + team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(other_user, present: false) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add user.name + end + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add other_user.name + end + team_planner.expect_assignee user team_planner.expect_assignee other_user @@ -135,7 +159,8 @@ describe 'Team planner', type: :feature, js: true do filters.expect_filter_by('Type', 'is', [type_task.name]) filters.expect_filter_count("2") - team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(user, present: true) + team_planner.expect_assignee(other_user, present: true) team_planner.within_lane(other_user) do team_planner.expect_event other_task @@ -147,8 +172,79 @@ describe 'Team planner', type: :feature, js: true do split_view.edit_field(:type).update(type_bug) split_view.expect_and_dismiss_toaster(message: "Successful update.") + team_planner.expect_assignee(user, present: true) + team_planner.expect_assignee(other_user, present: true) + end + + it 'can add and remove assignees' do + team_planner.visit! + team_planner.expect_assignee(user, present: false) team_planner.expect_assignee(other_user, present: false) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add user.name + end + + team_planner.expect_assignee(user) + team_planner.expect_assignee(other_user, present: false) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add other_user.name + end + + team_planner.expect_assignee(user) + team_planner.expect_assignee(other_user) + + team_planner.remove_assignee(user) + + team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(other_user) + + team_planner.remove_assignee(other_user) + + team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(other_user, present: false) + + # Try one more time to make sure deleting the full filter didn't kill the functionality + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add user.name + end + + team_planner.expect_assignee(user) + team_planner.expect_assignee(other_user, present: false) + end + + it 'filters possible assignees correctly' do + team_planner.visit! + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.search_user_to_add user_outside_project.name + end + + expect(page).to have_selector('.ng-option-disabled', text: "No items found") + + retry_block do + team_planner.select_user_to_add user.name + end + + team_planner.expect_assignee(user) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.search_user_to_add user.name + end + + expect(page).to have_selector('.ng-option-disabled', text: "No items found") end end end diff --git a/modules/team_planner/spec/support/pages/team_planner.rb b/modules/team_planner/spec/support/pages/team_planner.rb index 0f3b5546e1..7bcb34e500 100644 --- a/modules/team_planner/spec/support/pages/team_planner.rb +++ b/modules/team_planner/spec/support/pages/team_planner.rb @@ -30,6 +30,8 @@ require 'support/pages/page' module Pages class TeamPlanner < ::Pages::Page + include ::Components::NgSelectAutocompleteHelpers + attr_reader :project, :filters @@ -53,6 +55,10 @@ module Pages expect(page).to have_conditional_selector(present, '.fc-resource', text: name, wait: 10) end + def remove_assignee(user) + page.find(%([data-qa-remove-assignee="#{user.id}"])).click + end + def within_lane(user, &block) raise ArgumentError.new("Expected instance of principal") unless user.is_a?(Principal) @@ -73,5 +79,21 @@ module Pages ::Pages::SplitWorkPackage.new(work_package, project) end + + def click_add_user + page.find('[data-qa-selector="tp-assignee-add-button"]').click + end + + def select_user_to_add(name) + select_autocomplete page.find('[data-qa-selector="tp-add-assignee"]'), + query: name, + results_selector: 'body' + end + + def search_user_to_add(name) + search_autocomplete page.find('[data-qa-selector="tp-add-assignee"]'), + query: name, + results_selector: 'body' + end end end