Add dropzone to remove events from team planner

pull/10146/head
Oliver Günther 3 years ago
parent 3d0946f9a8
commit 1d15acbec9
No known key found for this signature in database
GPG Key ID: A3A8BDAD7C0C552C
  1. 51
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html
  2. 39
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass
  3. 57
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts
  4. 2
      modules/team_planner/config/locales/js-en.yml
  5. 94
      modules/team_planner/spec/features/team_planner_remove_event_spec.rb
  6. 45
      modules/team_planner/spec/support/pages/team_planner.rb
  7. 18
      spec/support/shared/drag_and_drop_helper_spec.rb

@ -89,19 +89,46 @@
</wp-single-card>
</ng-template>
<div
class="wp-inline-create-button op-team-planner--add-assignee"
*ngIf="!(showAddAssignee$ | async)"
>
<button
type="button"
class="wp-inline-create--add-link tp-assignee-add-button"
(click)="showAssigneeAddRow()"
data-qa-selector="tp-assignee-add-button"
<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 Subject<EventDragStartArg|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,6 +333,16 @@ 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
@ -410,6 +448,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 +536,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();
}
}

@ -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.'

@ -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