Merge pull request #7540 from opf/merge/release_9_1_in_dev

Merge Release/9.1 into dev

[ci skip]
pull/7543/head
Oliver Günther 5 years ago committed by GitHub
commit 18f7d5afb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 64
      frontend/src/app/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts
  2. 1
      frontend/src/app/components/wp-buttons/wp-details-view-button/wp-details-view-button.component.ts
  3. 42
      frontend/src/app/components/wp-card-view/event-handler/card-view-handler-registry.ts
  4. 72
      frontend/src/app/components/wp-card-view/event-handler/click-handler.ts
  5. 52
      frontend/src/app/components/wp-card-view/event-handler/double-click-handler.ts
  6. 68
      frontend/src/app/components/wp-card-view/event-handler/right-click-handler.ts
  7. 200
      frontend/src/app/components/wp-card-view/services/wp-card-drag-and-drop.service.ts
  8. 37
      frontend/src/app/components/wp-card-view/services/wp-card-view.service.ts
  9. 0
      frontend/src/app/components/wp-card-view/styles/wp-card-view-horizontal.sass
  10. 0
      frontend/src/app/components/wp-card-view/styles/wp-card-view-vertical.sass
  11. 5
      frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass
  12. 29
      frontend/src/app/components/wp-card-view/wp-card-view.component.html
  13. 252
      frontend/src/app/components/wp-card-view/wp-card-view.component.ts
  14. 2
      frontend/src/app/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts
  15. 19
      frontend/src/app/components/wp-fast-table/handlers/state/selection-transformer.ts
  16. 28
      frontend/src/app/components/wp-fast-table/state/wp-table-selection.service.ts
  17. 10
      frontend/src/app/components/wp-grid/wp-grid.component.ts
  18. 12
      frontend/src/app/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts
  19. 2
      frontend/src/app/modules/boards/board/board-list/board-list.component.ts
  20. 2
      frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.html
  21. 3
      frontend/src/app/modules/work_packages/query-space/wp-isolated-query-space.directive.ts
  22. 26
      spec/features/work_packages/bulk/copy_work_package_spec.rb
  23. 27
      spec/features/work_packages/bulk/move_work_package_spec.rb
  24. 26
      spec/features/work_packages/bulk/update_work_package_spec.rb
  25. 0
      spec/features/work_packages/select/select_query_spec.rb
  26. 0
      spec/features/work_packages/select/select_work_package_row_spec.rb
  27. 71
      spec/features/work_packages/select/select_wp_card_spec.rb
  28. 184
      spec/features/work_packages/table/context_menu_spec.rb
  29. 8
      spec/support/components/work_packages/context_menu.rb
  30. 53
      spec/support/work_packages/work_package_cards.rb

@ -32,15 +32,17 @@ export class OpWorkPackageContextMenu extends OpContextMenuHandler {
private selectedWorkPackages = this.getSelectedWorkPackages();
private permittedActions = this.WorkPackageContextMenuHelper.getPermittedActions(
this.selectedWorkPackages,
PERMITTED_CONTEXT_MENU_ACTIONS
PERMITTED_CONTEXT_MENU_ACTIONS,
this.allowSplitScreenActions
);
protected items = this.buildItems();
constructor(readonly injector:Injector,
readonly table:WorkPackageTable,
readonly workPackageId:string,
public $element:JQuery,
public additionalPositionArgs:any = {}) {
public additionalPositionArgs:any = {},
readonly table?:WorkPackageTable,
readonly allowSplitScreenActions:boolean = true) {
super(injector.get(OPContextMenuService))
}
@ -72,11 +74,15 @@ export class OpWorkPackageContextMenu extends OpContextMenuHandler {
break;
case 'relation-precedes':
this.table.timelineController.startAddRelationPredecessor(this.workPackage);
if (this.table) {
this.table.timelineController.startAddRelationPredecessor(this.workPackage);
}
break;
case 'relation-follows':
this.table.timelineController.startAddRelationFollower(this.workPackage);
if (this.table) {
this.table.timelineController.startAddRelationFollower(this.workPackage);
}
break;
case 'relation-new-child':
@ -145,15 +151,35 @@ export class OpWorkPackageContextMenu extends OpContextMenuHandler {
return false;
}
this.triggerContextMenuAction(action)
this.triggerContextMenuAction(action);
return true;
}
};
});
if (!this.workPackage.isNew) {
items.unshift(
{
items.unshift({
disabled: false,
icon: 'icon-view-fullscreen',
class: 'openFullScreenView',
href: this.$state.href('work-packages.show', {workPackageId: this.workPackageId}),
linkText: I18n.t('js.button_open_fullscreen'),
onClick: ($event:JQueryEventObject) => {
if (LinkHandling.isClickedWithModifier($event)) {
return false;
}
this.$state.go(
'work-packages.show',
{ workPackageId: this.workPackageId }
);
return true;
}
});
if (this.allowSplitScreenActions) {
items.unshift({
disabled: false,
icon: 'icon-view-split',
class: 'detailsViewMenuItem',
@ -170,26 +196,8 @@ export class OpWorkPackageContextMenu extends OpContextMenuHandler {
);
return true;
}
},
{
disabled: false,
icon: 'icon-view-fullscreen',
class: 'openFullScreenView',
href: this.$state.href('work-packages.show', {workPackageId: this.workPackageId}),
linkText: I18n.t('js.button_open_fullscreen'),
onClick: ($event:JQueryEventObject) => {
if (LinkHandling.isClickedWithModifier($event)) {
return false;
}
this.$state.go(
'work-packages.show',
{ workPackageId: this.workPackageId }
);
return true;
}
},
)
});
}
}
return items;

@ -85,7 +85,6 @@ export class WorkPackageDetailsViewButtonComponent extends AbstractWorkPackageBu
untilComponentDestroyed(this)
)
.subscribe(() => {
this.disabled = this.wpDisplayRepresentationService.current === wpDisplayCardRepresentation;
this.cdRef.detectChanges();
});
}

@ -0,0 +1,42 @@
import {Injector} from '@angular/core';
import {WorkPackageCardViewComponent} from "core-components/wp-card-view/wp-card-view.component";
import {CardClickHandler} from "core-components/wp-card-view/event-handler/click-handler";
import {CardDblClickHandler} from "core-components/wp-card-view/event-handler/double-click-handler";
import {CardRightClickHandler} from "core-components/wp-card-view/event-handler/right-click-handler";
export interface CardEventHandler {
EVENT:string;
SELECTOR:string;
handleEvent(card:WorkPackageCardViewComponent, evt:JQueryEventObject):void;
eventScope(card:WorkPackageCardViewComponent):JQuery;
}
export class CardViewHandlerRegistry {
constructor(public readonly injector:Injector) {
}
private eventHandlers:((c:WorkPackageCardViewComponent) => CardEventHandler)[] = [
// Clicking on the card (not within a cell)
c => new CardClickHandler(this.injector, c),
// Double Clicking on the row (not within a cell)
c => new CardDblClickHandler(this.injector, c),
// Right clicking on cards
t => new CardRightClickHandler(this.injector, t),
];
attachTo(card:WorkPackageCardViewComponent) {
this.eventHandlers.map(factory => {
let handler = factory(card);
let target = handler.eventScope(card);
target.on(handler.EVENT, handler.SELECTOR, (evt:JQueryEventObject) => {
handler.handleEvent(card, evt);
});
return handler;
});
}
}

@ -0,0 +1,72 @@
import {Injector} from '@angular/core';
import {CardEventHandler} from "core-components/wp-card-view/event-handler/card-view-handler-registry";
import {WorkPackageCardViewComponent} from "core-components/wp-card-view/wp-card-view.component";
import {WorkPackageTableSelection} from "core-components/wp-fast-table/state/wp-table-selection.service";
import {WorkPackageTableFocusService} from "core-components/wp-fast-table/state/wp-table-focus.service";
import {WorkPackageCardViewService} from "core-components/wp-card-view/services/wp-card-view.service";
export class CardClickHandler implements CardEventHandler {
// Injections
public wpTableSelection:WorkPackageTableSelection = this.injector.get(WorkPackageTableSelection);
public wpTableFocus:WorkPackageTableFocusService = this.injector.get(WorkPackageTableFocusService);
public wpCardView:WorkPackageCardViewService = this.injector.get(WorkPackageCardViewService);
constructor(public readonly injector:Injector,
card:WorkPackageCardViewComponent) {
}
public get EVENT() {
return 'click.cardView.card';
}
public get SELECTOR() {
return `.wp-card`;
}
public eventScope(card:WorkPackageCardViewComponent) {
return jQuery(card.container.nativeElement);
}
public handleEvent(card:WorkPackageCardViewComponent, evt:JQueryEventObject) {
let target = jQuery(evt.target);
// Ignore links
if (target.is('a') || target.parent().is('a')) {
return true;
}
// Locate the card from event
let element = target.closest(this.SELECTOR);
let wpId = element.data('workPackageId');
let classIdentifier = element.data('classIdentifier');
if (!wpId) {
return true;
}
let index = this.wpCardView.findRenderedCard(classIdentifier);
// Update single selection if no modifier present
if (!(evt.ctrlKey || evt.metaKey || evt.shiftKey)) {
this.wpTableSelection.setSelection(wpId, index);
}
// Multiple selection if shift present
if (evt.shiftKey) {
this.wpTableSelection.setMultiSelectionFrom(this.wpCardView.renderedCards, wpId, index);
}
// Single selection expansion if ctrl / cmd(mac)
if (evt.ctrlKey || evt.metaKey) {
this.wpTableSelection.toggleRow(wpId);
}
// The current card is the last selected work package
// not matter what other card are (de-)selected below.
// Thus save that card for the details view button.
this.wpTableFocus.updateFocus(wpId);
return false;
}
}

@ -0,0 +1,52 @@
import {Injector} from '@angular/core';
import {CardEventHandler} from "core-components/wp-card-view/event-handler/card-view-handler-registry";
import {WorkPackageCardViewComponent} from "core-components/wp-card-view/wp-card-view.component";
import {WorkPackageTableSelection} from "core-components/wp-fast-table/state/wp-table-selection.service";
import {StateService} from "@uirouter/core";
export class CardDblClickHandler implements CardEventHandler {
// Injections
public $state:StateService = this.injector.get(StateService);
public wpTableSelection:WorkPackageTableSelection = this.injector.get(WorkPackageTableSelection);
constructor(public readonly injector:Injector,
card:WorkPackageCardViewComponent) {
}
public get EVENT() {
return 'dblclick.cardView.card';
}
public get SELECTOR() {
return `.wp-card`;
}
public eventScope(card:WorkPackageCardViewComponent) {
return jQuery(card.container.nativeElement);
}
public handleEvent(card:WorkPackageCardViewComponent, evt:JQueryEventObject) {
let target = jQuery(evt.target);
// Ignore links
if (target.is('a') || target.parent().is('a')) {
return true;
}
// Locate the row from event
let element = target.closest(this.SELECTOR);
let wpId = element.data('workPackageId');
if (!wpId) {
return true;
}
this.$state.go(
'work-packages.show',
{workPackageId: wpId}
);
return false;
}
}

@ -0,0 +1,68 @@
import {Injector} from '@angular/core';
import {CardEventHandler} from "core-components/wp-card-view/event-handler/card-view-handler-registry";
import {WorkPackageCardViewComponent} from "core-components/wp-card-view/wp-card-view.component";
import {WorkPackageTableSelection} from "core-components/wp-fast-table/state/wp-table-selection.service";
import {uiStateLinkClass} from "core-components/wp-fast-table/builders/ui-state-link-builder";
import {debugLog} from "core-app/helpers/debug_output";
import {WorkPackageCardViewService} from "core-components/wp-card-view/services/wp-card-view.service";
import {OpWorkPackageContextMenu} from "core-components/op-context-menu/wp-context-menu/wp-table-context-menu.directive";
import {OPContextMenuService} from "core-components/op-context-menu/op-context-menu.service";
export class CardRightClickHandler implements CardEventHandler {
// Injections
public wpTableSelection:WorkPackageTableSelection = this.injector.get(WorkPackageTableSelection);
public wpCardView:WorkPackageCardViewService = this.injector.get(WorkPackageCardViewService);
public opContextMenu:OPContextMenuService = this.injector.get(OPContextMenuService);
constructor(public readonly injector:Injector,
card:WorkPackageCardViewComponent) {
}
public get EVENT() {
return 'contextmenu.cardView.rightclick';
}
public get SELECTOR() {
return `.wp-card`;
}
public eventScope(card:WorkPackageCardViewComponent) {
return jQuery(card.container.nativeElement);
}
public handleEvent(card:WorkPackageCardViewComponent, evt:JQueryEventObject) {
let target = jQuery(evt.target);
// We want to keep the original context menu on hrefs
// (currently, this is only the id)
if (target.closest(`.${uiStateLinkClass}`).length) {
debugLog('Allowing original context menu on state link');
return true;
}
evt.preventDefault();
evt.stopPropagation();
// Locate the card from event
const element = target.closest(this.SELECTOR);
const wpId = element.data('workPackageId');
if (!wpId) {
return true;
} else {
let classIdentifier = element.data('classIdentifier');
let index = this.wpCardView.findRenderedCard(classIdentifier);
if (!this.wpTableSelection.isSelected(wpId)) {
this.wpTableSelection.setSelection(wpId, index);
}
const handler = new OpWorkPackageContextMenu(this.injector, wpId, jQuery(evt.target) as JQuery, {}, undefined, card.showInfoButton);
this.opContextMenu.show(handler, evt);
}
return false;
}
}

@ -0,0 +1,200 @@
import {Inject, Injectable, Injector} from '@angular/core';
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {WorkPackageTableOrderService} from "core-components/wp-fast-table/state/wp-table-order.service";
import {States} from "core-components/states.service";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {IWorkPackageCreateServiceToken} from "core-components/wp-new/wp-create.service.interface";
import {WorkPackageCreateService} from "core-components/wp-new/wp-create.service";
import {WorkPackageNotificationService} from "core-components/wp-edit/wp-notification.service";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {WorkPackageInlineCreateService} from "core-components/wp-inline-create/wp-inline-create.service";
import {DragAndDropService} from "core-app/modules/common/drag-and-drop/drag-and-drop.service";
import {DragAndDropHelpers} from "core-app/modules/common/drag-and-drop/drag-and-drop.helpers";
import {WorkPackageCardViewComponent} from "core-components/wp-card-view/wp-card-view.component";
@Injectable()
export class WorkPackageCardDragAndDropService {
private _workPackages:WorkPackageResource[];
/** Whether the card view has an active inline created wp */
public activeInlineCreateWp?:WorkPackageResource;
/** A reference to the component in use, to have access to the current input variables */
public cardView:WorkPackageCardViewComponent;
public readonly dragService = this.injector.get(DragAndDropService, null);
public constructor(readonly states:States,
readonly injector:Injector,
readonly reorderService:WorkPackageTableOrderService,
@Inject(IWorkPackageCreateServiceToken) readonly wpCreate:WorkPackageCreateService,
readonly wpNotifications:WorkPackageNotificationService,
readonly currentProject:CurrentProjectService,
readonly wpInlineCreate:WorkPackageInlineCreateService) {
}
public init(componentRef:WorkPackageCardViewComponent) {
this.cardView = componentRef;
}
public destroy() {
if (this.dragService !== null) {
this.dragService.remove(this.cardView.container.nativeElement);
}
}
public registerDragAndDrop() {
// The DragService may not have been provided
// in which case we do not provide drag and drop
if (this.dragService === null) {
return;
}
this.dragService.register({
dragContainer: this.cardView.container.nativeElement,
scrollContainers: [this.cardView.container.nativeElement],
moves: (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const workPackage = this.states.workPackages.get(wpId).value!;
return this.cardView.canDragOutOf(workPackage) && !card.dataset.isNew;
},
accepts: () => this.cardView.dragInto,
onMoved: async (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const toIndex = DragAndDropHelpers.findIndex(card);
const newOrder = await this.reorderService.move(this.currentOrder, wpId, toIndex);
this.updateOrder(newOrder);
this.cardView.onMoved.emit();
},
onRemoved: (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const newOrder = this.reorderService.remove(this.currentOrder, wpId);
this.updateOrder(newOrder);
},
onAdded: async (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const toIndex = DragAndDropHelpers.findIndex(card);
const workPackage = this.states.workPackages.get(wpId).value!;
const result = await this.addWorkPackageToQuery(workPackage, toIndex);
card.parentElement!.removeChild(card);
return result;
}
});
}
/**
* Get the current work packages
*/
public get workPackages():WorkPackageResource[] {
return this._workPackages;
}
/**
* Set work packages array,
* remembering to keep the active inline-create
*/
public set workPackages(workPackages:WorkPackageResource[]) {
if (this.activeInlineCreateWp) {
let existingNewWp = this._workPackages.find(o => o.isNew);
// If there is already a card for a new WP,
// we have to replace this one by the new activeInlineCreateWp
if (existingNewWp) {
let index = this._workPackages.indexOf(existingNewWp);
this._workPackages[index] = this.activeInlineCreateWp;
} else {
this._workPackages = [this.activeInlineCreateWp, ...workPackages];
}
} else {
this._workPackages = [...workPackages];
}
}
/**
* Get current order
*/
private get currentOrder():string[] {
return this.workPackages
.filter(wp => wp && !wp.isNew)
.map(el => el.id!);
}
/**
* Update current order
*/
private updateOrder(newOrder:string[]) {
newOrder = _.uniq(newOrder);
this.workPackages = newOrder.map(id => this.states.workPackages.get(id).value!);
this.cardView.cdRef.detectChanges();
}
/**
* Inline create a new card
*/
public addNewCard() {
this.wpCreate
.createOrContinueWorkPackage(this.currentProject.identifier)
.then((changeset:WorkPackageChangeset) => {
this.activeInlineCreateWp = changeset.resource;
this.workPackages = this.workPackages;
this.cardView.cdRef.detectChanges();
});
}
/**
* Add the given work package to the query
*/
async addWorkPackageToQuery(workPackage:WorkPackageResource, toIndex:number = -1):Promise<boolean> {
try {
await this.cardView.workPackageAddedHandler(workPackage);
const newOrder = await this.reorderService.add(this.currentOrder, workPackage.id!, toIndex);
this.updateOrder(newOrder);
return true;
} catch (e) {
this.wpNotifications.handleRawError(e, workPackage);
}
return false;
}
/**
* Remove the new card
*/
removeCard(wp:WorkPackageResource) {
const index = this.workPackages.indexOf(wp);
this.workPackages.splice(index, 1);
this.activeInlineCreateWp = undefined;
if (!wp.isNew) {
const newOrder = this.reorderService.remove(this.currentOrder, wp.id!);
this.updateOrder(newOrder);
}
}
/**
* On new card saved
*/
async onCardSaved(wp:WorkPackageResource) {
if (this.activeInlineCreateWp && this.activeInlineCreateWp.__initialized_at === wp.__initialized_at) {
const index = this.workPackages.indexOf(this.activeInlineCreateWp);
this.activeInlineCreateWp = undefined;
// Add this item to the results
const newOrder = await this.reorderService.add(this.currentOrder, wp.id!, index);
this.updateOrder(newOrder);
// Notify inline create service
this.wpInlineCreate.newInlineWorkPackageCreated.next(wp.id!);
}
}
}

@ -0,0 +1,37 @@
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {Injectable} from '@angular/core';
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {RenderedRow} from "core-components/wp-fast-table/builders/primary-render-pass";
@Injectable()
export class WorkPackageCardViewService {
public constructor(readonly querySpace:IsolatedQuerySpace) {
}
public classIdentifier(wp:WorkPackageResource) {
// The same class names are used for the proximity to the table representation.
return `wp-row-${wp.id}`;
}
public get renderedCards() {
return this.querySpace.rendered.getValueOr([]);
}
public findRenderedCard(classIdentifier:string):number {
const index = _.findIndex(this.renderedCards, (card) => card.classIdentifier === classIdentifier);
return index;
}
public updateRenderedCardsValues(workPackages:WorkPackageResource[]) {
this.querySpace.rendered.putValue(
workPackages.map((wp) => {
return {
classIdentifier: this.classIdentifier(wp),
workPackageId: wp.id,
hidden: false
} as RenderedRow;
})
)
}
}

@ -20,6 +20,9 @@
&.-new
padding-right: 25px
&.-checked
background-color: var(--table-row-highlighting-color)
.wp-card--content:not(.-new)
display: grid
grid-template-columns: auto 1fr auto
@ -64,7 +67,7 @@ wp-edit-field
padding-top: 1rem
text-align: center
.wp-card--inline-cancel-button
.wp-card--inline-buttons
position: absolute
right: 0
top: 5px

@ -12,21 +12,29 @@
*ngFor="let wp of workPackages; trackBy:trackByHref"
[attr.data-is-new]="wp.isNew || undefined"
[attr.data-work-package-id]="wp.id"
(doubleClickOrTap)="handleDblClick(wp)"
[ngClass]="{'-draggable': canDragOutOf(wp), '-new' : wp.isNew }">
[attr.data-class-identifier]="classIdentifier(wp)"
[ngClass]="cardClasses(wp)">
<div class="wp-card--highlighting"
[ngClass]="cardHighlightingClass(wp)">
</div>
<a class="wp-card--inline-cancel-button -no-decoration"
*ngIf="wp.isNew || cardsRemovable"
[ngClass]="{ '-show': wp.isNew }"
[title]="text.removeCard"
(accessibleClick)="removeCard(wp)">
<op-icon icon-classes="icon icon-close"></op-icon>
</a>
<div class="wp-card--inline-buttons">
<a class="wp-card--inline-cancel-button -no-decoration"
*ngIf="wp.isNew || cardsRemovable"
[ngClass]="{ '-show': wp.isNew }"
[title]="text.removeCard"
(accessibleClick)="removeCard(wp)">
<op-icon icon-classes="icon icon-close"></op-icon>
</a>
<a class="-no-decoration"
*ngIf="!wp.isNew && showInfoButton"
[title]="text.detailsView"
(accessibleClick)="openSplitScreen(wp)">
<op-icon icon-classes="icon icon-info2"></op-icon>
</a>
</div>
<wp-edit-field-group [workPackage]="wp"
[inEditMode]="wp.isNew"
@ -53,7 +61,8 @@
[ngClass]="typeHighlightingClass(wp)"></span>
<a uiSref="work-packages.show"
[uiParams]="{workPackageId: wp.id}"
class="wp-card--id">
class="wp-card--id"
[ngClass]="uiStateLinkClass">
#{{wp.id}}
</a>
<span [textContent]="wpSubject(wp)"

@ -1,15 +1,13 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
ElementRef, EventEmitter,
Inject,
Injector,
Input,
OnInit,
Output,
ViewChild,
EventEmitter
OnInit, Output, ViewChild
} from "@angular/core";
import {QueryResource} from 'core-app/modules/hal/resources/query-resource';
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
@ -17,53 +15,58 @@ import {componentDestroyed, untilComponentDestroyed} from "ng2-rx-componentdestr
import {QueryColumn} from "app/components/wp-query/query-column";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {WorkPackageInlineCreateService} from "core-components/wp-inline-create/wp-inline-create.service";
import {IWorkPackageCreateServiceToken} from "core-components/wp-new/wp-create.service.interface";
import {WorkPackageCreateService} from "core-components/wp-new/wp-create.service";
import {AngularTrackingHelpers} from "core-components/angular/tracking-functions";
import {WorkPackageNotificationService} from "core-components/wp-edit/wp-notification.service";
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {CardHighlightingMode} from "core-components/wp-fast-table/builders/highlighting/highlighting-mode.const";
import {AuthorisationService} from "core-app/modules/common/model-auth/model-auth.service";
import {StateService} from "@uirouter/core";
import {States} from "core-components/states.service";
import {DragAndDropService} from "core-app/modules/common/drag-and-drop/drag-and-drop.service";
import {DragAndDropHelpers} from "core-app/modules/common/drag-and-drop/drag-and-drop.helpers";
import {WorkPackageTableOrderService} from "core-components/wp-fast-table/state/wp-table-order.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {filter} from 'rxjs/operators';
import {CausedUpdatesService} from "core-app/modules/boards/board/caused-updates/caused-updates.service";
import {WorkPackageTableOrderService} from "core-components/wp-fast-table/state/wp-table-order.service";
import {WorkPackageTableSelection} from "core-components/wp-fast-table/state/wp-table-selection.service";
import {CardViewHandlerRegistry} from "core-components/wp-card-view/event-handler/card-view-handler-registry";
import {WorkPackageCardViewService} from "core-components/wp-card-view/services/wp-card-view.service";
import {WorkPackageCardDragAndDropService} from "core-components/wp-card-view/services/wp-card-drag-and-drop.service";
import {uiStateLinkClass, checkedClassName} from "core-components/wp-fast-table/builders/ui-state-link-builder";
export type CardViewOrientation = 'horizontal'|'vertical';
@Component({
selector: 'wp-card-view',
styleUrls: ['./wp-card-view.component.sass', './wp-card-view-horizontal.sass', './wp-card-view-vertical.sass'],
styleUrls: ['./styles/wp-card-view.component.sass', './styles/wp-card-view-horizontal.sass', './styles/wp-card-view-vertical.sass'],
templateUrl: './wp-card-view.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkPackageCardViewComponent implements OnInit {
export class WorkPackageCardViewComponent implements OnInit, AfterViewInit {
@Input('dragOutOfHandler') public canDragOutOf:(wp:WorkPackageResource) => boolean;
@Input() public dragInto:boolean;
@Input() public highlightingMode:CardHighlightingMode;
@Input() public workPackageAddedHandler:(wp:WorkPackageResource) => Promise<unknown>;
@Input() public showStatusButton:boolean = true;
@Input() public showInfoButton:boolean = false;
@Input() public orientation:CardViewOrientation = 'vertical';
/** Whether cards are removable */
@Input() public cardsRemovable:boolean = false;
/** Whether a notification box shall be shown when there are no WP to display */
@Input() public showEmptyResultsBox:boolean = false;
/** Whether the first element of the view shall be marked as selected. */
@Input() public showInitialSelection:boolean = false;
/** Container reference */
@ViewChild('container', { static: true }) public container:ElementRef;
@Output() onMoved = new EventEmitter<void>();
@Output() public onMoved = new EventEmitter<void>();
public trackByHref = AngularTrackingHelpers.trackByHrefAndProperty('lockVersion');
public query:QueryResource;
private _workPackages:WorkPackageResource[] = [];
public isResultEmpty:boolean = false;
public columns:QueryColumn[];
public text = {
@ -73,8 +76,12 @@ export class WorkPackageCardViewComponent implements OnInit {
title: this.I18n.t('js.work_packages.no_results.title'),
description: this.I18n.t('js.work_packages.no_results.description')
},
detailsView: this.I18n.t('js.button_open_details')
};
public uiStateLinkClass:string = uiStateLinkClass;
public checkedClassName:string = checkedClassName;
/** Inline create / reference properties */
public canAdd = false;
public canReference = false;
@ -84,32 +91,27 @@ export class WorkPackageCardViewComponent implements OnInit {
// but map the following output
public referenceOutputs = {
onCancel: () => this.setReferenceMode(false),
onReferenced: (wp:WorkPackageResource) => this.addWorkPackageToQuery(wp, 0)
onReferenced: (wp:WorkPackageResource) => this.cardDragDrop.addWorkPackageToQuery(wp, 0)
};
/** Whether the card view has an active inline created wp */
public activeInlineCreateWp?:WorkPackageResource;
constructor(readonly querySpace:IsolatedQuerySpace,
readonly states:States,
readonly injector:Injector,
readonly $state:StateService,
readonly I18n:I18nService,
readonly currentProject:CurrentProjectService,
@Inject(IWorkPackageCreateServiceToken) readonly wpCreate:WorkPackageCreateService,
readonly wpInlineCreate:WorkPackageInlineCreateService,
readonly wpNotifications:WorkPackageNotificationService,
readonly dragService:DragAndDropService,
readonly reorderService:WorkPackageTableOrderService,
readonly authorisationService:AuthorisationService,
readonly causedUpdates:CausedUpdatesService,
readonly cdRef:ChangeDetectorRef,
readonly pathHelper:PathHelperService) {
readonly pathHelper:PathHelperService,
readonly wpTableSelection:WorkPackageTableSelection,
readonly cardView:WorkPackageCardViewService,
readonly cardDragDrop:WorkPackageCardDragAndDropService) {
}
ngOnInit() {
this.registerDragAndDrop();
this.registerCreationCallback();
// Update permission on model updates
@ -132,20 +134,48 @@ export class WorkPackageCardViewComponent implements OnInit {
this.isResultEmpty = this.workPackages.length === 0;
this.cdRef.detectChanges();
});
// Update selection state
this.wpTableSelection.selection$()
.pipe(
untilComponentDestroyed(this)
)
.subscribe(() => {
this.cdRef.detectChanges();
});
}
ngOnDestroy():void {
this.dragService.remove(this.container.nativeElement);
ngAfterViewInit() {
// In case "rendered" was not already set by the list
if (this.cardView.renderedCards.length === 0) {
this.cardView.updateRenderedCardsValues(this.workPackages);
}
// Register Drag & Drop
this.cardDragDrop.init(this);
this.cardDragDrop.registerDragAndDrop();
// Register event handlers for the cards
new CardViewHandlerRegistry(this.injector).attachTo(this);
this.wpTableSelection.registerSelectAllListener(() => { return this.cardView.renderedCards; });
this.wpTableSelection.registerDeselectAllListener();
// Do not highlight selected elements on initial load
if (!this.showInitialSelection) {
this.wpTableSelection.reset();
}
}
public handleDblClick(wp:WorkPackageResource) {
this.goToWpFullView(wp.id!);
ngOnDestroy():void {
this.cardDragDrop.destroy();
}
private goToWpFullView(wpId:string) {
public openSplitScreen(wp:WorkPackageResource) {
let classIdentifier = this.classIdentifier(wp);
this.wpTableSelection.setSelection(wp.id!, this.cardView.findRenderedCard(classIdentifier));
this.$state.go(
'work-packages.show',
{workPackageId: wpId}
'work-packages.list.details',
{workPackageId: wp.id!}
);
}
@ -157,6 +187,14 @@ export class WorkPackageCardViewComponent implements OnInit {
return wp.subject;
}
public isSelected(wp:WorkPackageResource):boolean {
return this.wpTableSelection.isSelected(wp.id!);
}
public classIdentifier(wp:WorkPackageResource) {
return this.cardView.classIdentifier(wp);
}
public bcfSnapshotPath(wp:WorkPackageResource) {
let vp = _.get(wp, 'bcf.viewpoints[0]');
if (vp) {
@ -166,6 +204,15 @@ export class WorkPackageCardViewComponent implements OnInit {
}
}
public cardClasses(wp:WorkPackageResource) {
let classes = this.isSelected(wp) ? checkedClassName : '';
classes += this.canDragOutOf(wp) ? ' -draggable' : '';
classes += wp.isNew ? ' -new' : '';
classes += ' wp-card-' + wp.id;
return classes;
}
public cardHighlightingClass(wp:WorkPackageResource) {
return this.cardHighlighting(wp);
}
@ -185,121 +232,12 @@ export class WorkPackageCardViewComponent implements OnInit {
return Highlighting.inlineClass(type, wp.type.id!);
}
registerDragAndDrop() {
this.dragService.register({
dragContainer: this.container.nativeElement,
scrollContainers: [this.container.nativeElement],
moves: (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const workPackage = this.states.workPackages.get(wpId).value!;
return this.canDragOutOf(workPackage) && !card.dataset.isNew;
},
accepts: () => this.dragInto,
onMoved: async (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const toIndex = DragAndDropHelpers.findIndex(card);
const newOrder = await this.reorderService.move(this.currentOrder, wpId, toIndex);
this.updateOrder(newOrder);
this.onMoved.emit();
},
onRemoved: (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const newOrder = this.reorderService.remove(this.currentOrder, wpId);
this.updateOrder(newOrder);
},
onAdded: async (card:HTMLElement) => {
const wpId:string = card.dataset.workPackageId!;
const toIndex = DragAndDropHelpers.findIndex(card);
const workPackage = this.states.workPackages.get(wpId).value!;
const result = await this.addWorkPackageToQuery(workPackage, toIndex);
card.parentElement!.removeChild(card);
return result;
}
});
}
/**
* Get current order
*/
private get currentOrder():string[] {
return this.workPackages
.filter(wp => wp && !wp.isNew)
.map(el => el.id!);
}
/**
* Update current order
*/
private updateOrder(newOrder:string[]) {
newOrder = _.uniq(newOrder);
this.workPackages = newOrder.map(id => this.states.workPackages.get(id).value!);
this.cdRef.detectChanges();
}
/**
* Get the current work packages
*/
public get workPackages():WorkPackageResource[] {
return this._workPackages;
return this.cardDragDrop.workPackages;
}
/**
* Set work packages array,
* remembering to keep the active inline-create
*/
public set workPackages(workPackages:WorkPackageResource[]) {
if (this.activeInlineCreateWp) {
let existingNewWp = this._workPackages.find(o => o.isNew);
// If there is already a card for a new WP,
// we have to replace this one by the new activeInlineCreateWp
if (existingNewWp) {
let index = this._workPackages.indexOf(existingNewWp);
this._workPackages[index] = this.activeInlineCreateWp;
} else {
this._workPackages = [this.activeInlineCreateWp, ...workPackages];
}
} else {
this._workPackages = [...workPackages];
}
}
/**
* Add the given work package to the query
*/
async addWorkPackageToQuery(workPackage:WorkPackageResource, toIndex:number = -1):Promise<boolean> {
try {
await this.workPackageAddedHandler(workPackage);
const newOrder = await this.reorderService.add(this.currentOrder, workPackage.id!, toIndex);
this.updateOrder(newOrder);
return true;
} catch (e) {
this.wpNotifications.handleRawError(e, workPackage);
}
return false;
}
/**
* Inline create a new card
*/
public addNewCard() {
this.wpCreate
.createOrContinueWorkPackage(this.currentProject.identifier)
.then((changeset:WorkPackageChangeset) => {
this.activeInlineCreateWp = changeset.resource;
this.workPackages = this.workPackages;
this.cdRef.detectChanges();
});
this.cardDragDrop.workPackages = workPackages;
}
public setReferenceMode(mode:boolean) {
@ -307,37 +245,17 @@ export class WorkPackageCardViewComponent implements OnInit {
this.cdRef.detectChanges();
}
/**
* Remove the new card
*/
removeCard(wp:WorkPackageResource) {
const index = this.workPackages.indexOf(wp);
this.workPackages.splice(index, 1);
this.activeInlineCreateWp = undefined;
if (!wp.isNew) {
const newOrder = this.reorderService.remove(this.currentOrder, wp.id!);
this.updateOrder(newOrder);
}
public addNewCard() {
this.cardDragDrop.addNewCard();
}
/**
* On new card saved
*/
async onCardSaved(wp:WorkPackageResource) {
if (this.activeInlineCreateWp && this.activeInlineCreateWp.__initialized_at === wp.__initialized_at) {
const index = this.workPackages.indexOf(this.activeInlineCreateWp);
this.activeInlineCreateWp = undefined;
// Add this item to the results
const newOrder = await this.reorderService.add(this.currentOrder, wp.id!, index);
this.updateOrder(newOrder);
// Notify inline create service
this.wpInlineCreate.newInlineWorkPackageCreated.next(wp.id!);
}
public removeCard(wp:WorkPackageResource) {
this.cardDragDrop.removeCard(wp);
}
async onCardSaved(wp:WorkPackageResource) {
await this.cardDragDrop.onCardSaved(wp);
}
/**
* Listen to newly created work packages to detect whether the WP is the one we created,

@ -28,7 +28,7 @@ export abstract class ContextMenuHandler implements TableEventHandler {
public abstract handleEvent(table:WorkPackageTable, evt:JQueryEventObject):boolean;
protected openContextMenu(evt:JQueryEventObject, workPackageId:string, positionArgs?:any):void {
const handler = new OpWorkPackageContextMenu(this.injector, this.table, workPackageId, jQuery(evt.target) as JQuery, positionArgs);
const handler = new OpWorkPackageContextMenu(this.injector, workPackageId, jQuery(evt.target) as JQuery, positionArgs, this.table);
this.opContextMenu.show(handler, evt);
}
}

@ -7,7 +7,6 @@ import {locateTableRow, scrollTableRowIntoView} from '../../helpers/wp-table-row
import {WorkPackageTableSelection} from '../../state/wp-table-selection.service';
import {WorkPackageTable} from '../../wp-fast-table';
import {WPTableRowSelectionState} from '../../wp-table.interfaces';
import {OPContextMenuService} from "core-components/op-context-menu/op-context-menu.service";
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {FocusHelperService} from 'core-app/modules/common/focus/focus-helper';
@ -17,7 +16,6 @@ export class SelectionTransformer {
public wpTableFocus:WorkPackageTableFocusService = this.injector.get(WorkPackageTableFocusService);
public querySpace:IsolatedQuerySpace = this.injector.get(IsolatedQuerySpace);
public FocusHelper:FocusHelperService = this.injector.get(FocusHelperService);
public opContextMenu:OPContextMenuService = this.injector.get(OPContextMenuService);
constructor(public readonly injector:Injector,
public readonly table:WorkPackageTable) {
@ -48,22 +46,9 @@ export class SelectionTransformer {
this.renderSelectionState(state);
});
// Bind CTRL+A to select all work packages
Mousetrap.bind(['command+a', 'ctrl+a'], (e) => {
this.wpTableSelection.selectAll(table.renderedRows);
e.preventDefault();
this.opContextMenu.close();
return false;
});
// Bind CTRL+D to deselect all work packages
Mousetrap.bind(['command+d', 'ctrl+d'], (e) => {
this.wpTableSelection.reset();
this.opContextMenu.close();
e.preventDefault();
return false;
});
this.wpTableSelection.registerSelectAllListener(() => { return table.renderedRows; });
this.wpTableSelection.registerDeselectAllListener();
}
/**

@ -3,9 +3,10 @@ import {RenderedRow} from '../builders/primary-render-pass';
import {input} from 'reactivestates';
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {WorkPackageCacheService} from 'core-components/work-packages/work-package-cache.service';
import {Injectable} from '@angular/core';
import {Injectable, Injector} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {States} from 'core-components/states.service';
import {OPContextMenuService} from "core-components/op-context-menu/op-context-menu.service";
@Injectable()
export class WorkPackageTableSelection {
@ -14,8 +15,8 @@ export class WorkPackageTableSelection {
public constructor(readonly querySpace:IsolatedQuerySpace,
readonly states:States,
readonly wpCacheService:WorkPackageCacheService) {
readonly wpCacheService:WorkPackageCacheService,
readonly opContextMenu:OPContextMenuService) {
this.reset();
}
@ -151,6 +152,27 @@ export class WorkPackageTableSelection {
this.selectionState.putValue(state);
}
public registerSelectAllListener(renderedElements: () => RenderedRow[]) {
// Bind CTRL+A to select all work packages
Mousetrap.bind(['command+a', 'ctrl+a'], (e) => {
this.selectAll(renderedElements());
e.preventDefault();
this.opContextMenu.close();
return false;
});
}
public registerDeselectAllListener () {
// Bind CTRL+D to deselect all work packages
Mousetrap.bind(['command+d', 'ctrl+d'], (e) => {
this.reset();
e.preventDefault();
this.opContextMenu.close();
return false;
});
}
private get _emptyState():WPTableRowSelectionState {
return {

@ -33,6 +33,8 @@ import {WorkPackageTableSortByService} from "core-components/wp-fast-table/state
import {distinctUntilChanged, takeUntil} from "rxjs/operators";
import {HighlightingMode} from "core-components/wp-fast-table/builders/highlighting/highlighting-mode.const";
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {DragAndDropService} from "core-app/modules/common/drag-and-drop/drag-and-drop.service";
import {WorkPackageCardDragAndDropService} from "core-components/wp-card-view/services/wp-card-drag-and-drop.service";
@Component({
selector: 'wp-grid',
@ -44,10 +46,16 @@ import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/iso
[showStatusButton]="false"
[orientation]="gridOrientation"
(onMoved)="switchToManualSorting()"
[showEmptyResultsBox]="true">
[showEmptyResultsBox]="true"
[showInfoButton]="true"
[showInitialSelection]="true">
</wp-card-view>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
DragAndDropService,
WorkPackageCardDragAndDropService
]
})
export class WorkPackagesGridComponent {
public canDragOutOf = () => { return true; };

@ -77,12 +77,12 @@ export class WorkPackageContextMenuHelperService {
private PathHelper:PathHelperService) {
}
public getPermittedActionLinks(workPackage:WorkPackageResource, permittedActionConstants:any):WorkPackageAction[] {
public getPermittedActionLinks(workPackage:WorkPackageResource, permittedActionConstants:any, allowSplitScreenActions:boolean):WorkPackageAction[] {
let singularPermittedActions: any[] = [];
let allowedActions = this.getAllowedActions(workPackage, permittedActionConstants);
allowedActions = allowedActions.concat(this.getAllowedRelationActions(workPackage));
allowedActions = allowedActions.concat(this.getAllowedRelationActions(workPackage, allowSplitScreenActions));
_.each(allowedActions, (allowedAction) => {
singularPermittedActions.push({
@ -151,7 +151,7 @@ export class WorkPackageContextMenuHelperService {
return allowedActions;
}
private getAllowedRelationActions(workPackage:WorkPackageResource) {
private getAllowedRelationActions(workPackage:WorkPackageResource, allowSplitScreenActions:boolean) {
let allowedActions: WorkPackageAction[] = [];
if (workPackage.addRelation && this.wpTableTimeline.isVisible) {
@ -167,7 +167,7 @@ export class WorkPackageContextMenuHelperService {
});
}
if (!!workPackage.addChild) {
if (!!workPackage.addChild && allowSplitScreenActions) {
allowedActions.push({
key: "relation-new-child",
text: I18n.t("js.relation_buttons.add_new_child"),
@ -179,9 +179,9 @@ export class WorkPackageContextMenuHelperService {
}
public getPermittedActions(workPackages:WorkPackageResource[], permittedActionConstants:any):WorkPackageAction[] {
public getPermittedActions(workPackages:WorkPackageResource[], permittedActionConstants:any, allowSplitScreenActions:boolean):WorkPackageAction[] {
if (workPackages.length === 1) {
return this.getPermittedActionLinks(workPackages[0], permittedActionConstants);
return this.getPermittedActionLinks(workPackages[0], permittedActionConstants, allowSplitScreenActions);
} else {
return this.getIntersectOfPermittedActions(workPackages);
}

@ -47,6 +47,7 @@ import {IFieldSchema} from "core-app/modules/fields/field.base";
import {CausedUpdatesService} from "core-app/modules/boards/board/caused-updates/caused-updates.service";
import {BoardListMenuComponent} from "core-app/modules/boards/board/board-list/board-list-menu.component";
import {debugLog} from "core-app/helpers/debug_output";
import {WorkPackageCardDragAndDropService} from "core-components/wp-card-view/services/wp-card-drag-and-drop.service";
export interface DisabledButtonPlaceholder {
text:string;
@ -60,6 +61,7 @@ export interface DisabledButtonPlaceholder {
providers: [
{provide: WorkPackageInlineCreateService, useClass: BoardInlineCreateService},
BoardListMenuComponent,
WorkPackageCardDragAndDropService
]
})
export class BoardListComponent extends AbstractWidgetComponent implements OnInit, OnDestroy, OnChanges {

@ -7,6 +7,6 @@
id: handler.htmlId,
createAllowed: false,
finishedLoading: true,
classes: 'wp-inline-edit--field' }"
classes: 'wp-inline-edit--field ' + handler.fieldName }"
[ndcDynamicOutputs]="referenceOutputs">
</ndc-dynamic>

@ -63,6 +63,7 @@ import {TableDragActionsRegistryService} from "core-components/wp-table/drag-and
import {WorkPackageTableOrderService} from "core-components/wp-fast-table/state/wp-table-order.service";
import {CausedUpdatesService} from "core-app/modules/boards/board/caused-updates/caused-updates.service";
import {WorkPackageDisplayRepresentationService} from "core-components/wp-fast-table/state/work-package-display-representation.service";
import {WorkPackageCardViewService} from "core-components/wp-card-view/services/wp-card-view.service";
/**
* Directive to open a work package query 'space', an isolated injector hierarchy
@ -111,6 +112,8 @@ import {WorkPackageDisplayRepresentationService} from "core-components/wp-fast-t
WpChildrenInlineCreateService,
WpRelationInlineCreateService,
WorkPackageCardViewService,
// Provide both serves with tokens to avoid tight dependency cycles
{ provide: IWorkPackageCreateServiceToken, useClass: WorkPackageCreateService },
{ provide: IWorkPackageEditingServiceToken, useClass: WorkPackageEditingService },

@ -56,6 +56,7 @@ describe 'Copy work packages through Rails view', js: true do
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let(:context_menu) { Components::WorkPackages::ContextMenu.new }
let(:display_representation) { ::Components::WorkPackages::DisplayRepresentation.new }
before do
login_as current_user
@ -115,4 +116,29 @@ describe 'Copy work packages through Rails view', js: true do
end
end
end
describe 'accessing the bulk copy from the card view' do
before do
display_representation.switch_to_card_layout
loading_indicator_saveguard
end
context 'with permissions' do
let(:current_user) { mover }
it 'does allow to copy' do
context_menu.open_for work_package, false
context_menu.expect_options ['Bulk copy']
end
end
context 'without permission' do
let(:current_user) { dev }
it 'does not allow to copy' do
context_menu.open_for work_package, false
context_menu.expect_no_options ['Bulk copy']
end
end
end
end

@ -57,6 +57,7 @@ describe 'Moving a work package through Rails view', js: true do
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let(:context_menu) { Components::WorkPackages::ContextMenu.new }
let(:display_representation) { ::Components::WorkPackages::DisplayRepresentation.new }
before do
login_as current_user
@ -120,4 +121,30 @@ describe 'Moving a work package through Rails view', js: true do
end
end
end
describe 'accessing the bulk move from the card view' do
before do
display_representation.switch_to_card_layout
loading_indicator_saveguard
find('body').send_keys [:control, 'a']
end
context 'with permissions' do
let(:current_user) { mover }
it 'does allow to move' do
context_menu.open_for work_package, false
context_menu.expect_options ['Bulk move']
end
end
context 'without permission' do
let(:current_user) { dev }
it 'does not allow to move' do
context_menu.open_for work_package, false
context_menu.expect_no_options ['Bulk move']
end
end
end
end

@ -57,6 +57,7 @@ describe 'Bulk update work packages through Rails view', js: true do
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let(:context_menu) { Components::WorkPackages::ContextMenu.new }
let(:display_representation) { ::Components::WorkPackages::DisplayRepresentation.new }
before do
login_as current_user
@ -121,4 +122,29 @@ describe 'Bulk update work packages through Rails view', js: true do
end
end
end
describe 'accessing the bulk edit from the card view' do
before do
display_representation.switch_to_card_layout
loading_indicator_saveguard
end
context 'with permissions' do
let(:current_user) { mover }
it 'does allow to edit' do
context_menu.open_for work_package, false
context_menu.expect_options ['Bulk edit']
end
end
context 'without permission' do
let(:current_user) { dev }
it 'does not allow to edit' do
context_menu.open_for work_package, false
context_menu.expect_no_options ['Bulk edit']
end
end
end
end

@ -0,0 +1,71 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF)
#
# 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'
describe 'Select work package card', type: :feature, js: true, selenium: true do
let(:user) { FactoryBot.create(:admin) }
let(:project) { FactoryBot.create(:project) }
let(:work_package_1) { FactoryBot.create(:work_package, project: project) }
let(:work_package_2) { FactoryBot.create(:work_package, project: project) }
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let(:wp_card_view) { WorkPackageCards.new(project) }
let(:display_representation) { ::Components::WorkPackages::DisplayRepresentation.new }
before do
login_as(user)
work_package_1
work_package_2
wp_table.visit!
wp_table.expect_work_package_listed(work_package_1)
wp_table.expect_work_package_listed(work_package_2)
display_representation.switch_to_card_layout
end
describe 'opening' do
it 'the full screen view via double click' do
wp_card_view.open_full_screen_by_doubleclick(work_package_1)
expect(page).to have_selector('.work-packages--details--subject',
text: work_package_1.subject)
end
it 'the split screen of the selected WP' do
wp_card_view.select_work_package(work_package_2)
find('#work-packages-details-view-button').click
split_wp = Pages::SplitWorkPackage.new(work_package_2)
split_wp.expect_attributes Subject: work_package_2.subject
find('#work-packages-details-view-button').click
expect(page).to have_no_selector('.work-packages--details')
end
end
end

@ -8,15 +8,101 @@ describe 'Work package table context menu', js: true do
let(:wp_timeline) { Pages::WorkPackagesTimeline.new(work_package.project) }
let(:menu) { Components::WorkPackages::ContextMenu.new }
let(:destroy_modal) { Components::WorkPackages::DestroyModal.new }
let(:display_representation) { ::Components::WorkPackages::DisplayRepresentation.new }
def goto_context_menu
def goto_context_menu list_view = true
# Go to table
wp_table.visit!
wp_table.expect_work_package_listed(work_package)
display_representation.switch_to_card_layout unless list_view
loading_indicator_saveguard
# Open context menu
menu.expect_closed
menu.open_for(work_package)
menu.open_for(work_package, list_view)
end
shared_examples_for 'provides a context menu' do
let(:list_view) { raise 'needs to be defined' }
context 'for a single work package' do
it 'provide a context menu' do
# Open detail pane
goto_context_menu list_view
menu.choose('Open details view')
split_page = Pages::SplitWorkPackage.new(work_package)
split_page.expect_attributes Subject: work_package.subject
# Open full view
goto_context_menu list_view
menu.choose('Open fullscreen view')
expect(page).to have_selector('.work-packages--show-view .wp-edit-field.subject',
text: work_package.subject)
# Open log time
goto_context_menu list_view
menu.choose('Log time')
expect(page).to have_selector('h2', text: I18n.t(:label_spent_time))
# Open Move
goto_context_menu list_view
menu.choose('Move')
expect(page).to have_selector('h2', text: I18n.t(:button_move))
expect(page).to have_selector('a.issue', text: "##{work_package.id}")
# Open Copy
goto_context_menu list_view
menu.choose('Copy')
# Split view open in copy state
expect(page).
to have_selector('.wp-new-top-row',
text: "#{work_package.status.name.capitalize}\n#{work_package.type.name.upcase}")
expect(page).to have_field('wp-new-inline-edit--field-subject', with: work_package.subject)
# Open Delete
goto_context_menu list_view
menu.choose('Delete')
destroy_modal.expect_listed(work_package)
destroy_modal.cancel_deletion
# Open create new child
goto_context_menu list_view
menu.choose('Create new child')
expect(page).to have_selector('.wp-edit-field.subject input')
expect(page).to have_selector('.wp-inline-edit--field.type')
find('#work-packages--edit-actions-cancel').click
expect(page).to have_no_selector('.wp-edit-field.subject input')
# Timeline actions only shown when open
wp_timeline.expect_timeline!(open: false)
goto_context_menu list_view
menu.expect_no_options 'Add predecessor', 'Add follower'
end
end
context 'for multiple selected WPs' do
let!(:work_package2) { FactoryBot.create(:work_package) }
it 'provides a context menu with a subset of the available menu items' do
# Go to table
wp_table.visit!
wp_table.expect_work_package_listed(work_package)
wp_table.expect_work_package_listed(work_package2)
display_representation.switch_to_card_layout unless list_view
loading_indicator_saveguard
# Select all WPs
find('body').send_keys [:control, 'a']
menu.open_for(work_package, list_view)
menu.expect_options ['Open details view', 'Open fullscreen view',
'Bulk edit', 'Bulk copy', 'Bulk move', 'Bulk delete']
end
end
end
before do
@ -24,87 +110,27 @@ describe 'Work package table context menu', js: true do
work_package
end
it 'provides a context menu for a single work package' do
# Open detail pane
goto_context_menu
menu.choose('Open details view')
split_page = Pages::SplitWorkPackage.new(work_package)
split_page.expect_attributes Subject: work_package.subject
# Open full view
goto_context_menu
menu.choose('Open fullscreen view')
expect(page).to have_selector('.work-packages--show-view .wp-edit-field.subject',
text: work_package.subject)
# Open log time
goto_context_menu
menu.choose('Log time')
expect(page).to have_selector('h2', text: I18n.t(:label_spent_time))
# Open Move
goto_context_menu
menu.choose('Move')
expect(page).to have_selector('h2', text: I18n.t(:button_move))
expect(page).to have_selector('a.issue', text: "##{work_package.id}")
# Open Copy
goto_context_menu
menu.choose('Copy')
# Split view open in copy state
expect(page).
to have_selector('.wp-new-top-row',
text: "#{work_package.status.name.capitalize}\n#{work_package.type.name.upcase}")
expect(page).to have_field('wp-new-inline-edit--field-subject', with: work_package.subject)
# Open Delete
goto_context_menu
menu.choose('Delete')
destroy_modal.expect_listed(work_package)
destroy_modal.cancel_deletion
# Open create new child
goto_context_menu
menu.choose('Create new child')
expect(page).to have_selector('.wp-edit-field.subject input')
expect(page).to have_selector('.wp-edit-field--display-field.type')
find('#work-packages--edit-actions-cancel').click
expect(page).to have_no_selector('.wp-edit-field.subject input')
# Timeline actions only shown when open
wp_timeline.expect_timeline!(open: false)
goto_context_menu
menu.expect_no_options 'Add predecessor', 'Add follower'
# Open timeline
wp_timeline.toggle_timeline
wp_timeline.expect_timeline!(open: true)
# Open context menu
menu.expect_closed
menu.open_for(work_package)
menu.expect_options ['Add predecessor', 'Add follower']
end
context 'multiple selected' do
let!(:work_package2) { FactoryBot.create(:work_package) }
context 'in the table' do
it_behaves_like 'provides a context menu' do
let(:list_view) { true }
end
before do
# Go to table
wp_table.visit!
wp_table.expect_work_package_listed(work_package)
wp_table.expect_work_package_listed(work_package2)
it 'provides a context menu with timeline options' do
goto_context_menu true
# Open timeline
wp_timeline.toggle_timeline
wp_timeline.expect_timeline!(open: true)
# Select both
find('body').send_keys [:control, 'a']
# Open context menu
menu.expect_closed
menu.open_for(work_package)
menu.expect_options ['Add predecessor', 'Add follower']
end
end
it 'shows a subset of the available menu items' do
menu.open_for(work_package)
menu.expect_options ['Open details view', 'Open fullscreen view',
'Bulk edit', 'Bulk copy', 'Bulk move', 'Bulk delete']
context 'in the card view' do
it_behaves_like 'provides a context menu' do
let(:list_view) { false }
end
end
end

@ -32,8 +32,12 @@ module Components
include Capybara::DSL
include RSpec::Matchers
def open_for(work_package)
find(".wp-row-#{work_package.id}-table").right_click
def open_for(work_package, list_view = true)
if list_view
find(".wp-row-#{work_package.id}-table").right_click
else
find(".wp-card-#{work_package.id}").right_click
end
expect_open
end

@ -0,0 +1,53 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF)
#
# 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'
class WorkPackageCards
include Capybara::DSL
include RSpec::Matchers
attr_reader :project
def initialize(project = nil)
@project = project
end
def open_full_screen_by_doubleclick(work_package)
loading_indicator_saveguard
page.driver.browser.action.double_click(card(work_package).native).perform
Pages::FullWorkPackage.new(work_package, project)
end
def select_work_package(work_package)
card(work_package).click
end
def card(work_package)
page.find(".wp-card-#{work_package.id}")
end
end
Loading…
Cancel
Save