Merge remote-tracking branch 'origin/dev' into feature/40014/team-planner-sidemenu

# Conflicts:
#	modules/bim/app/views/bim/ifc_models/ifc_models/_panels.html.erb
pull/9957/head
Henriette Darge 3 years ago
commit e24e3f1ba4
  1. 2
      app/helpers/application_helper.rb
  2. 1
      docker-compose.yml
  3. 4
      frontend/src/app/core/path-helper/path-helper.service.ts
  4. 3
      frontend/src/app/core/state/collection-store.ts
  5. 2
      frontend/src/app/core/state/openproject-state.module.ts
  6. 13
      frontend/src/app/core/state/principals/group.model.ts
  7. 17
      frontend/src/app/core/state/principals/placeholder-user.model.ts
  8. 5
      frontend/src/app/core/state/principals/principal.model.ts
  9. 10
      frontend/src/app/core/state/principals/principals.query.ts
  10. 110
      frontend/src/app/core/state/principals/principals.service.ts
  11. 13
      frontend/src/app/core/state/principals/principals.store.ts
  12. 31
      frontend/src/app/core/state/principals/user.model.ts
  13. 14
      frontend/src/app/features/bim/bcf/bcf-viewer-bridge/viewer-bridge.service.ts
  14. 71
      frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts
  15. 37
      frontend/src/app/features/bim/ifc_models/bcf/list-container/bcf-list-container.component.html
  16. 38
      frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.html
  17. 2
      frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.sass
  18. 96
      frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts
  19. 24
      frontend/src/app/features/bim/ifc_models/bcf/new-split/bcf-new-split.component.html
  20. 7
      frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.html
  21. 32
      frontend/src/app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component.ts
  22. 4
      frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.html
  23. 54
      frontend/src/app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component.ts
  24. 7
      frontend/src/app/features/bim/ifc_models/empty/empty-component.ts
  25. 46
      frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts
  26. 22
      frontend/src/app/features/bim/ifc_models/openproject-ifc-models.module.ts
  27. 74
      frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts
  28. 98
      frontend/src/app/features/bim/ifc_models/pages/viewer/bcf-view.service.ts
  29. 112
      frontend/src/app/features/bim/ifc_models/pages/viewer/bim-view.service.ts
  30. 140
      frontend/src/app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component.ts
  31. 2
      frontend/src/app/features/bim/ifc_models/toolbar/manage-ifc-models-button/bim-manage-ifc-models-button.component.ts
  32. 20
      frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-button.component.ts
  33. 74
      frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts
  34. 2
      frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts
  35. 8
      frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html
  36. 96
      frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts
  37. 43
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html
  38. 3
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass
  39. 257
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts
  40. 30
      frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass
  41. 5
      frontend/src/app/features/team-planner/team-planner/team-planner.module.ts
  42. 3
      frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass
  43. 8
      frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts
  44. 2
      frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts
  45. 6
      frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts
  46. 9
      frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts
  47. 2
      frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts
  48. 21
      frontend/src/app/shared/components/principal/principal-helper.ts
  49. 23
      frontend/src/app/shared/components/principal/principal-renderer.service.ts
  50. 1
      frontend/src/assets/sass/_helpers.sass
  51. 13
      frontend/src/global_styles/content/_principal.sass
  52. 2
      frontend/src/global_styles/content/modules/_team_planner.sass
  53. 1
      frontend/src/global_styles/openproject/_mixins.sass
  54. 2
      frontend/src/global_styles/openproject/_variables.sass
  55. 2
      modules/bim/app/views/bim/ifc_models/ifc_models/_panels.html.erb
  56. 53
      modules/bim/spec/features/bcf/create_spec.rb
  57. 2
      modules/bim/spec/features/model_viewer_spec.rb
  58. 4
      modules/bim/spec/features/revit_add_in/bim_revit_add_in_navigation_spec.rb
  59. 2
      modules/bim/spec/features/viewer/show_viewpoint_spec.rb
  60. 6
      modules/bim/spec/support/pages/bcf/create_split.rb
  61. 2
      modules/bim/spec/support/pages/ifc_models/bcf_details_page.rb
  62. 16
      modules/bim/spec/support/pages/ifc_models/show_default.rb
  63. 2
      modules/team_planner/config/locales/js-en.yml
  64. 100
      modules/team_planner/spec/features/team_planner_spec.rb
  65. 22
      modules/team_planner/spec/support/pages/team_planner.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

@ -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:

@ -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;

@ -93,7 +93,8 @@ export function selectEntitiesFromIDCollection<T extends CollectionItem>(service
* @param state
* @param params
*/
export function selectCollectionAsEntities$<T extends CollectionItem>(service:CollectionService<T>, state:CollectionState<T>, params:Apiv3ListParameters):T[] {
export function selectCollectionAsEntities$<T extends CollectionItem>(service:CollectionService<T>, params:Apiv3ListParameters):T[] {
const state = service.query.getValue();
const key = collectionKey(params);
const collection = state.collections[key];

@ -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 {

@ -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;
}

@ -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;
}

@ -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;

@ -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<PrincipalsState> {
public byIds(ids:string[]):Observable<Principal[]> {
return this.selectMany(ids);
}
}

@ -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<IHALCollection<Principal>> {
const collectionURL = collectionKey(params);
return this
.http
.get<IHALCollection<Principal>>(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<Principal>):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)),
},
},
}
));
}
}

@ -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<Principal> {
}
@StoreConfig({ name: 'principals' })
export class PrincipalsStore extends EntityStore<PrincipalsState> {
constructor() {
super(createInitialCollectionState());
}
}

@ -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;
}

@ -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;

@ -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 });
}
}

@ -1,37 +0,0 @@
<div class="op-bcf-list-container--result-overlay"
*ngIf="(showResultOverlay$ | async) && showTableView"></div>
<!-- TABLE + TIMELINE horizontal split -->
<div class="op-bcf-list-container--work-packages-split-view--tabletimeline-container"
[ngClass]="{ '_with-resizer': showResizerInCardView() }"
*ngIf="tableInformationLoaded && showTableView">
<wp-resizer elementClass="work-packages-partitioned-page--content-right"
localStorageKey="openProject-splitViewFlexBasis">
</wp-resizer>
<wp-table [projectIdentifier]="CurrentProject.identifier"
[configuration]="wpTableConfiguration"
(itemClicked)="handleWorkPackageClicked($event)"
(stateLinkClicked)="openStateLink($event)"
class="work-packages-split-view--tabletimeline-content">
</wp-table>
</div>
<!-- GRID representation of the WP -->
<div *ngIf="!showTableView"
class="op-bcf-list-container--work-packages--card-view-container"
[ngClass]="{ '_with-resizer': showResizerInCardView() }" >
<wp-grid [configuration]="wpTableConfiguration"
[showResizer]="showResizerInCardView()"
(itemClicked)="handleWorkPackageCardClicked($event)"
(stateLinkClicked)="openStateLink($event)"
resizerClass="work-packages-partitioned-page--content-right"
resizerStorageKey="openProject-splitViewFlexBasis">
</wp-grid>
</div>
<!-- Footer -->
<div class="work-packages-split-view--tabletimeline-footer hide-when-print"
*ngIf="tableInformationLoaded">
<wp-table-pagination></wp-table-pagination>
</div>

@ -0,0 +1,38 @@
<div class="op-bcf-list--result-overlay"
*ngIf="(showResultOverlay$ | async) && showTableView"></div>
<!-- TABLE representation of the WP -->
<div class="op-bcf-list--work-packages-split-view--tabletimeline-container"
[ngClass]="{ '_with-resizer': showResizerInCardView() }"
*ngIf="tableInformationLoaded && showTableView">
<wp-resizer *ngIf="showResizer"
elementClass="work-packages-partitioned-page--content-right"
localStorageKey="openProject-splitViewFlexBasis">
</wp-resizer>
<wp-table [projectIdentifier]="CurrentProject.identifier"
[configuration]="wpTableConfiguration"
(itemClicked)="handleWorkPackageClicked($event)"
(stateLinkClicked)="openStateLink($event)"
class="work-packages-split-view--tabletimeline-content">
</wp-table>
</div>
<!-- GRID representation of the WP -->
<div *ngIf="!showTableView"
class="op-bcf-list--work-packages--card-view-container"
[ngClass]="{ '_with-resizer': showResizerInCardView() }">
<wp-grid [configuration]="wpTableConfiguration"
[showResizer]="showResizerInCardView()"
(itemClicked)="handleWorkPackageCardClicked($event)"
(stateLinkClicked)="openStateLink($event)"
resizerClass="work-packages-partitioned-page--content-right"
resizerStorageKey="openProject-splitViewFlexBasis">
</wp-grid>
</div>
<!-- Footer -->
<div class="work-packages-split-view--tabletimeline-footer hide-when-print"
*ngIf="tableInformationLoaded">
<wp-table-pagination></wp-table-pagination>
</div>

@ -1,6 +1,6 @@
@import "src/assets/sass/helpers"
.op-bcf-list-container
.op-bcf-list
&--result-overlay
@include overlay-background
background-color: #FFFFFF

@ -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);
}
}

@ -1,24 +0,0 @@
<div
class="work-packages--details work-packages--new"
*ngIf="newWorkPackage"
>
<edit-form [resource]="newWorkPackage"
[skippedFields]="['status', 'type']"
[inEditMode]="true"
(onSaved)="onSaved($event)">
<div class="work-packages--details-content -create-mode">
<div class="work-packages--new-details-header">
<wp-type-status [workPackage]="newWorkPackage"></wp-type-status>
</div>
<wp-single-view [workPackage]="newWorkPackage"
[showProject]="copying">
</wp-single-view>
</div>
<div class="work-packages--details-toolbar-container">
<wp-edit-actions-bar
(onCancel)="cancelAndBackToList()">
</wp-edit-actions-bar>
</div>
</edit-form>
</div>

@ -0,0 +1,7 @@
<op-ifc-viewer
*ngIf="showViewer$ | async"
></op-ifc-viewer>
<op-bcf-list
*ngIf="(showViewer$ | async) === false"
></op-bcf-list>

@ -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<boolean>;
@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'),
);
}
}

@ -0,0 +1,4 @@
<op-bcf-list
[showResizer]="true"
*ngIf="showWorkPackages$ | async"
></op-bcf-list>

@ -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<boolean>;
constructor(private readonly bcfView:BcfViewService) {}
ngOnInit():void {
this.showWorkPackages$ = this.bcfView.live$()
.pipe(
map((state) => state === 'splitTable' || state === 'splitCards'),
);
}
}

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
template: '<div></div>',
})
export class EmptyComponent {
}

@ -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<boolean>(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,
);
}

@ -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,
],
})

@ -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,
),
];

@ -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<BcfViewState> {
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;
}
}

@ -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<BimViewState>();
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<BimViewState> {
return this._state.values$();
}
public observeUntil(unsubscribe:Observable<any>) {
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();
}
}

@ -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<string | undefined>(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<QueryResource> {
return super.loadQuery(firstPage)
.then((query) => {
this.bcfView.initialize(query, query.results);
return query;
});
}
}

@ -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 = {

@ -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: `
<ng-container *ngIf="(view$ | async) as current">
<button class="button"
id="bim-view-toggle-button"
bimViewDropdown>
<op-icon icon-classes="button--icon {{bimView.icon[current]}}"></op-icon>
id="bcf-view-toggle-button"
opBcfViewDropdown>
<op-icon icon-classes="button--icon {{bcfView.icon[current]}}"></op-icon>
<span class="button--text"
aria-hidden="true"
[textContent]="bimView.text[current]">
[textContent]="bcfView.text[current]">
</span>
<op-icon icon-classes="button--icon icon-small icon-pulldown"></op-icon>
</button>
</ng-container>
`,
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) { }
}

@ -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;

@ -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);

@ -0,0 +1,8 @@
<op-autocompleter
data-qa-selector="tp-add-assignee"
(change)="selectUser($event)"
[fetchDataDirectly]="true"
[getOptionsFn]="getOptionsFn"
resource="user"
appendTo="body"
></op-autocompleter>

@ -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<HalResource>();
@Input() alreadySelected:string[] = [];
public getOptionsFn = (query:string):Observable<unknown[]> => 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<HalResource[]> {
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);
}
}

@ -11,8 +11,45 @@
[textContent]="calendar.tooManyResultsText"
class="op-wp-calendar--notification"></div>
</ng-container>
<ng-template #resourceContent let-resource="resource">
<op-principal
[principal]="resource.extendedProps.user"
></op-principal>
<div
*ngIf="resource.extendedProps.principal"
class="tp-assignee"
>
<op-principal
[principal]="resource.extendedProps.principal"
class="tp-assignee--principal"
></op-principal>
<button
type="button"
class="tp-assignee--remove"
(click)="removeAssignee(resource.id)"
[aria-label]="text.remove_assignee"
[attr.data-qa-remove-assignee]="resource.extendedProps.principal.id"
>
<op-icon icon-classes="icon-remove"></op-icon>
</button>
</div>
<op-tp-add-assignee
*ngIf="!resource.extendedProps.principal"
(selectAssignee)="addAssignee($event)"
[alreadySelected]="principalIds$ | async"
></op-tp-add-assignee>
</ng-template>
<div
class="wp-inline-create-button"
*ngIf="!(showAddAssignee$ | async)"
>
<button
type="button"
class="wp-inline-create--add-link tp-assignee-add-button"
(click)="showAssigneeAddRow()"
data-qa-selector="tp-assignee-add-button"
>
<op-icon icon-classes="icon-context icon-add"></op-icon>
<span [textContent]="text.add_assignee"></span>
</button>
</div>

@ -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

@ -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<unknown>;
@ViewChild('assigneeAutocompleter') assigneeAutocompleter:TemplateRef<unknown>;
private resizeSubject = new Subject<unknown>();
calendarOptions$ = new Subject<CalendarOptions>();
projectIdentifier:string|undefined = undefined;
showAddAssignee$ = new Subject<boolean>();
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<EventInput[]> {
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<EventInput[]> {
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<EventInput[]> {
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<unknown>, 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;
}
}

@ -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

@ -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 {}

@ -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

@ -228,19 +228,21 @@ export class PartitionedQuerySpacePageComponent extends WorkPackagesViewBase imp
}
}
refresh(visibly = false, firstPage = false):Promise<unknown> {
let promise = this.loadQuery(firstPage) as Promise<unknown>;
refresh(visibly = false, firstPage = false):Promise<QueryResource> {
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;
});
}

@ -19,7 +19,7 @@ export class WpSingleViewQuery extends Query<WpSingleViewState> {
this.resourceService.query.select(),
]).pipe(
filter((filters) => filters.length > 0),
map(([filters, state]) => selectCollectionAsEntities$<InAppNotification>(this.resourceService, state, { filters })),
map(([filters]) => selectCollectionAsEntities$<InAppNotification>(this.resourceService, { filters })),
);
selectNotificationsCount$ = this

@ -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<UserAutocompleteItem[]> {
protected getAvailableUsers(url:string, searchTerm:any):Observable<IUserAutocompleteItem[]> {
return this.http
.get<UserAutocompleteItem[]>(url,
.get<IUserAutocompleteItem[]>(url,
{
params: new HttpParams({ encoder: new URLParamsEncoder(), fromObject: { q: searchTerm } }),
responseType: 'json',

@ -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<void>();
@Output() public onChange = new EventEmitter<IUserAutocompleteItem>();
@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<string, UserAutocompleteItem>(
public requests = new DebouncedRequestSwitchmap<string, IUserAutocompleteItem>(
(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<UserAutocompleteItem[]> {
protected getAvailableUsers(url:string, searchTerm:any):Observable<IUserAutocompleteItem[]> {
// Need to clone the filters to not add additional filters on every
// search term being processed.
const searchFilters = this.inputFilters.clone();

@ -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);
}

@ -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);

@ -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':

@ -4,4 +4,5 @@
* importing these helpers!
*/
@import "~global_styles/openproject/_mixins"
@import "~global_styles/openproject/_variables"
@import "~global_styles/content/drag_and_drop"

@ -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

@ -1,3 +1,5 @@
@import "~app/features/team-planner/team-planner/planner/tp-assignee"
.router--team-planner
#content
height: 100%

@ -273,3 +273,4 @@ $scrollbar-size: 10px
&:focus
right: 0

@ -0,0 +1,2 @@
// A selector that checks whether we are running in a test environment
$spec-active-selector: '.env-test'

@ -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',
}
%>

@ -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

@ -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,

@ -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

@ -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}/

@ -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

@ -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

@ -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)

@ -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'

@ -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

@ -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

Loading…
Cancel
Save