Merge pull request #8073 from opf/bim/feature/31979-Allow-filtering-WPs-within-IFC-Models-module

[31979] Allow filtering WPs within IFC Models module

[ci skip]
pull/8086/head
Oliver Günther 5 years ago committed by GitHub
commit 041dc3fbdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 74
      frontend/src/app/components/wp-query/query-param-listener.service.ts
  2. 61
      frontend/src/app/modules/ifc_models/bcf/container/bcf-container.component.ts
  3. 8
      frontend/src/app/modules/ifc_models/openproject-ifc-models.routes.ts
  4. 10
      frontend/src/app/modules/ifc_models/pages/viewer/ifc-viewer-page.component.html
  5. 11
      frontend/src/app/modules/ifc_models/pages/viewer/ifc-viewer-page.component.sass
  6. 23
      frontend/src/app/modules/ifc_models/pages/viewer/ifc-viewer-page.component.ts
  7. 17
      frontend/src/app/modules/ifc_models/view-toggle/bim-view-toggle-dropdown.directive.ts
  8. 4
      frontend/src/app/modules/ifc_models/view-toggle/bim-view-toggle.component.ts
  9. 12
      frontend/src/app/modules/ifc_models/view-toggle/bim-view.service.ts
  10. 43
      frontend/src/app/modules/work_packages/routing/wp-list/wp-list.component.ts
  11. 133
      modules/bim/spec/features/bim_filter_spec.rb
  12. 14
      modules/bim/spec/support/pages/ifc_models/show_default.rb

@ -0,0 +1,74 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 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 docs/COPYRIGHT.rdoc for more details.
// ++
import {Injectable, Injector} from '@angular/core';
import {WorkPackagesListChecksumService} from "core-components/wp-list/wp-list-checksum.service";
import {WorkPackagesListService} from "core-components/wp-list/wp-list.service";
import {TransitionService} from "@uirouter/core";
import {Subject} from "rxjs";
@Injectable()
export class QueryParamListenerService {
readonly wpListChecksumService:WorkPackagesListChecksumService = this.injector.get(WorkPackagesListChecksumService);
readonly wpListService:WorkPackagesListService = this.injector.get(WorkPackagesListService);
readonly $transitions:TransitionService = this.injector.get(TransitionService);
public observe$ = new Subject<any>();
public queryChangeListener:Function;
constructor(readonly injector:Injector) {
this.listenForQueryParamsChanged();
}
public listenForQueryParamsChanged():any {
// Listen for param changes
return this.queryChangeListener = this.$transitions.onSuccess({}, (transition):any => {
let options = transition.options();
const params = transition.params('to');
let newChecksum = this.wpListService.getCurrentQueryProps(params);
let newId:string = params.query_id ? params.query_id.toString() : null;
// Avoid performing any changes when we're going to reload
if (options.reload || (options.custom && options.custom.notify === false)) {
return true;
}
return this.wpListChecksumService
.executeIfOutdated(newId,
newChecksum,
() => {
this.observe$.next(newChecksum);
});
});
}
public removeQueryChangeListener() {
this.queryChangeListener();
}
}

@ -1,51 +1,84 @@
import {Component} from "@angular/core";
import {ChangeDetectorRef, Component, Injector, OnDestroy, OnInit} from "@angular/core";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {WorkPackageTableConfigurationObject} from "core-components/wp-table/wp-table-configuration";
import { StateService } from '@uirouter/core';
import {StateService} from '@uirouter/core';
import {GonService} from "core-app/modules/common/gon/gon.service";
import {QueryParamListenerService} from "core-components/wp-query/query-param-listener.service";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {WorkPackagesListService} from "core-components/wp-list/wp-list.service";
import {UrlParamsHelperService} from "core-components/wp-query/url-params-helper";
import {untilComponentDestroyed} from "ng2-rx-componentdestroyed";
@Component({
templateUrl: './bcf-container.component.html'
templateUrl: './bcf-container.component.html',
providers: [
QueryParamListenerService
]
})
export class BCFContainerComponent {
export class BCFContainerComponent implements OnInit, OnDestroy {
@InjectField() public queryParamListener:QueryParamListenerService;
@InjectField() public wpListService:WorkPackagesListService;
@InjectField() public urlParamsHelper:UrlParamsHelperService;
public queryProps:{ [key:string]:any };
public configuration:WorkPackageTableConfigurationObject = {
actionsColumnEnabled: false,
columnMenuEnabled: false,
contextMenuEnabled: false,
inlineCreateEnabled: false,
withFilters: true,
withFilters: false,
showFilterButton: false,
isCardView: true
};
private filters:any[] = [];
constructor(readonly state:StateService,
readonly i18n:I18nService,
readonly paths:PathHelperService,
readonly currentProject:CurrentProjectService,
readonly gon:GonService) {
readonly gon:GonService,
readonly injector:Injector,
readonly cdRef:ChangeDetectorRef) {
}
ngOnInit():void {
this.refresh();
this.applyFilters();
this.queryParamListener
.observe$
.pipe(
untilComponentDestroyed(this)
).subscribe((queryProps) => {
this.refresh(this.urlParamsHelper.buildV3GetQueryFromJsonParams(queryProps));
});
}
private applyFilters() {
// TODO: Limit to project
this.filters.push({
ngOnDestroy():void {
this.queryParamListener.removeQueryChangeListener();
}
private defaultQueryProps() {
let filters = [];
filters.push({
status: {
operator: 'o',
values: []
}
});
this.queryProps = {
return {
'columns[]': ['id', 'subject'],
filters: JSON.stringify(this.filters),
filters: JSON.stringify(filters),
sortBy: JSON.stringify([['updatedAt', 'desc']]),
showHierarchies: false
};
}
public refresh(queryProps:{ [key:string]:any }|undefined = undefined) {
this.wpListService.loadCurrentQueryFromParams(this.currentProject.identifier!);
this.queryProps = queryProps || this.state.params.query_props || this.defaultQueryProps();
this.cdRef.detectChanges();
}
}

@ -36,9 +36,13 @@ export const IFC_ROUTES:Ng2StateDeclaration[] = [
{
name: 'bim',
parent: 'root',
url: '/ifc_models',
url: '/ifc_models?query_props',
abstract: true,
component: WorkPackagesBaseComponent
component: WorkPackagesBaseComponent,
params: {
// Use custom encoder/decoder that ensures validity of URL string
query_props: {type: 'opQueryString', dynamic: true}
}
},
{
name: 'bim.space',

@ -5,6 +5,11 @@
</h2>
</div>
<ul class="toolbar-items">
<li class="toolbar-item"
*ngIf="filterAllowed">
<wp-filter-button>
</wp-filter-button>
</li>
<li class="toolbar-item">
<bim-view-toggle-button></bim-view-toggle-button>
</li>
@ -25,10 +30,13 @@
</div>
</div>
<filter-container></filter-container>
<div class="ifc-model-viewer--container"
ui-view="viewer">
</div>
<div class="bcf-table--container"
<div class="bcf-table--container loading-indicator--location"
data-indicator-name="ifc-table-container"
ui-view="list">
</div>

@ -3,17 +3,17 @@
overflow: hidden
display: grid
grid-template-columns: 1fr 500px
grid-template-rows: 60px calc(100% - 60px)
grid-template-rows: 60px auto minmax(200px, 1fr)
grid-column-gap: 10px
&.-split
grid-template-areas: "header header" "viewer list"
grid-template-areas: "header header" "filter filter" "viewer list"
&.-viewer
grid-template-areas: "header header" "viewer viewer"
grid-template-areas: "header header" "filter filter" "viewer viewer"
&.-list
grid-template-areas: "header header" "list list"
grid-template-areas: "header header" "filter filter" "list list"
.toolbar-container
grid-area: header
@ -23,3 +23,6 @@
.bcf-table--container
grid-area: list
filter-container
grid-area: filter

@ -1,7 +1,11 @@
import {Component, Injector, HostBinding, ChangeDetectionStrategy} from "@angular/core";
import {ChangeDetectionStrategy, Component, HostBinding, Injector} from "@angular/core";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {GonService} from "core-app/modules/common/gon/gon.service";
import {WorkPackagesViewBase} from "core-app/modules/work_packages/routing/wp-view-base/work-packages-view.base";
import {
bimViewerViewIdentifier, BimViewService
} from "core-app/modules/ifc_models/view-toggle/bim-view.service";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
@Component({
templateUrl: './ifc-viewer-page.component.html',
@ -9,6 +13,8 @@ import {WorkPackagesViewBase} from "core-app/modules/work_packages/routing/wp-vi
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IFCViewerPageComponent extends WorkPackagesViewBase {
@InjectField() bimView:BimViewService;
text = {
title: this.I18n.t('js.ifc_models.models.default'),
manage: this.I18n.t('js.ifc_models.models.manage'),
@ -25,13 +31,7 @@ export class IFCViewerPageComponent extends WorkPackagesViewBase {
@HostBinding('class')
get gridTemplateAreas() {
if (this.$state.includes('bim.space.list')) {
return '-list';
} else if (this.$state.includes('bim.space.*.model')) {
return '-viewer';
} else {
return '-split';
}
return '-' + this.bimView.currentViewerState();
}
public get title() {
@ -50,16 +50,19 @@ export class IFCViewerPageComponent extends WorkPackagesViewBase {
return this.gonIFC.permissions.manage;
}
public get filterAllowed():boolean {
return this.bimView.currentViewerState() !== bimViewerViewIdentifier;
}
private get gonIFC() {
return (this.gon.get('ifc_models') as any);
}
protected set loadingIndicator(promise:Promise<unknown>) {
// TODO: do something useful
this.loadingIndicatorService.indicator('ifc-table-container').promise = promise;
}
public refresh(visibly:boolean, firstPage:boolean):Promise<unknown> {
// TODO: do something useful
return this.loadingIndicator =
this.wpListService.loadCurrentQueryFromParams(this.projectIdentifier);
}

@ -30,13 +30,14 @@ import {OPContextMenuService} from "core-components/op-context-menu/op-context-m
import {Directive, ElementRef} from "@angular/core";
import {OpContextMenuTrigger} from "core-components/op-context-menu/handlers/op-context-menu-trigger.directive";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {StateService} from "@uirouter/core";
import {
bimListViewIdentifier,
bimSplitViewIdentifier,
bimViewerViewIdentifier
} from "core-app/modules/ifc_models/view-toggle/bim-view-toggle.component";
import {StateService} from "@uirouter/core";
import {BimViewService} from "core-app/modules/ifc_models/view-toggle/bim-view.service";
bimViewerViewIdentifier,
BimViewService
} from "core-app/modules/ifc_models/view-toggle/bim-view.service";
import {WorkPackageFiltersService} from "core-components/filters/wp-filters/wp-filters.service";
@Directive({
selector: '[bimViewDropdown]'
@ -46,7 +47,8 @@ export class BimViewToggleDropdownDirective extends OpContextMenuTrigger {
readonly opContextMenu:OPContextMenuService,
readonly bimView:BimViewService,
readonly I18n:I18nService,
readonly state:StateService) {
readonly state:StateService,
readonly wpFiltersService:WorkPackageFiltersService) {
super(elementRef, opContextMenu);
}
@ -73,6 +75,11 @@ export class BimViewToggleDropdownDirective extends OpContextMenuTrigger {
hidden: key === current,
linkText: this.bimView.text[key],
onClick: () => {
// Close filter section
if (this.wpFiltersService.visible) {
this.wpFiltersService.toggleVisibility();
}
switch (key) {
case bimListViewIdentifier:
this.state.go('bim.space.list');

@ -31,10 +31,6 @@ import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {BimViewService} from "core-app/modules/ifc_models/view-toggle/bim-view.service";
export const bimListViewIdentifier = 'list';
export const bimViewerViewIdentifier = 'viewer';
export const bimSplitViewIdentifier = 'split';
@Component({
template: `
<ng-container *ngIf="(view$ | async) as current">

@ -75,16 +75,20 @@ export class BimViewService implements OnDestroy {
return this.view.getValueOr(bimSplitViewIdentifier);
}
private detectView() {
public currentViewerState():BimViewState {
if (this.state.current.name === 'bim.space.list') {
this.view.putValue(bimListViewIdentifier);
return bimListViewIdentifier;
} else if (this.state.includes('bim.**.model')) {
this.view.putValue(bimViewerViewIdentifier);
return bimViewerViewIdentifier;
} else {
this.view.putValue(bimSplitViewIdentifier);
return bimSplitViewIdentifier;
}
}
private detectView() {
this.view.putValue(this.currentViewerState());
}
ngOnDestroy() {
this.transitionFn();
}

@ -39,7 +39,7 @@ import {wpDisplayCardRepresentation} from "core-app/modules/work_packages/routin
import {WorkPackageTableConfigurationObject} from "core-components/wp-table/wp-table-configuration";
import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service";
import {WorkPackageNotificationService} from "core-app/modules/work_packages/notifications/work-package-notification.service";
import {scrollHeaderOnMobile} from "core-app/globals/global-listeners/top-menu-scroll";
import {QueryParamListenerService} from "core-components/wp-query/query-param-listener.service";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
@Component({
@ -51,12 +51,14 @@ import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
/** We need to provide the wpNotification service here to get correct save notifications for WP resources */
{provide: HalResourceNotificationService, useClass: WorkPackageNotificationService},
DragAndDropService,
CausedUpdatesService
CausedUpdatesService,
QueryParamListenerService
]
})
export class WorkPackagesListComponent extends WorkPackagesViewBase implements OnDestroy {
@InjectField() titleService:OpTitleService;
@InjectField() bcfDetectorService:BcfDetectorService;
@InjectField() queryParamListener:QueryParamListenerService;
text = {
'jump_to_pagination': this.I18n.t('js.work_packages.jump_marks.pagination'),
@ -98,13 +100,23 @@ export class WorkPackagesListComponent extends WorkPackagesViewBase implements O
super.ngOnInit();
this.hasQueryProps = !!this.$state.params.query_props;
this.removeTransitionSubscription = this.$transitions.onSuccess({}, (transition):any => {
const params = transition.params('to');
this.hasQueryProps = !!params.query_props;
});
// If the query was loaded, reload invisibly
const isFirstLoad = !this.querySpace.initialized.hasValue();
this.refresh(isFirstLoad, isFirstLoad);
// Load query on URL transitions
this.updateQueryOnParamsChanges();
this.queryParamListener
.observe$
.pipe(
untilComponentDestroyed(this)
).subscribe(() => {
this.refresh(true, true);
});
// Mark tableInformationLoaded when initially loading done
this.setupInformationLoadedListener();
@ -138,6 +150,7 @@ export class WorkPackagesListComponent extends WorkPackagesViewBase implements O
super.ngOnDestroy();
this.unRegisterTitleListener();
this.removeTransitionSubscription();
this.queryParamListener.removeQueryChangeListener();
}
public setAnchorToNextElement() {
@ -229,30 +242,6 @@ export class WorkPackagesListComponent extends WorkPackagesViewBase implements O
return this.bcfDetectorService.isBcfActivated;
}
protected updateQueryOnParamsChanges() {
// Listen for param changes
this.removeTransitionSubscription = this.$transitions.onSuccess({}, (transition):any => {
let options = transition.options();
const params = transition.params('to');
this.hasQueryProps = !!params.query_props;
let newChecksum = this.wpListService.getCurrentQueryProps(params);
let newId:string = params.query_id ? params.query_id.toString() : null;
this.cdRef.detectChanges();
// Avoid performing any changes when we're going to reload
if (options.reload || (options.custom && options.custom.notify === false)) {
return true;
}
this.wpListChecksumService
.executeIfOutdated(newId,
newChecksum,
() => this.refresh(true, true));
});
}
protected setupInformationLoadedListener() {
this
.querySpace

@ -0,0 +1,133 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require_relative '../support/pages/ifc_models/show'
require_relative '../support/pages/ifc_models/show_default'
describe 'BIM filter spec', type: :feature, js: true do
let(:project) { FactoryBot.create :project, enabled_module_names: %w(bim work_package_tracking) }
let(:open_status) { FactoryBot.create(:status, is_closed: false) }
let(:closed_status) { FactoryBot.create(:status, is_closed: true) }
let(:wp1) { FactoryBot.create(:work_package, project: project, status: open_status) }
let(:wp2) { FactoryBot.create(:work_package, project: project, status: closed_status) }
let(:admin) { FactoryBot.create :admin }
let!(:model) do
FactoryBot.create(:ifc_model_converted,
project: project,
uploader: admin)
end
let(:card_view) { ::Pages::WorkPackageCards.new(project) }
let(:filters) { ::Components::WorkPackages::Filters.new }
let(:model_page) { ::Pages::IfcModels::ShowDefault.new project }
before do
wp1
wp2
login_as(admin)
model_page.visit!
model_page.finished_loading
end
context 'on default page' do
before do
# Per default all open work packages are shown
filters.expect_loaded
filters.expect_filter_count 1
filters.open
filters.expect_filter_by('Status', 'open', nil)
card_view.expect_work_package_listed wp1
card_view.expect_work_package_not_listed wp2
end
it 'shows a filter button when there is a list shown' do
model_page.page_shows_a_filter_button true
model_page.switch_view 'Viewer only'
model_page.page_shows_a_filter_button false
end
it 'the filter is applied even after browser back' do
# Change filter
filters.set_operator('Status', 'closed', nil)
filters.expect_filter_count 1
# Otherwise the check for the loading indicator is done
# before it is even shown and the next steps will fail
sleep 0.5
loading_indicator_saveguard
card_view.expect_work_package_not_listed wp1
card_view.expect_work_package_listed wp2
# Using the browser back will reload the filter and the work packages
page.go_back
loading_indicator_saveguard
filters.expect_loaded
filters.expect_filter_count 1
filters.expect_filter_by('Status', 'open', nil)
card_view.expect_work_package_listed wp1
card_view.expect_work_package_not_listed wp2
end
it 'the filter is applied even after reload' do
# Change filter
filters.set_operator('Status', 'closed', nil)
filters.expect_filter_count 1
# Otherwise the check for the loading indicator is done
# before it is even shown and the next steps will fail
sleep 0.5
loading_indicator_saveguard
card_view.expect_work_package_not_listed wp1
card_view.expect_work_package_listed wp2
# Reload and the filter is still correctly applied
page.driver.browser.navigate.refresh
loading_indicator_saveguard
filters.expect_loaded
filters.expect_filter_count 1
filters.open
filters.expect_filter_by('Status', 'closed', nil)
card_view.expect_work_package_not_listed wp1
card_view.expect_work_package_listed wp2
end
end
end

@ -68,18 +68,20 @@ module Pages
tabs = ['Models', 'Objects', 'Classes', 'Storeys']
tabs.each do |tab|
expect(page).to (visible ? have_selector(selector, text: tab) : have_no_selector(selector, text: tab))
element_visible? visible, selector, tab
end
end
def page_shows_a_toolbar(visible)
selector = '.toolbar-item'
toolbar_items.each do |button|
expect(page).to (visible ? have_selector(selector, text: button) : have_no_selector(selector, text: button))
element_visible? visible, '.toolbar-item', button
end
end
def page_shows_a_filter_button(visible)
element_visible? visible, '.toolbar-item', 'Filter'
end
def switch_view(value)
page.find('#bim-view-toggle-button').click
page.find('.menu-item', text: value).click
@ -94,6 +96,10 @@ module Pages
def toolbar_items
['Manage models']
end
def element_visible?(visible, selector, name)
expect(page).to (visible ? have_selector(selector, text: name) : have_no_selector(selector, text: name))
end
end
end
end

Loading…
Cancel
Save