diff --git a/frontend/src/app/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts b/frontend/src/app/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts index 014436c308..1717376ef6 100644 --- a/frontend/src/app/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts +++ b/frontend/src/app/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts @@ -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; diff --git a/frontend/src/app/components/wp-buttons/wp-details-view-button/wp-details-view-button.component.ts b/frontend/src/app/components/wp-buttons/wp-details-view-button/wp-details-view-button.component.ts index 0413c152b9..cb5f9cc43d 100644 --- a/frontend/src/app/components/wp-buttons/wp-details-view-button/wp-details-view-button.component.ts +++ b/frontend/src/app/components/wp-buttons/wp-details-view-button/wp-details-view-button.component.ts @@ -85,7 +85,6 @@ export class WorkPackageDetailsViewButtonComponent extends AbstractWorkPackageBu untilComponentDestroyed(this) ) .subscribe(() => { - this.disabled = this.wpDisplayRepresentationService.current === wpDisplayCardRepresentation; this.cdRef.detectChanges(); }); } diff --git a/frontend/src/app/components/wp-card-view/event-handler/card-view-handler-registry.ts b/frontend/src/app/components/wp-card-view/event-handler/card-view-handler-registry.ts new file mode 100644 index 0000000000..60740c72ca --- /dev/null +++ b/frontend/src/app/components/wp-card-view/event-handler/card-view-handler-registry.ts @@ -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; + }); + } +} diff --git a/frontend/src/app/components/wp-card-view/event-handler/click-handler.ts b/frontend/src/app/components/wp-card-view/event-handler/click-handler.ts new file mode 100644 index 0000000000..faa901d53f --- /dev/null +++ b/frontend/src/app/components/wp-card-view/event-handler/click-handler.ts @@ -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; + } +} + diff --git a/frontend/src/app/components/wp-card-view/event-handler/double-click-handler.ts b/frontend/src/app/components/wp-card-view/event-handler/double-click-handler.ts new file mode 100644 index 0000000000..e06c6678cf --- /dev/null +++ b/frontend/src/app/components/wp-card-view/event-handler/double-click-handler.ts @@ -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; + } +} + diff --git a/frontend/src/app/components/wp-card-view/event-handler/right-click-handler.ts b/frontend/src/app/components/wp-card-view/event-handler/right-click-handler.ts new file mode 100644 index 0000000000..e35cad81b7 --- /dev/null +++ b/frontend/src/app/components/wp-card-view/event-handler/right-click-handler.ts @@ -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; + } +} + diff --git a/frontend/src/app/components/wp-card-view/services/wp-card-drag-and-drop.service.ts b/frontend/src/app/components/wp-card-view/services/wp-card-drag-and-drop.service.ts new file mode 100644 index 0000000000..b624dddd21 --- /dev/null +++ b/frontend/src/app/components/wp-card-view/services/wp-card-drag-and-drop.service.ts @@ -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 { + 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!); + } + } +} diff --git a/frontend/src/app/components/wp-card-view/services/wp-card-view.service.ts b/frontend/src/app/components/wp-card-view/services/wp-card-view.service.ts new file mode 100644 index 0000000000..9fb2bc809a --- /dev/null +++ b/frontend/src/app/components/wp-card-view/services/wp-card-view.service.ts @@ -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; + }) + ) + } +} diff --git a/frontend/src/app/components/wp-card-view/wp-card-view-horizontal.sass b/frontend/src/app/components/wp-card-view/styles/wp-card-view-horizontal.sass similarity index 100% rename from frontend/src/app/components/wp-card-view/wp-card-view-horizontal.sass rename to frontend/src/app/components/wp-card-view/styles/wp-card-view-horizontal.sass diff --git a/frontend/src/app/components/wp-card-view/wp-card-view-vertical.sass b/frontend/src/app/components/wp-card-view/styles/wp-card-view-vertical.sass similarity index 100% rename from frontend/src/app/components/wp-card-view/wp-card-view-vertical.sass rename to frontend/src/app/components/wp-card-view/styles/wp-card-view-vertical.sass diff --git a/frontend/src/app/components/wp-card-view/wp-card-view.component.sass b/frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass similarity index 94% rename from frontend/src/app/components/wp-card-view/wp-card-view.component.sass rename to frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass index f97db5f2b2..78c5dcc0a7 100644 --- a/frontend/src/app/components/wp-card-view/wp-card-view.component.sass +++ b/frontend/src/app/components/wp-card-view/styles/wp-card-view.component.sass @@ -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 diff --git a/frontend/src/app/components/wp-card-view/wp-card-view.component.html b/frontend/src/app/components/wp-card-view/wp-card-view.component.html index 0fb56dd642..fc55434ef4 100644 --- a/frontend/src/app/components/wp-card-view/wp-card-view.component.html +++ b/frontend/src/app/components/wp-card-view/wp-card-view.component.html @@ -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)">
- - - +
+ + + + + + +
+ class="wp-card--id" + [ngClass]="uiStateLinkClass"> #{{wp.id}} boolean; @Input() public dragInto:boolean; @Input() public highlightingMode:CardHighlightingMode; @Input() public workPackageAddedHandler:(wp:WorkPackageResource) => Promise; @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(); + @Output() public onMoved = new EventEmitter(); 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 { - 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, diff --git a/frontend/src/app/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts b/frontend/src/app/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts index 2c6edf75dd..5584e1ddc6 100644 --- a/frontend/src/app/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts +++ b/frontend/src/app/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts @@ -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); } } diff --git a/frontend/src/app/components/wp-fast-table/handlers/state/selection-transformer.ts b/frontend/src/app/components/wp-fast-table/handlers/state/selection-transformer.ts index c621331d7a..c198734b11 100644 --- a/frontend/src/app/components/wp-fast-table/handlers/state/selection-transformer.ts +++ b/frontend/src/app/components/wp-fast-table/handlers/state/selection-transformer.ts @@ -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(); } /** diff --git a/frontend/src/app/components/wp-fast-table/state/wp-table-selection.service.ts b/frontend/src/app/components/wp-fast-table/state/wp-table-selection.service.ts index 7dea3c568a..3c28fcbdf0 100644 --- a/frontend/src/app/components/wp-fast-table/state/wp-table-selection.service.ts +++ b/frontend/src/app/components/wp-fast-table/state/wp-table-selection.service.ts @@ -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 { diff --git a/frontend/src/app/components/wp-grid/wp-grid.component.ts b/frontend/src/app/components/wp-grid/wp-grid.component.ts index 9026c0b95b..03dcb0199f 100644 --- a/frontend/src/app/components/wp-grid/wp-grid.component.ts +++ b/frontend/src/app/components/wp-grid/wp-grid.component.ts @@ -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"> `, changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + DragAndDropService, + WorkPackageCardDragAndDropService + ] }) export class WorkPackagesGridComponent { public canDragOutOf = () => { return true; }; diff --git a/frontend/src/app/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts b/frontend/src/app/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts index 6cbc47b646..bff0c6e721 100644 --- a/frontend/src/app/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts +++ b/frontend/src/app/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts @@ -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); } diff --git a/frontend/src/app/modules/boards/board/board-list/board-list.component.ts b/frontend/src/app/modules/boards/board/board-list/board-list.component.ts index 15254692e0..8e57758847 100644 --- a/frontend/src/app/modules/boards/board/board-list/board-list.component.ts +++ b/frontend/src/app/modules/boards/board/board-list/board-list.component.ts @@ -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 { diff --git a/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.html b/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.html index de1ebde891..919750a95a 100644 --- a/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.html +++ b/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.html @@ -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"> diff --git a/frontend/src/app/modules/work_packages/query-space/wp-isolated-query-space.directive.ts b/frontend/src/app/modules/work_packages/query-space/wp-isolated-query-space.directive.ts index 043dda609c..87b56f03a5 100644 --- a/frontend/src/app/modules/work_packages/query-space/wp-isolated-query-space.directive.ts +++ b/frontend/src/app/modules/work_packages/query-space/wp-isolated-query-space.directive.ts @@ -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 }, diff --git a/spec/features/work_packages/bulk/copy_work_package_spec.rb b/spec/features/work_packages/bulk/copy_work_package_spec.rb index c85c347c59..28db609719 100644 --- a/spec/features/work_packages/bulk/copy_work_package_spec.rb +++ b/spec/features/work_packages/bulk/copy_work_package_spec.rb @@ -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 diff --git a/spec/features/work_packages/bulk/move_work_package_spec.rb b/spec/features/work_packages/bulk/move_work_package_spec.rb index c042f9d276..cb1876330c 100644 --- a/spec/features/work_packages/bulk/move_work_package_spec.rb +++ b/spec/features/work_packages/bulk/move_work_package_spec.rb @@ -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 diff --git a/spec/features/work_packages/bulk/update_work_package_spec.rb b/spec/features/work_packages/bulk/update_work_package_spec.rb index a15b7bfa5f..7e5a531398 100644 --- a/spec/features/work_packages/bulk/update_work_package_spec.rb +++ b/spec/features/work_packages/bulk/update_work_package_spec.rb @@ -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 diff --git a/spec/features/work_packages/select_query_spec.rb b/spec/features/work_packages/select/select_query_spec.rb similarity index 100% rename from spec/features/work_packages/select_query_spec.rb rename to spec/features/work_packages/select/select_query_spec.rb diff --git a/spec/features/work_packages/select_work_package_row_spec.rb b/spec/features/work_packages/select/select_work_package_row_spec.rb similarity index 100% rename from spec/features/work_packages/select_work_package_row_spec.rb rename to spec/features/work_packages/select/select_work_package_row_spec.rb diff --git a/spec/features/work_packages/select/select_wp_card_spec.rb b/spec/features/work_packages/select/select_wp_card_spec.rb new file mode 100644 index 0000000000..0d83b865ce --- /dev/null +++ b/spec/features/work_packages/select/select_wp_card_spec.rb @@ -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 diff --git a/spec/features/work_packages/table/context_menu_spec.rb b/spec/features/work_packages/table/context_menu_spec.rb index c430bb6dea..fc0b9b2592 100644 --- a/spec/features/work_packages/table/context_menu_spec.rb +++ b/spec/features/work_packages/table/context_menu_spec.rb @@ -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 diff --git a/spec/support/components/work_packages/context_menu.rb b/spec/support/components/work_packages/context_menu.rb index 639d55b31b..7b81cb30e7 100644 --- a/spec/support/components/work_packages/context_menu.rb +++ b/spec/support/components/work_packages/context_menu.rb @@ -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 diff --git a/spec/support/work_packages/work_package_cards.rb b/spec/support/work_packages/work_package_cards.rb new file mode 100644 index 0000000000..b2bb32b101 --- /dev/null +++ b/spec/support/work_packages/work_package_cards.rb @@ -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