Feature/group fold button (#8863)

* add fold/unfold groups button

* wip

* introduce service to communicate fold state

* fix colspan on folding/unfolding groups

* move state handling into service

* refreshAllGroupHeaderCells when allGroupsChanged too

* have correct order for states to update

* rely on refreshView to trigger collapsedRowRefreshing

The refreshView method is called whenever a group is folded/expanded. Therefore, the listeners for the states are just extra and lead to the code being executed twice.

* remove code made obsolete by commit before

* show group toggle button only when grouping

* use plus/minus icon for button

* shorten code

* remove duplicated code

* linting

* dropdown for expand/collapse

* add icon to button

* alter button position and switch icons

* remove text from group toggle button

* have unique html id on fold button

* Rerendering only group rows when grouping changes

* Disable unavailable menu collapse option + merge old collapse state

* refreshAllGroupHeaderCells when allGroupsChanged too

* Disable collapse options handling mixed scenarios

* Working with different grouping & updating grouping options disable

* Code climate fixes

* Fix + Types with headers extracted to service

* Test fixes

* CodeClimate fixes

Co-authored-by: Aleix Suau <info@macrofonoestudio.es>
pull/8878/head
ulferts 4 years ago committed by GitHub
parent b6784e90d4
commit 7a738cebc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      config/locales/js-en.yml
  2. 89
      frontend/src/app/components/op-context-menu/handlers/wp-group-toggle-dropdown-menu.directive.ts
  3. 4
      frontend/src/app/components/op-context-menu/handlers/wp-view-dropdown-menu.directive.ts
  4. 46
      frontend/src/app/components/wp-buttons/wp-fold-toggle-button/wp-fold-toggle-button.component.ts
  5. 3
      frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts
  6. 26
      frontend/src/app/components/wp-fast-table/handlers/row/group-row-handler.ts
  7. 23
      frontend/src/app/components/wp-fast-table/handlers/state/group-fold-transformer.ts
  8. 2
      frontend/src/app/components/wp-fast-table/handlers/table-handler-registry.ts
  9. 31
      frontend/src/app/components/wp-fast-table/wp-fast-table.ts
  10. 5
      frontend/src/app/components/wp-list/wp-states-initialization.service.ts
  11. 89
      frontend/src/app/components/wp-table/timeline/container/wp-timeline-container.directive.ts
  12. 6
      frontend/src/app/modules/work_packages/openproject-work-packages.module.ts
  13. 2
      frontend/src/app/modules/work_packages/query-space/wp-isolated-query-space.directive.ts
  14. 8
      frontend/src/app/modules/work_packages/routing/wp-view-base/typings.d.ts
  15. 159
      frontend/src/app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service.ts
  16. 7
      frontend/src/app/modules/work_packages/routing/wp-view-page/wp-view-page.component.ts
  17. 2
      frontend/src/global_styles/content/_buttons.sass
  18. 50
      spec/features/work_packages/timeline/timeline_navigation_spec.rb

@ -66,6 +66,8 @@ en:
button_duplicate: "Duplicate"
button_edit: "Edit"
button_filter: "Filter"
button_collapse_all: "Collapse all"
button_expand_all: "Expand all"
button_advanced_filter: "Advanced filter"
button_list_view: "List view"
button_show_view: "Fullscreen view"

@ -0,0 +1,89 @@
//-- 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 {OPContextMenuService} from "core-components/op-context-menu/op-context-menu.service";
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 {
WorkPackageViewDisplayRepresentationService,
wpDisplayCardRepresentation,
wpDisplayListRepresentation
} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-display-representation.service";
import {WorkPackageViewTimelineService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-timeline.service";
import {WorkPackageViewCollapsedGroupsService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service";
@Directive({
selector: '[wpGroupToggleDropdown]'
})
export class WorkPackageGroupToggleDropdownMenuDirective extends OpContextMenuTrigger {
constructor(readonly elementRef:ElementRef,
readonly opContextMenu:OPContextMenuService,
readonly I18n:I18nService,
readonly wpViewCollapsedGroups:WorkPackageViewCollapsedGroupsService) {
super(elementRef, opContextMenu);
}
protected open(evt:JQuery.TriggeredEvent) {
this.buildItems();
this.opContextMenu.show(this, evt);
}
public get locals() {
return {
items: this.items,
contextMenuId: 'wp-group-fold-context-menu'
};
}
private buildItems() {
this.items = [
{
disabled: this.wpViewCollapsedGroups.allGroupsAreCollapsed,
linkText: this.I18n.t('js.button_collapse_all'),
icon: 'icon-minus2',
onClick: (evt:JQuery.TriggeredEvent) => {
this.wpViewCollapsedGroups.setAllGroupsCollapseStateTo(true);
return true;
}
},
{
disabled: this.wpViewCollapsedGroups.allGroupsAreExpanded,
linkText: this.I18n.t('js.button_expand_all'),
icon: 'icon-plus',
onClick: (evt:JQuery.TriggeredEvent) => {
this.wpViewCollapsedGroups.setAllGroupsCollapseStateTo(false);
return true;
}
}
];
}
}

@ -64,7 +64,7 @@ export class WorkPackageViewDropdownMenuDirective extends OpContextMenuTrigger {
private buildItems() {
this.items = [];
if (this.wpDisplayRepresentationService.current !== wpDisplayCardRepresentation) {
this.items.push(
{
@ -104,7 +104,7 @@ export class WorkPackageViewDropdownMenuDirective extends OpContextMenuTrigger {
// List View with enabled Gantt
linkText: this.I18n.t('js.views.timeline'),
icon: 'icon-view-timeline',
onClick: (evt: any) => {
onClick: (evt:any) => {
if (!this.wpTableTimeline.isVisible) {
this.wpTableTimeline.toggle();
}

@ -0,0 +1,46 @@
// -- 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 {ChangeDetectionStrategy, Component} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
template: `
<button class="button"
id="wp-fold-toggle-button"
wpGroupToggleDropdown>
<op-icon icon-classes="button--icon icon-outline"></op-icon>
<span class="button--text"></span>
<op-icon icon-classes="button--icon icon-small icon-pulldown"></op-icon>
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'wp-fold-toggle-view-button'
})
export class WorkPackageFoldToggleButtonComponent {
}

@ -70,7 +70,6 @@ export class GroupedRowsBuilder extends RowsBuilder {
*/
public refreshExpansionState() {
const groups = this.getGroupData();
const colspan = this.wpTableColumns.columnCount + 1;
const rendered = this.querySpace.tableRendered.value!;
const builder = new GroupHeaderBuilder(this.injector);
@ -81,7 +80,7 @@ export class GroupedRowsBuilder extends RowsBuilder {
let group = groups[groupIndex];
// Refresh the group header
let newRow = builder.buildGroupRow(group, colspan);
let newRow = builder.buildGroupRow(group, this.colspan);
if (oldRow.parentNode) {
oldRow.parentNode.replaceChild(newRow, oldRow);

@ -1,16 +1,15 @@
import {Injector} from '@angular/core';
import {debugLog} from '../../../../helpers/debug_output';
import {GroupedRowsBuilder} from '../../builders/modes/grouped/grouped-rows-builder';
import {WorkPackageTable} from '../../wp-fast-table';
import {TableEventComponent, TableEventHandler} from '../table-handler-registry';
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {rowGroupClassName} from "core-components/wp-fast-table/builders/modes/grouped/grouped-classes.constants";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {IsolatedQuerySpace} from 'core-app/modules/work_packages/query-space/isolated-query-space';
import {rowGroupClassName} from 'core-components/wp-fast-table/builders/modes/grouped/grouped-classes.constants';
import {InjectField} from 'core-app/helpers/angular/inject-field.decorator';
import {WorkPackageViewCollapsedGroupsService} from 'core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service';
export class GroupRowHandler implements TableEventHandler {
// Injections
@InjectField() public querySpace:IsolatedQuerySpace;
@InjectField() public workPackageViewCollapsedGroupsService:WorkPackageViewCollapsedGroupsService;
constructor(public readonly injector:Injector) {
}
@ -33,20 +32,7 @@ export class GroupRowHandler implements TableEventHandler {
let groupHeader = jQuery(evt.target).parents(`.${rowGroupClassName}`);
let groupIdentifier = groupHeader.data('groupIdentifier');
let state = this.collapsedState.value || {};
state[groupIdentifier] = !state[groupIdentifier];
this.collapsedState.putValue(state);
// Refresh groups
const builder = new GroupedRowsBuilder(this.injector, view.workPackageTable);
const t0 = performance.now();
builder.refreshExpansionState();
const t1 = performance.now();
debugLog('Group redraw took ' + (t1 - t0) + ' milliseconds.');
}
private get collapsedState() {
return this.querySpace.collapsedGroups;
this.workPackageViewCollapsedGroupsService.toggleGroupCollapseState(groupIdentifier);
}
}

@ -0,0 +1,23 @@
import {Injector} from '@angular/core';
import {distinctUntilChanged, takeUntil} from 'rxjs/operators';
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {WorkPackageTable} from "core-components/wp-fast-table/wp-fast-table";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {WorkPackageViewCollapsedGroupsService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service";
export class GroupFoldTransformer {
@InjectField() public workPackageViewCollapsedGroupsService:WorkPackageViewCollapsedGroupsService;
@InjectField() public querySpace:IsolatedQuerySpace;
constructor(public readonly injector:Injector,
table:WorkPackageTable) {
this.workPackageViewCollapsedGroupsService
.updates$()
.pipe(
takeUntil(this.querySpace.stopAllSubscriptions),
distinctUntilChanged()
)
.subscribe((groupsCollapseEvent) => table.setGroupsCollapseState(groupsCollapseEvent.state));
}
}

@ -23,6 +23,7 @@ import {
WorkPackageViewHandlerRegistry
} from "core-app/modules/work_packages/routing/wp-view-base/event-handling/event-handler-registry";
import {WorkPackageFocusContext} from "core-components/wp-table/wp-table.component";
import {GroupFoldTransformer} from "core-components/wp-fast-table/handlers/state/group-fold-transformer";
type StateTransformers = {
// noinspection JSUnusedLocalSymbols
@ -65,6 +66,7 @@ export class TableHandlerRegistry extends WorkPackageViewHandlerRegistry<TableEv
SelectionTransformer,
RowsTransformer,
ColumnsTransformer,
GroupFoldTransformer,
TimelineTransformer,
HierarchyTransformer,
RelationsTransformer,

@ -1,9 +1,8 @@
import {Injector} from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {IsolatedQuerySpace} from 'core-app/modules/work_packages/query-space/isolated-query-space';
import {debugLog} from '../../helpers/debug_output';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {States} from '../states.service';
import {WorkPackageTimelineTableController} from '../wp-table/timeline/container/wp-timeline-container.directive';
import {GroupedRowsBuilder} from './builders/modes/grouped/grouped-rows-builder';
@ -12,12 +11,12 @@ import {PlainRowsBuilder} from './builders/modes/plain/plain-rows-builder';
import {RowsBuilder} from './builders/modes/rows-builder';
import {PrimaryRenderPass} from './builders/primary-render-pass';
import {WorkPackageTableEditingContext} from './wp-table-editing';
import {WorkPackageTableRow} from './wp-table.interfaces';
import {WorkPackageTableConfiguration} from 'core-app/components/wp-table/wp-table-configuration';
import {RenderedWorkPackage} from "core-app/modules/work_packages/render-info/rendered-work-package.type";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {RenderedWorkPackage} from 'core-app/modules/work_packages/render-info/rendered-work-package.type';
import {InjectField} from 'core-app/helpers/angular/inject-field.decorator';
import {APIV3Service} from 'core-app/modules/apiv3/api-v3.service';
import {WorkPackageViewCollapsedGroupsService} from 'core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service';
export class WorkPackageTable {
@ -25,17 +24,17 @@ export class WorkPackageTable {
@InjectField() apiV3Service:APIV3Service;
@InjectField() states:States;
@InjectField() I18n:I18nService;
@InjectField() workPackageViewCollapsedGroupsService:WorkPackageViewCollapsedGroupsService;
public originalRows:string[] = [];
public originalRowIndex:{ [id:string]:WorkPackageTableRow } = {};
private hierarchyRowsBuilder = new HierarchyRowsBuilder(this.injector, this);
private groupedRowsBuilder = new GroupedRowsBuilder(this.injector, this);
private plainRowsBuilder = new PlainRowsBuilder(this.injector, this);
// WP rows builder
// Ordered by priority
private builders = [
new HierarchyRowsBuilder(this.injector, this),
new GroupedRowsBuilder(this.injector, this),
new PlainRowsBuilder(this.injector, this)
];
private builders = [this.hierarchyRowsBuilder, this.groupedRowsBuilder, this.plainRowsBuilder];
// Last render pass used for refreshing single rows
public lastRenderPass:PrimaryRenderPass|null = null;
@ -172,4 +171,14 @@ export class WorkPackageTable {
return renderPass;
}
setGroupsCollapseState(newState:{[key:string]:boolean}) {
this.querySpace.collapsedGroups.putValue(newState);
const t0 = performance.now();
this.groupedRowsBuilder.refreshExpansionState();
const t1 = performance.now();
debugLog('Group redraw took ' + (t1 - t0) + ' milliseconds.');
}
}

@ -23,6 +23,7 @@ import {WorkPackageViewGroupByService} from "core-app/modules/work_packages/rout
import {WorkPackageViewFiltersService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-filters.service";
import {WorkPackageViewRelationColumnsService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-relation-columns.service";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {WorkPackageViewCollapsedGroupsService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service";
@Injectable()
export class WorkPackageStatesInitializationService {
@ -30,6 +31,7 @@ export class WorkPackageStatesInitializationService {
protected querySpace:IsolatedQuerySpace,
protected wpTableColumns:WorkPackageViewColumnsService,
protected wpTableGroupBy:WorkPackageViewGroupByService,
protected wpTableGroupFold:WorkPackageViewCollapsedGroupsService,
protected wpTableSortBy:WorkPackageViewSortByService,
protected wpTableFilters:WorkPackageViewFiltersService,
protected wpTableSum:WorkPackageViewSumService,
@ -138,6 +140,7 @@ export class WorkPackageStatesInitializationService {
this.wpTableColumns.initialize(query, results);
this.wpTableSortBy.initialize(query, results);
this.wpTableGroupBy.initialize(query, results);
this.wpTableGroupFold.initialize(query, results);
this.wpTableTimeline.initialize(query, results);
this.wpTableHierarchies.initialize(query, results);
this.wpTableHighlighting.initialize(query, results);
@ -153,6 +156,7 @@ export class WorkPackageStatesInitializationService {
this.wpTableColumns.applyToQuery(query);
this.wpTableSortBy.applyToQuery(query);
this.wpTableGroupBy.applyToQuery(query);
this.wpTableGroupFold.applyToQuery(query);
this.wpTableTimeline.applyToQuery(query);
this.wpTableHighlighting.applyToQuery(query);
this.wpTableHierarchies.applyToQuery(query);
@ -174,6 +178,7 @@ export class WorkPackageStatesInitializationService {
this.wpTableColumns.clear(reason);
this.wpTableSortBy.clear(reason);
this.wpTableGroupBy.clear(reason);
this.wpTableGroupFold.clear(reason);
this.wpDisplayRepresentation.clear(reason);
this.wpTableSum.clear(reason);

@ -33,7 +33,7 @@ import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-r
import {IsolatedQuerySpace} from 'core-app/modules/work_packages/query-space/isolated-query-space';
import * as moment from 'moment';
import {Moment} from 'moment';
import {filter, map, switchMap, take, takeUntil} from 'rxjs/operators';
import {filter, takeUntil} from 'rxjs/operators';
import {
calculateDaySpan,
getPixelPerDayForZoomLevel,
@ -57,15 +57,14 @@ import {debugLog, timeOutput} from 'core-app/helpers/debug_output';
import {RenderedWorkPackage} from 'core-app/modules/work_packages/render-info/rendered-work-package.type';
import {HalEventsService} from 'core-app/modules/hal/services/hal-events.service';
import {WorkPackageNotificationService} from 'core-app/modules/work_packages/notifications/work-package-notification.service';
import {combineLatest, merge, Observable} from 'rxjs';
import {combineLatest, Observable} from 'rxjs';
import {UntilDestroyedMixin} from 'core-app/helpers/angular/until-destroyed.mixin';
import {WorkPackagesTableComponent} from 'core-components/wp-table/wp-table.component';
import {GroupObject} from 'core-app/modules/hal/resources/wp-collection-resource';
import {SchemaCacheService} from 'core-components/schemas/schema-cache.service';
import {
groupIdFromIdentifier,
groupTypeFromIdentifier
} from 'core-components/wp-fast-table/builders/modes/grouped/grouped-rows-helpers';
import {WorkPackageViewCollapsedGroupsService} from 'core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service';
@Component({
selector: 'wp-timeline-container',
@ -100,10 +99,6 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl
private collapsedGroupsCellsMap:IGroupCellsMap = {};
private wpTypesToShowInCollapsedGroupHeaders:((wp:WorkPackageResource) => boolean)[];
private groupTypesWithHeaderCellsWhenCollapsed = ['project'];
private orderedRows:RenderedWorkPackage[] = [];
get commonPipes() {
@ -138,12 +133,11 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl
private halEvents:HalEventsService,
private querySpace:IsolatedQuerySpace,
readonly I18n:I18nService,
private schemaCacheService:SchemaCacheService) {
private workPackageViewCollapsedGroupsService:WorkPackageViewCollapsedGroupsService) {
super();
}
ngAfterViewInit() {
this.wpTypesToShowInCollapsedGroupHeaders = [this.isMilestone];
this.$element = jQuery(this.elementRef.nativeElement);
this.text = {
@ -458,50 +452,36 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl
}
setupManageCollapsedGroupHeaderCells() {
merge(
// Refresh the last collapsed/expanded group header cells when its collapsed state changes
this.querySpace.collapsedGroups.changes$().pipe(filter(collapsedGroupsChange => collapsedGroupsChange != null)),
// Refresh all the collapsed group header cells whenever the query changes
this.querySpace.initialized.values$().pipe(switchMap(() => this.querySpace.tableRendered.values$().pipe(take(1), map(() => false)))),
)
.pipe(
this.commonPipes,
)
.subscribe((change:{[identifier:string]:boolean} | false) => {
const collapsedGroupsChange = change || this.querySpace.collapsedGroups.value;
const refreshAllGroupHeaderCells = !change;
if (collapsedGroupsChange) {
this.manageCollapsedGroupHeaderCells(this.querySpace.groups.value!,
collapsedGroupsChange,
this.querySpace.results.value!.elements,
this.collapsedGroupsCellsMap,
refreshAllGroupHeaderCells);
}
});
this.workPackageViewCollapsedGroupsService.updates$()
.pipe(
this.commonPipes,
)
.subscribe((groupsCollapseEvent:IGroupsCollapseEvent) => {
this.manageCollapsedGroupHeaderCells(
groupsCollapseEvent,
this.querySpace.results.value!.elements,
this.collapsedGroupsCellsMap,
);
});
}
manageCollapsedGroupHeaderCells(allGroups:GroupObject[],
collapsedGroupsChange:{[key:string]:boolean},
manageCollapsedGroupHeaderCells(groupsCollapseConfig:IGroupsCollapseEvent,
tableWorkPackages:WorkPackageResource[],
collapsedGroupsCellsMap:IGroupCellsMap,
refreshAllGroupHeaderCells:boolean) {
const collapsedGroupChangesToManage = Object.keys(collapsedGroupsChange).filter(groupIdentifier => {
const keyGroupType = groupTypeFromIdentifier(groupIdentifier);
collapsedGroupsCellsMap:IGroupCellsMap) {
const refreshAllGroupHeaderCells = groupsCollapseConfig.allGroupsChanged;
const collapsedGroupsChange = groupsCollapseConfig.state;
const collapsedGroupsChangeArray = Object.keys(collapsedGroupsChange);
let groupsToUpdate:string[] = [];
return this.groupTypesWithHeaderCellsWhenCollapsed.includes(keyGroupType);
});
let groupsToUpdate:string[];
if (!collapsedGroupsChangeArray.length) { return; }
if (refreshAllGroupHeaderCells) {
groupsToUpdate = collapsedGroupChangesToManage;
groupsToUpdate = collapsedGroupsChangeArray.filter(groupIdentifier => this.shouldManageCollapsedGroupHeaderCells(groupIdentifier, groupsCollapseConfig));
} else {
groupsToUpdate = collapsedGroupChangesToManage.filter(groupKey => {
const currentGroupCollapsedValue = collapsedGroupsChange[groupKey];
const storedGroup = allGroups.find(group => group.identifier === groupKey);
return storedGroup && storedGroup.collapsed !== currentGroupCollapsedValue;
});
const groupIdentifier = groupsCollapseConfig.lastChangedGroup!;
if (this.shouldManageCollapsedGroupHeaderCells(groupIdentifier, groupsCollapseConfig)) {
groupsToUpdate = [groupIdentifier];
}
}
groupsToUpdate.forEach(groupIdentifier => {
@ -515,6 +495,13 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl
});
}
shouldManageCollapsedGroupHeaderCells(groupIdentifier:string, groupsCollapseConfig:IGroupsCollapseEvent) {
const keyGroupType = groupTypeFromIdentifier(groupIdentifier);
return this.workPackageViewCollapsedGroupsService.groupTypesWithHeaderCellsWhenCollapsed.includes(keyGroupType) &&
this.workPackageViewCollapsedGroupsService.groupTypesWithHeaderCellsWhenCollapsed.includes(groupsCollapseConfig.groupedBy!);
}
createCollapsedGroupHeaderCells(groupIdentifier:string, tableWorkPackages:WorkPackageResource[], collapsedGroupsCellsMap:IGroupCellsMap) {
this.removeCollapsedGroupHeaderCells(groupIdentifier, collapsedGroupsCellsMap);
@ -544,11 +531,9 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl
}
shouldBeShownInCollapsedGroupHeaders(workPackage:WorkPackageResource) {
return this.wpTypesToShowInCollapsedGroupHeaders.some(wpTypeFunction => wpTypeFunction(workPackage));
}
isMilestone = (workPackage:WorkPackageResource):boolean => {
return this.schemaCacheService.of(workPackage)?.isMilestone;
return this.workPackageViewCollapsedGroupsService
.wpTypesToShowInCollapsedGroupHeaders
.some(wpTypeFunction => wpTypeFunction(workPackage));
}
getWorkPackagesToCalculateTimelineWidthFrom() {

@ -95,6 +95,7 @@ import {FilterIntegerValueComponent} from 'core-components/filters/filter-intege
import {FilterStringValueComponent} from 'core-components/filters/filter-string-value/filter-string-value.component';
import {FilterToggledMultiselectValueComponent} from 'core-components/filters/filter-toggled-multiselect-value/filter-toggled-multiselect-value.component';
import {WorkPackageDetailsViewButtonComponent} from 'core-components/wp-buttons/wp-details-view-button/wp-details-view-button.component';
import {WorkPackageFoldToggleButtonComponent} from 'core-components/wp-buttons/wp-fold-toggle-button/wp-fold-toggle-button.component';
import {WpTableConfigurationModalComponent} from 'core-components/wp-table/configuration-modal/wp-table-configuration.modal';
import {WpTableConfigurationColumnsTab} from 'core-components/wp-table/configuration-modal/tabs/columns-tab.component';
import {WpTableConfigurationDisplaySettingsTab} from 'core-components/wp-table/configuration-modal/tabs/display-settings-tab.component';
@ -165,6 +166,7 @@ import {WorkPackageSettingsButtonComponent} from "core-components/wp-buttons/wp-
import {BackButtonComponent} from "core-app/modules/common/back-routing/back-button.component";
import {DatePickerModal} from "core-components/datepicker/datepicker.modal";
import {WorkPackagesTableComponent} from "core-components/wp-table/wp-table.component";
import {WorkPackageGroupToggleDropdownMenuDirective} from "core-components/op-context-menu/handlers/wp-group-toggle-dropdown-menu.directive";
@NgModule({
imports: [
@ -255,6 +257,9 @@ import {WorkPackagesTableComponent} from "core-components/wp-table/wp-table.comp
WorkPackageTableSumsRowController,
// Fold/Unfold button on wp list
WorkPackageFoldToggleButtonComponent,
// Filters
QueryFiltersComponent,
QueryFilterComponent,
@ -279,6 +284,7 @@ import {WorkPackagesTableComponent} from "core-components/wp-table/wp-table.comp
WorkPackageSingleContextMenuDirective,
WorkPackageQuerySelectDropdownComponent,
WorkPackageViewDropdownMenuDirective,
WorkPackageGroupToggleDropdownMenuDirective,
// Timeline
WorkPackageTimelineButtonComponent,

@ -65,6 +65,7 @@ import {WorkPackageViewHierarchyIdentationService} from "core-app/modules/work_p
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 {TimeEntryCreateService} from "core-app/modules/time_entries/create/create.service";
import {WorkPackageViewCollapsedGroupsService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service";
/**
* Directive to open a work package query 'space', an isolated injector hierarchy
@ -89,6 +90,7 @@ import {TimeEntryCreateService} from "core-app/modules/time_entries/create/creat
WorkPackageViewRelationColumnsService,
WorkPackageViewPaginationService,
WorkPackageViewGroupByService,
WorkPackageViewCollapsedGroupsService,
WorkPackageViewHierarchiesService,
WorkPackageViewSortByService,
WorkPackageViewColumnsService,

@ -0,0 +1,8 @@
interface IGroupsCollapseEvent {
state:{[identifier:string]:boolean};
allGroupsAreCollapsed:boolean;
allGroupsAreExpanded:boolean;
lastChangedGroup:string|null;
allGroupsChanged:boolean;
groupedBy:string|null;
}

@ -0,0 +1,159 @@
// -- 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 {QueryResource} from 'core-app/modules/hal/resources/query-resource';
import {WorkPackageViewBaseService} from './wp-view-base.service';
import {Injectable} from '@angular/core';
import {WorkPackageViewGroupByService} from 'core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-group-by.service';
import {IsolatedQuerySpace} from 'core-app/modules/work_packages/query-space/isolated-query-space';
import {take} from 'rxjs/operators';
import {GroupObject, WorkPackageCollectionResource} from 'core-app/modules/hal/resources/wp-collection-resource';
import {QuerySchemaResource} from 'core-app/modules/hal/resources/query-schema-resource';
import {QueryGroupByResource} from 'core-app/modules/hal/resources/query-group-by-resource';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {SchemaCacheService} from 'core-components/schemas/schema-cache.service';
@Injectable()
export class WorkPackageViewCollapsedGroupsService extends WorkPackageViewBaseService<IGroupsCollapseEvent> {
readonly wpTypesToShowInCollapsedGroupHeaders:((wp:WorkPackageResource) => boolean)[];
readonly groupTypesWithHeaderCellsWhenCollapsed = ['project'];
get config():IGroupsCollapseEvent {
return this.updatesState.getValueOr(this.getDefaultState());
}
get currentGroups():GroupObject[] {
return this.querySpace.groups.value!;
}
get allGroupsAreCollapsed():boolean {
return this.config.allGroupsAreCollapsed;
}
get allGroupsAreExpanded():boolean {
return this.config.allGroupsAreExpanded;
}
get currentGroupedBy():QueryGroupByResource|null {
return this.workPackageViewGroupByService.current;
}
constructor(
protected readonly querySpace:IsolatedQuerySpace,
readonly workPackageViewGroupByService:WorkPackageViewGroupByService,
private schemaCacheService:SchemaCacheService,
) {
super(querySpace);
this.wpTypesToShowInCollapsedGroupHeaders = [this.isMilestone];
}
// Every time the groupedBy changes, this services is initialized
private getDefaultState():IGroupsCollapseEvent {
return {
state: this.querySpace.collapsedGroups.value || {},
allGroupsChanged: false,
lastChangedGroup: null,
groupedBy: this.currentGroupedBy?.id || null,
...this.getAllGroupsCollapsedState(this.currentGroups, this.querySpace.collapsedGroups.value!),
};
}
isMilestone = (workPackage:WorkPackageResource):boolean => {
return this.schemaCacheService.of(workPackage)?.isMilestone;
}
toggleGroupCollapseState(groupIdentifier:string):void {
const newCollapsedState = !this.config.state[groupIdentifier];
const state = {
...this.config.state,
[groupIdentifier]: newCollapsedState
};
const newState = {
...this.config,
state,
lastChangedGroup: groupIdentifier,
...this.getAllGroupsCollapsedState(this.currentGroups, state),
};
this.update(newState);
}
setAllGroupsCollapseStateTo(collapsedState:boolean):void {
const groupUpdatedState = this.currentGroups.reduce((updatedState:{[key:string]:boolean}, group) => {
return {
...updatedState,
[group.identifier]:collapsedState,
};
}, {});
const newState = {
...this.config,
state: {
...this.config.state,
...groupUpdatedState,
},
lastChangedGroup: null,
allGroupsAreCollapsed: collapsedState,
allGroupsAreExpanded: !collapsedState,
allGroupsChanged: true,
};
this.update(newState);
}
getAllGroupsCollapsedState(groups:GroupObject[], currentCollapsedGroupsState:IGroupsCollapseEvent['state']) {
let allGroupsAreCollapsed = false;
let allGroupsAreExpanded = true;
if (currentCollapsedGroupsState && groups?.length) {
const firstGroupIdentifier = groups[0].identifier;
const firstGroupCollapsedState = currentCollapsedGroupsState[firstGroupIdentifier];
const allGroupsHaveTheSameCollapseState = groups.every((group) => {
return currentCollapsedGroupsState[group.identifier] != null &&
currentCollapsedGroupsState[group.identifier] === currentCollapsedGroupsState[firstGroupIdentifier];
});
allGroupsAreCollapsed = allGroupsHaveTheSameCollapseState && firstGroupCollapsedState;
allGroupsAreExpanded = allGroupsHaveTheSameCollapseState && !firstGroupCollapsedState;
}
return {allGroupsAreCollapsed, allGroupsAreExpanded};
}
initialize(query:QueryResource, results:WorkPackageCollectionResource, schema?:QuerySchemaResource) {
// When this service is initialized (first time the table is loaded and very time the groupBy changes),
// we need to wait until the table is ready to emit the collapseStatus. Otherwise the groups are not
// ready in the DOM and can't be collapsed/expanded.
this.querySpace.tableRendered.values$().pipe(take(1)).subscribe(() => this.update({ ...this.config, allGroupsChanged: true }));
}
valueFromQuery(query:QueryResource, results:WorkPackageCollectionResource) {
return this.getDefaultState();
}
applyToQuery(query:QueryResource) { return; }
}

@ -43,6 +43,7 @@ import {WorkPackageTimelineButtonComponent} from "core-components/wp-buttons/wp-
import {ZenModeButtonComponent} from "core-components/wp-buttons/zen-mode-toggle-button/zen-mode-toggle-button.component";
import {WorkPackageSettingsButtonComponent} from "core-components/wp-buttons/wp-settings-button/wp-settings-button.component";
import {of} from "rxjs";
import {WorkPackageFoldToggleButtonComponent} from "core-components/wp-buttons/wp-fold-toggle-button/wp-fold-toggle-button.component";
@Component({
selector: 'wp-view-page',
@ -74,6 +75,12 @@ export class WorkPackageViewPageComponent extends PartitionedQuerySpacePageCompo
component: WorkPackageViewToggleButton,
containerClasses: 'hidden-for-mobile'
},
{
component: WorkPackageFoldToggleButtonComponent,
show: () => {
return !!(this.currentQuery && this.currentQuery.groupBy);
}
},
{
component: WorkPackageDetailsViewButtonComponent,
containerClasses: 'hidden-for-mobile'

@ -123,7 +123,7 @@ a.button
.button--text + .op-icon--wrapper
margin: 0 0 0 var(--button--text-icon-spacing)
// Hack as Lato font on Win Chrome draws about a pixel too highligh
// Hack as Lato font on Win Chrome draws about a pixel too high
html.-browser-windows.-browser-chrome
.button--text,
.button--icon

@ -315,41 +315,22 @@ RSpec.feature 'Work package timeline navigation', js: true, selenium: true do
end
end
it 'shows milestone icons on collapsed project group rows' do
it 'shows milestone icons on collapsed project group rows but not on expanded ones' do
wp_table.visit_query(query)
# The button to fold/expand all groups is only present when grouping
expect(page)
.not_to have_button('wp-fold-toggle-button')
group_by.enable_via_menu 'Project'
# Collapse Foo section
header = find('.wp-table--group-header', text: 'My Project No.')
header.find('.expander').click
find('.wp-table--group-header', text: 'My Project No.')
.find('.expander')
.click
# Folding will lead to having milestones presented within the group row
expect(page).to have_selector('.-group-row .timeline-element.milestone')
end
it 'does not show icons on expanded project group rows' do
wp_table.visit_query(query)
group_by.enable_via_menu 'Project'
# Collapse Group rows
header = find('.wp-table--group-header', text: 'My Project No.')
header_expander = header.find('.expander')
header_expander.click
header_expander.click
expect(page).to have_no_selector('.-group-row .timeline-element')
end
it 'shows correct labels when hovering milestone icons on collapsed group rows' do
wp_table.visit_query(query)
group_by.enable_via_menu 'Project'
# Collapse Group rows
header = find('.wp-table--group-header', text: 'My Project No.')
header_expander = header.find('.expander')
header_expander.click
# Check hover labels (milestone)
milestone = find('.timeline-element.milestone')
@ -360,6 +341,19 @@ RSpec.feature 'Work package timeline navigation', js: true, selenium: true do
expect(milestone).to have_selector(".labelLeft", visible: false)
expect(milestone).to have_selector(".labelRight", visible: false)
expect(milestone).to have_selector(".labelFarRight", visible: false)
# Unfold Group rows
find('.wp-table--group-header', text: 'My Project No.')
.find('.expander')
.click
expect(page).to have_no_selector('.-group-row .timeline-element')
click_button('wp-fold-toggle-button')
click_link(I18n.t('js.button_collapse_all'))
# Will again fold all rows so the milestone elements should again be present
expect(page).to have_selector('.-group-row .timeline-element.milestone')
end
end
end

Loading…
Cancel
Save