Merge pull request #10146 from opf/feature/team-planner-remove-dragzone

Add dropzone to remove events from team planner
pull/10180/head
Henriette Darge 3 years ago committed by GitHub
commit 8023c38900
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      frontend/src/app/features/team-planner/team-planner/calendar-drag-drop.service.ts
  2. 135
      frontend/src/app/features/team-planner/team-planner/planner/event-view-lookup.service.ts
  3. 74
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html
  4. 39
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass
  5. 79
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts
  6. 2
      frontend/src/app/shared/helpers/debug_output.ts
  7. 2
      modules/team_planner/config/locales/js-en.yml
  8. 13
      modules/team_planner/spec/features/team_planner_add_existing_work_packages_spec.rb
  9. 94
      modules/team_planner/spec/features/team_planner_remove_event_spec.rb
  10. 45
      modules/team_planner/spec/support/pages/team_planner.rb
  11. 18
      spec/support/shared/drag_and_drop_helper_spec.rb

@ -115,6 +115,7 @@ export class CalendarDragDropService {
const diff = dueDate.diff(startDate, 'days') + 1;
return {
id: `${workPackage.href as string}-external`,
title: workPackage.subject,
duration: {
days: diff || 1,

@ -5,6 +5,7 @@ import {
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { debugLog } from 'core-app/shared/helpers/debug_output';
/**
* View lookup service for injecting angular templates
@ -16,7 +17,11 @@ import {
*/
@Injectable()
export class EventViewLookupService implements OnDestroy {
private readonly views = new Map<string, EmbeddedViewRef<unknown>>();
/** Active templates currently rendered */
private readonly activeViews = new Map<string, EmbeddedViewRef<unknown>>();
/** Remember detached views to be destroyed on destroyAll */
private readonly detachedViews:EmbeddedViewRef<unknown>[] = [];
constructor(private viewContainerRef:ViewContainerRef) {
}
@ -32,109 +37,87 @@ export class EventViewLookupService implements OnDestroy {
* @param context The available variables for the <ng-template>. For
* example, if it looks like this: <ng-template let-localVar="value"> then
* your context should be an object with a `value` key.
* @param comparator If you're re-rendering the same view and the context
* hasn't changed, then performance is a lot better if we just return the
* original view rather than destroying and re-creating the view.
* Optionally pass this function to return true when the views should be
* re-used.
*/
getView(
template:TemplateRef<unknown>, id:string, context:unknown,
comparator?:(v1:unknown, v2:unknown) => boolean,
):EmbeddedViewRef<unknown> {
let view = this.views.get(id);
let view = this.activeViews.get(id);
if (view) {
if (comparator && comparator(view.context, context)) {
// Nothing changed -- no need to re-render the component.
view.markForCheck();
return view;
}
// The performance would be better if we didn't need to destroy
// the view here... but just updating the context and checking
// changes doesn't work.
this.destroyView(id);
debugLog('Returning active view %O', id);
view.detectChanges();
return view;
}
// Create a new view and move to active
debugLog('CREATING new view %O', id);
view = this.viewContainerRef.createEmbeddedView(template, context);
this.views.set(id, view);
this.activeViews.set(id, view);
view.detectChanges();
return view;
}
ngOnDestroy():void {
this.destroyAll();
}
/**
* Generates a view for the given template and returns the root DOM node(s)
* for the view, which can be returned from an eventContent call.
* @param template The template ref (get this from a @ViewChild of an
* <ng-template>)
* @param id The unique ID for this instance of the view. Use this so that
* you don't keep around views for the same event.
* @param context The available variables for the <ng-template>. For
* example, if it looks like this: <ng-template let-localVar="value"> then
* your context should be an object with a `value` key.
* @param comparator If you're re-rendering the same view and the context
* hasn't changed, then performance is a lot better if we just return the
* original view rather than destroying and re-creating the view.
* Optionally pass this function to return true when the views should be
* re-used.
* Call this method if all views need to be cleaned up. This will happen
* when your parent component is destroyed (e.g., in ngOnDestroy),
* but it may also be needed if you are clearing just the area where the
* views have been placed.
*/
getTemplateRootNodes(
template:TemplateRef<unknown>,
id:string,
context:unknown,
comparator?:(v1:unknown, v2:unknown) => boolean,
):unknown[] {
return this.getView(template, id, context, comparator).rootNodes;
}
public destroyAll():void {
debugLog('Destroying all views');
Array
.from(this.activeViews.values())
.forEach(this.destroyView.bind(this));
hasView(id:string):boolean {
return this.views.has(id);
debugLog('Destroying %O active views', this.activeViews.size);
this.activeViews.clear();
this.destroyDetached();
}
/**
* Marks the given view (or all views) as needing change detection.
* Call `detectChanges` on your component if you need to run change
* detection synchronously; normally Angular handles that.
* Call this method if you want to clean detached views.
* This is safe to call outside of drag & drop operations.
*
*/
markForCheck(id?:string):void {
if (id) {
this.views.get(id)?.markForCheck();
} else {
// eslint-disable-next-line no-restricted-syntax
for (const view of this.views.values()) {
view.markForCheck();
}
}
}
public destroyDetached():void {
debugLog('Destroying %O detached views', this.detachedViews.length);
ngOnDestroy():void {
this.destroyAll();
while (this.detachedViews.length) {
this.destroyView(this.detachedViews.pop() as EmbeddedViewRef<unknown>);
}
}
/**
* Call this method if all views need to be cleaned up. This will happen
* when your parent component is destroyed (e.g., in ngOnDestroy),
* but it may also be needed if you are clearing just the area where the
* views have been placed.
* Mark a view to be destroyed.
* It will only be destroyed once +destroyAll+ is called.
*
* Ensure that destroyAll is called when, e.g., refreshing the calendar.
*
* @param id View ID
*/
public destroyAll():void {
// eslint-disable-next-line no-restricted-syntax
for (const view of this.views.values()) {
view.destroy();
public markForDestruction(id:string):void {
const view = this.activeViews.get(id);
if (!view) {
return;
}
this.views.clear();
debugLog('Marking view %O to be destroyed', id);
this.activeViews.delete(id);
this.detachedViews.push(view);
}
public destroyView(id:string):void {
const view = this.views.get(id);
if (view) {
const index = this.viewContainerRef.indexOf(view);
if (index !== -1) {
this.viewContainerRef.remove(index);
}
view.destroy();
this.views.delete(id);
private destroyView(view:EmbeddedViewRef<unknown>) {
const index = this.viewContainerRef.indexOf(view);
if (index !== -1) {
this.viewContainerRef.remove(index);
}
view.destroy();
}
}

@ -75,33 +75,59 @@
<ng-template #eventContent let-event="event">
<wp-single-card
*ngIf="event.extendedProps.workPackage"
[workPackage]="event.extendedProps.workPackage"
[orientation]="'horizontal'"
[highlightingMode]="'type'"
[showInfoButton]="true"
[disabledInfo]="showDisabledText(event.extendedProps.workPackage)"
[isClosed]="isStatusClosed(event.extendedProps.workPackage)"
[showAsInlineCard]="true"
[showStartDate]="!this.isWpDateInCurrentView(event.extendedProps.workPackage, 'start')"
[showEndDate]="!this.isWpDateInCurrentView(event.extendedProps.workPackage, 'end')"
>
</wp-single-card>
</ng-template>
<div
class="wp-inline-create-button op-team-planner--add-assignee"
*ngIf="!(showAddAssignee$ | async)"
[orientation]="'horizontal'"
[highlightingMode]="'type'"
[showInfoButton]="true"
[disabledInfo]="showDisabledText(event.extendedProps.workPackage)"
[isClosed]="isStatusClosed(event.extendedProps.workPackage)"
[showAsInlineCard]="true"
[showStartDate]="!this.isWpDateInCurrentView(event.extendedProps.workPackage, 'start')"
[showEndDate]="!this.isWpDateInCurrentView(event.extendedProps.workPackage, 'end')"
>
<button
type="button"
class="wp-inline-create--add-link tp-assignee-add-button"
(click)="showAssigneeAddRow()"
data-qa-selector="tp-assignee-add-button"
</wp-single-card>
</ng-template>
<div class="op-team-planner--footer" data-qa-selector="op-team-planner-footer">
<div class="op-team-planner--add-assignee">
<div
class="wp-inline-create-button"
*ngIf="!(showAddAssignee$ | async)"
>
<op-icon icon-classes="icon-context icon-add"></op-icon>
<span [textContent]="text.add_assignee"></span>
</button>
<button
type="button"
class="wp-inline-create--add-link tp-assignee-add-button"
(click)="showAssigneeAddRow()"
data-qa-selector="tp-assignee-add-button"
>
<op-icon icon-classes="icon-context icon-add"></op-icon>
<span [textContent]="text.add_assignee"></span>
</button>
</div>
</div>
<ng-container *ngIf="(dropzone$ | async) as dropzone">
<div
*ngIf="dropzone.dragging"
#removeDropzone
(mouseenter)="dropzoneHovered$.next(true)"
(mouseleave)="dropzoneHovered$.next(false)"
(mouseup)="dropzone.canDrop && removeEvent(dropzone.dragging)"
class="op-team-planner--remove-dropzone"
data-qa-selector="op-team-planner-dropzone"
[class.op-team-planner--remove-dropzone_active]="dropzone.isHovering && dropzone.canDrop"
[class.op-team-planner--remove-dropzone_forbidden]="!dropzone.canDrop"
>
<span
*ngIf="dropzone.canDrop"
class="op-team-planner--dropzone-label"
[textContent]="text.drag_here_to_remove"
></span>
<span
*ngIf="!dropzone.canDrop"
class="op-team-planner--dropzone-label"
[textContent]="text.cannot_drag_here"
></span>
</div>
</ng-container>
</div>
</div>

@ -1,5 +1,8 @@
@import "helpers"
// This needs to match the resourceAreaWidth in the team planner configuration
$op-team-planner-resource-width: 180px
\:host
display: block
height: 100%
@ -7,6 +10,8 @@
.op-team-planner
$block: &
display: grid
grid-template-columns: auto 1fr
grid-template-rows: 55px 1fr
@ -30,12 +35,40 @@
&--add-existing-toggle
position: absolute
&--footer
grid-area: footer
display: flex
margin-top: 1rem
&--add-assignee
width: $op-team-planner-resource-width
border-right: 1px solid transparent
&--remove-dropzone
flex: 1
border: 1px dashed #1A67A3
box-sizing: border-box
display: flex
align-items: center
justify-content: center
&_active
background: #D1E5F5
&_forbidden
border-color: var(--content-form-danger-zone-bg-color)
#{$block}--dropzone-label
color: var(--content-form-danger-zone-bg-color)
&--dropzone-label
color: #1A67A3
&--no-data
min-height: 250px
background-color: #F3F3F3
display: flex
justify-content: center
align-items: center
.tp-assignee-add-button
margin-top: 1rem

@ -31,6 +31,8 @@ import {
import { StateService } from '@uirouter/angular';
import resourceTimelinePlugin from '@fullcalendar/resource-timeline';
import interactionPlugin, {
EventDragStartArg,
EventDragStopArg,
EventReceiveArg,
EventResizeDoneArg,
} from '@fullcalendar/interaction';
@ -57,6 +59,7 @@ import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { CalendarDragDropService } from 'core-app/features/team-planner/team-planner/calendar-drag-drop.service';
import { StatusResource } from 'core-app/features/hal/resources/status-resource';
import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset';
@Component({
selector: 'op-team-planner',
@ -82,8 +85,31 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
@ViewChild('assigneeAutocompleter') assigneeAutocompleter:TemplateRef<unknown>;
@ViewChild('removeDropzone', { read: ElementRef }) removeDropzone:ElementRef;
calendarOptions$ = new Subject<CalendarOptions>();
draggingItem$ = new BehaviorSubject<EventDragStartArg|undefined>(undefined);
dropzoneHovered$ = new BehaviorSubject<boolean>(false);
dropzoneAllowed$ = this
.draggingItem$
.pipe(
filter((dragging) => !!dragging),
map((dragging) => {
const workPackage = (dragging as EventDragStartArg).event.extendedProps.workPackage as WorkPackageResource;
const durationEditable = this.calendar.eventDurationEditable(workPackage);
const resourceEditable = this.eventResourceEditable(workPackage);
return durationEditable && resourceEditable;
}),
);
dropzone$ = combineLatest([this.draggingItem$, this.dropzoneHovered$, this.dropzoneAllowed$])
.pipe(
map(([dragging, isHovering, canDrop]) => ({ dragging, isHovering, canDrop })),
);
projectIdentifier:string|undefined = undefined;
showAddExistingPane = new BehaviorSubject<boolean>(false);
@ -127,6 +153,8 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
remove_assignee: this.I18n.t('js.team_planner.remove_assignee'),
noData: this.I18n.t('js.team_planner.no_data'),
two_weeks: this.I18n.t('js.team_planner.two_weeks'),
drag_here_to_remove: this.I18n.t('js.team_planner.drag_here_to_remove'),
cannot_drag_here: this.I18n.t('js.team_planner.cannot_drag_here'),
};
principals$ = this.principalIds$
@ -305,12 +333,22 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
editable: true,
droppable: true,
eventResize: (resizeInfo:EventResizeDoneArg) => this.updateEvent(resizeInfo),
eventDragStart: (dragInfo:EventDragStartArg) => {
const { el } = dragInfo;
el.style.pointerEvents = 'none';
this.draggingItem$.next(dragInfo);
},
eventDragStop: (dragInfo:EventDragStopArg) => {
const { el } = dragInfo;
el.style.removeProperty('pointer-events');
this.draggingItem$.next(undefined);
},
eventDrop: (dropInfo:EventDropArg) => this.updateEvent(dropInfo),
eventReceive: (dropInfo:EventReceiveArg) => this.updateEvent(dropInfo),
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
eventContent: (data:EventContentArg) => this.renderTemplate(this.eventContent, data.event.extendedProps.workPackage.href, data),
eventContent: (data:EventContentArg) => this.renderTemplate(this.eventContent, this.eventId(data), data),
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
eventWillUnmount: (data:EventContentArg) => this.unrenderTemplate(data.event.extendedProps.workPackage.href),
eventWillUnmount: (data:EventContentArg) => this.unrenderTemplate(this.eventId(data)),
} as CalendarOptions),
);
});
@ -327,6 +365,9 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
.toPromise()
.then((workPackages:WorkPackageCollectionResource) => {
const events = this.mapToCalendarEvents(workPackages.elements);
this.viewLookup.destroyDetached();
successCallback(events);
})
.catch(failureCallback);
@ -335,12 +376,25 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
}
renderTemplate(template:TemplateRef<unknown>, id:string, data:ResourceLabelContentArg|EventContentArg):{ domNodes:unknown[] } {
if (this.isDragggedEvent(id)) {
this.viewLookup.markForDestruction(id);
}
const ref = this.viewLookup.getView(template, id, data);
return { domNodes: ref.rootNodes };
}
unrenderTemplate(id:string):void {
this.viewLookup.destroyView(id);
this.viewLookup.markForDestruction(id);
}
isDragggedEvent(id:string):boolean {
const dragging = this.draggingItem$.getValue();
return !!dragging && (dragging.event.extendedProps.workPackage as WorkPackageResource).href === id;
}
eventId(data:EventContentArg):string {
return `${data.event.id},dragging=${data.isDragging.toString()}`;
}
public showAssigneeAddRow():void {
@ -410,6 +464,20 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
return status ? status.isClosed : false;
}
public async removeEvent(item:EventDragStartArg):Promise<void> {
// Remove item from view
item.el.remove();
item.event.remove();
const workPackage = item.event.extendedProps.workPackage as WorkPackageResource;
const changeset = this.halEditing.edit(workPackage);
changeset.setValue('assignee', { href: null });
changeset.setValue('startDate', null);
changeset.setValue('dueDate', null);
await this.saveChangeset(changeset);
}
private mapToCalendarEvents(workPackages:WorkPackageResource[]):EventInput[] {
return workPackages
.map((workPackage:WorkPackageResource):EventInput|undefined => {
@ -484,14 +552,17 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
}
this.calendarDrag.handleDrop(changeset.projectedResource);
await this.saveChangeset(changeset, info);
}
private async saveChangeset(changeset:ResourceChangeset<WorkPackageResource>, info?:EventResizeDoneArg|EventDropArg|EventReceiveArg) {
try {
const result = await this.halEditing.save(changeset);
this.halNotification.showSave(result.resource, result.wasNew);
} catch (e) {
this.halNotification.showError(e.resource, changeset.projectedResource);
this.calendarDrag.handleDropError(changeset.projectedResource);
info.revert();
info?.revert();
}
}

@ -14,7 +14,7 @@ export function whenDebugging(cb:() => void) {
* Log with console.log when DEBUG is defined
* through webpack.
*/
export function debugLog(message:string, ...args:any[]) {
export function debugLog(message:string, ...args:unknown[]):void {
// eslint-disable-next-line no-console
whenDebugging(() => console.log(`[DEBUG] ${message}`, ...args));
}

@ -11,6 +11,8 @@ en:
add_assignee: 'Add assignee'
remove_assignee: 'Remove assignee'
two_weeks: '2 weeks'
drag_here_to_remove: 'Drag here to remove assignee and start and end dates.'
cannot_drag_here: 'Cannot remove the work package due to permissions or editing restrictions.'
quick_add:
empty_state: 'Use the search field to find work packages and drag them to the planner to assign it to someone and define start and end dates.'

@ -36,6 +36,7 @@ describe 'Team planner add existing work packages', type: :feature, js: true do
include_context 'with team planner full access'
let(:closed_status) { create :status, is_closed: true }
let(:start_of_week) { Time.zone.today.beginning_of_week(:sunday) }
let!(:other_user) do
create :user,
@ -51,8 +52,8 @@ describe 'Team planner add existing work packages', type: :feature, js: true do
project: project,
subject: 'Task 1',
assigned_to: user,
start_date: Time.zone.today.beginning_of_week.next_occurring(:tuesday),
due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday)
start_date: start_of_week.next_occurring(:tuesday),
due_date: start_of_week.next_occurring(:thursday)
end
let!(:second_wp) do
create :work_package,
@ -107,8 +108,8 @@ describe 'Team planner add existing work packages', type: :feature, js: true do
# ... and thus update its attributes. Thereby the duration is maintained
second_wp.reload
expect(second_wp.start_date).to eq(Time.zone.today.beginning_of_week.next_occurring(:tuesday))
expect(second_wp.due_date).to eq(Time.zone.today.beginning_of_week.next_occurring(:thursday))
expect(second_wp.start_date).to eq(start_of_week.next_occurring(:tuesday))
expect(second_wp.due_date).to eq(start_of_week.next_occurring(:thursday))
expect(second_wp.assigned_to_id).to eq(user.id)
# Search for another work package
@ -124,8 +125,8 @@ describe 'Team planner add existing work packages', type: :feature, js: true do
# ... and thus update its attributes. Since no dates were set before, start and end date are set to the same day
third_wp.reload
expect(third_wp.start_date).to eq(Time.zone.today.beginning_of_week.next_occurring(:tuesday))
expect(third_wp.due_date).to eq(Time.zone.today.beginning_of_week.next_occurring(:tuesday))
expect(third_wp.start_date).to eq(start_of_week.next_occurring(:tuesday))
expect(third_wp.due_date).to eq(start_of_week.next_occurring(:tuesday))
expect(third_wp.assigned_to_id).to eq(user.id)
# New events are directly clickable

@ -0,0 +1,94 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
require_relative './shared_context'
describe 'Team planner remove event', type: :feature, js: true do
include_context 'with team planner full access'
let!(:viewer_role) { create :existing_role, permissions: [:view_work_packages] }
let!(:other_user) do
create :user,
firstname: 'Bernd',
member_in_project: project,
member_with_permissions: %w[
view_work_packages view_team_planner
]
end
let!(:removable_wp) do
create :work_package,
project: project,
subject: 'Some task',
assigned_to: other_user,
start_date: Time.zone.today.beginning_of_week.next_occurring(:tuesday),
due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday)
end
let!(:non_removable_wp) do
create :work_package,
project: project,
subject: 'Parent work package',
assigned_to: other_user,
start_date: Time.zone.today.beginning_of_week.next_occurring(:wednesday),
due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday),
derived_start_date: Time.zone.today.beginning_of_week.next_occurring(:wednesday),
derived_due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday)
end
let!(:child_wp) do
create :work_package,
parent: non_removable_wp,
project: project,
assigned_to: user,
start_date: Time.zone.today.beginning_of_week.next_occurring(:wednesday),
due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday)
end
before do
with_enterprise_token(:team_planner_view)
team_planner.visit!
team_planner.add_assignee other_user
team_planner.within_lane(other_user) do
team_planner.expect_event removable_wp
team_planner.expect_event non_removable_wp
end
sleep 2
end
it 'can remove one of the work packages' do
team_planner.drag_to_remove_dropzone non_removable_wp, expect_removable: false
team_planner.drag_to_remove_dropzone removable_wp, expect_removable: true
end
end

@ -130,9 +130,7 @@ module Pages
end
def open_split_view(work_package)
page
.find('.fc-event', text: work_package.subject)
.click
event(work_package).click
::Pages::SplitWorkPackage.new(work_package, project)
end
@ -163,7 +161,7 @@ module Pages
end
def change_wp_date_by_resizing(work_package, number_of_days:, is_start_date:)
wp_strip = page.find('.fc-event', text: work_package.subject)
wp_strip = event(work_package)
page
.driver
@ -178,12 +176,47 @@ module Pages
end
def drag_wp_by_pixel(work_package, by_x, by_y)
source = page
.find('.fc-event', text: work_package.subject)
source = event(work_package)
drag_by_pixel(element: source, by_x: by_x, by_y: by_y)
end
def drag_to_remove_dropzone(work_package, expect_removable: true)
source = event(work_package)
start_dragging(source)
# Move the footer first to signal we're dragging something
footer = find('[data-qa-selector="op-team-planner-footer"]')
drag_element_to(footer)
sleep 1
dropzone = find('[data-qa-selector="op-team-planner-dropzone"]')
drag_element_to(dropzone)
if expect_removable
expect(page).to have_selector('span', text: I18n.t('js.team_planner.drag_here_to_remove'))
else
expect(page).to have_selector('span', text: I18n.t('js.team_planner.cannot_drag_here'))
end
drag_release
if expect_removable
expect_and_dismiss_toaster(message: "Successful update.")
else
expect_no_toaster
end
sleep 1
expect_event(work_package, present: !expect_removable)
end
def event(work_package)
page.find('.fc-event', text: work_package.subject)
end
def expect_wp_not_resizable(work_package)
expect(page).to have_selector('.fc-event:not(.fc-event-resizable)', text: work_package.subject)
end

@ -26,7 +26,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
def drag_n_drop_element(from:, to:, offset_x: nil, offset_y: nil)
def start_dragging(from)
scroll_to_element(from)
page
.driver
@ -35,17 +35,33 @@ def drag_n_drop_element(from:, to:, offset_x: nil, offset_y: nil)
.move_to(from.native)
.click_and_hold(from.native)
.perform
end
def drag_element_to(to, offset_x: nil, offset_y: nil)
scroll_to_element(to)
page
.driver
.browser
.action
.move_to(to.native, offset_x, offset_y)
.perform
end
def drag_release
page
.driver
.browser
.action
.release
.perform
end
def drag_n_drop_element(from:, to:, offset_x: nil, offset_y: nil)
start_dragging(from)
drag_element_to(to, offset_x: offset_x, offset_y: offset_y)
drag_release
end
def drag_by_pixel(element:, by_x:, by_y:)
scroll_to_element(element)

Loading…
Cancel
Save