[#43637] Update gantt chart for duration and non-working days (#11189)

* Introduce the MouseDirection type and the applyRendererMoveChanges function on the timeline cell mouse handler and renderer

* [#43637] Update gantt chart for duration and non-working days

https://community.openproject.org/work_packages/43637
pull/11240/head
Dombi Attila 2 years ago committed by GitHub
parent d6ede77dc8
commit 86773a814f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      frontend/.eslintrc.js
  2. 3
      frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-new-wp-attribute-group.component.ts
  3. 3
      frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts
  4. 8
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center-page.component.ts
  5. 3
      frontend/src/app/features/team-planner/team-planner/page/team-planner-page.component.ts
  6. 135
      frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts
  7. 15
      frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts
  8. 38
      frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell-labels.ts
  9. 67
      frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell-mouse-handler.ts
  10. 22
      frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell.ts
  11. 2
      frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.ts
  12. 2
      frontend/src/app/shared/components/autocompleter/project-autocompleter/project-autocompleter.component.ts
  13. 2
      frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts
  14. 6
      frontend/src/app/shared/components/modals/wp-destroy-modal/wp-destroy.modal.ts
  15. 3
      frontend/src/app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component.ts
  16. 175
      spec/features/work_packages/timeline/timeline_dates_spec.rb
  17. 40
      spec/support/components/timelines/timeline_row.rb
  18. 6
      spec/support/shared/drag_and_drop_helper_spec.rb

@ -63,7 +63,8 @@ module.exports = {
], ],
// Sometimes we need to shush the TypeScript compiler // Sometimes we need to shush the TypeScript compiler
"no-unused-vars": ["error", { "varsIgnorePattern": "^_" }], "no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
// Who cares about line length // Who cares about line length
"max-len": "off", "max-len": "off",

@ -79,8 +79,7 @@ export class BcfNewWpAttributeGroupComponent extends BcfWpAttributeGroupComponen
} }
// Disable show viewpoint functionality // Disable show viewpoint functionality
// eslint-disable-next-line @typescript-eslint/no-unused-vars showViewpoint(_workPackage:WorkPackageResource, _index:number):void {
showViewpoint(workPackage:WorkPackageResource, index:number):void {
} }

@ -120,8 +120,7 @@ export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePage
this.currentPartition = state.data?.partition || '-split'; this.currentPartition = state.data?.partition || '-split';
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars protected staticQueryName(_query:QueryResource):string {
protected staticQueryName(query:QueryResource):string {
return this.text.unsaved_title; return this.text.unsaved_title;
} }

@ -124,13 +124,13 @@ export class InAppNotificationCenterPageComponent extends UntilDestroyedMixin im
} }
// For shared template compliance // For shared template compliance
// eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars // eslint-disable-next-line class-methods-use-this
updateTitleName(val:string):void { updateTitleName(_val:string):void {
} }
// For shared template compliance // For shared template compliance
// eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars // eslint-disable-next-line class-methods-use-this
changeChangesFromTitle(val:string):void { changeChangesFromTitle(_val:string):void {
} }
private setInitialHtmlTitle():void { private setInitialHtmlTitle():void {

@ -119,8 +119,7 @@ export class TeamPlannerPageComponent extends PartitionedQuerySpacePageComponent
this.currentPartition = state.data?.partition || '-split'; this.currentPartition = state.data?.partition || '-split';
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars protected staticQueryName(_query:QueryResource):string {
protected staticQueryName(query:QueryResource):string {
return this.text.unsaved_title; return this.text.unsaved_title;
} }

@ -9,20 +9,9 @@ import { WorkPackageChangeset } from 'core-app/features/work-packages/components
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { I18nService } from 'core-app/core/i18n/i18n.service'; import { I18nService } from 'core-app/core/i18n/i18n.service';
import { WeekdayService } from 'core-app/core/days/weekday.service';
import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive'; import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive';
import { classNameBarLabel, classNameLeftHandle, classNameRightHandle } from './wp-timeline-cell-mouse-handler'; import { WorkPackageCellLabels } from './wp-timeline-cell-labels';
import {
classNameFarRightLabel,
classNameHideOnHover,
classNameHoverStyle,
classNameLeftHoverLabel,
classNameLeftLabel,
classNameRightContainer,
classNameRightHoverLabel,
classNameRightLabel,
classNameShowOnHover,
WorkPackageCellLabels,
} from './wp-timeline-cell';
import { import {
calculatePositionValueForDayCount, calculatePositionValueForDayCount,
calculatePositionValueForDayCountingPx, calculatePositionValueForDayCountingPx,
@ -42,14 +31,25 @@ export interface CellDateMovement {
} }
export type LabelPosition = 'left'|'right'|'farRight'; export type LabelPosition = 'left'|'right'|'farRight';
export type MouseDirection = 'left'|'right'|'both'|'create'|'dragright';
class TimezoneService {
} export const classNameLeftLabel = 'labelLeft';
export const classNameRightContainer = 'containerRight';
export const classNameRightLabel = 'labelRight';
export const classNameLeftHoverLabel = 'labelHoverLeft';
export const classNameRightHoverLabel = 'labelHoverRight';
export const classNameHoverStyle = '-label-style';
export const classNameFarRightLabel = 'labelFarRight';
export const classNameShowOnHover = 'show-on-hover';
export const classNameHideOnHover = 'hide-on-hover';
export const classNameLeftHandle = 'leftHandle';
export const classNameRightHandle = 'rightHandle';
export const classNameBarLabel = 'bar-label';
export class TimelineCellRenderer { export class TimelineCellRenderer {
@InjectField() wpTableTimeline:WorkPackageViewTimelineService; @InjectField() wpTableTimeline:WorkPackageViewTimelineService;
@InjectField() TimezoneService:TimezoneService; @InjectField() weekdayService:WeekdayService;
@InjectField() schemaCache:SchemaCacheService; @InjectField() schemaCache:SchemaCacheService;
@ -63,7 +63,7 @@ export class TimelineCellRenderer {
public fieldRenderer:DisplayFieldRenderer = new DisplayFieldRenderer(this.injector, 'timeline'); public fieldRenderer:DisplayFieldRenderer = new DisplayFieldRenderer(this.injector, 'timeline');
protected dateDisplaysOnMouseMove:{ left?:HTMLElement; right?:HTMLElement } = {}; protected mouseDownCursorType:string;
constructor(readonly injector:Injector, constructor(readonly injector:Injector,
readonly workPackageTimeline:WorkPackageTimelineTableController) { readonly workPackageTimeline:WorkPackageTimelineTableController) {
@ -76,7 +76,7 @@ export class TimelineCellRenderer {
return 'bar'; return 'bar';
} }
public canMoveDates(wp:WorkPackageResource) { public canMoveDates(wp:WorkPackageResource):boolean {
const schema = this.schemaCache.of(wp); const schema = this.schemaCache.of(wp);
return schema.startDate.writable && schema.dueDate.writable && schema.isAttributeEditable('startDate'); return schema.startDate.writable && schema.dueDate.writable && schema.isAttributeEditable('startDate');
} }
@ -89,16 +89,17 @@ export class TimelineCellRenderer {
} }
public displayPlaceholderUnderCursor(ev:MouseEvent, renderInfo:RenderInfo):HTMLElement { public displayPlaceholderUnderCursor(ev:MouseEvent, renderInfo:RenderInfo):HTMLElement {
const days = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay); const [dateUnderCursor, dayOffset] = this.cursorDateAndDayOffset(ev, renderInfo);
const duration = this.displayDurationForDate(renderInfo, dateUnderCursor);
const width = duration * renderInfo.viewParams.pixelPerDay || 30;
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.style.pointerEvents = 'none'; placeholder.style.pointerEvents = 'none';
placeholder.style.position = 'absolute'; placeholder.style.position = 'absolute';
placeholder.style.height = '1em'; placeholder.style.height = '1em';
placeholder.style.width = '30px'; placeholder.style.width = `${width}px`;
placeholder.style.zIndex = '9999'; placeholder.style.zIndex = '9999';
placeholder.style.left = `${days * renderInfo.viewParams.pixelPerDay}px`; placeholder.style.left = `${dayOffset * renderInfo.viewParams.pixelPerDay}px`;
this.applyTypeColor(renderInfo, placeholder); this.applyTypeColor(renderInfo, placeholder);
return placeholder; return placeholder;
@ -125,7 +126,7 @@ export class TimelineCellRenderer {
public onDaysMoved(change:WorkPackageChangeset, public onDaysMoved(change:WorkPackageChangeset,
dayUnderCursor:Moment, dayUnderCursor:Moment,
delta:number, delta:number,
direction:'left'|'right'|'both'|'create'|'dragright'):CellDateMovement { direction:MouseDirection):CellDateMovement {
const initialStartDate = change.pristineResource.startDate; const initialStartDate = change.pristineResource.startDate;
const initialDueDate = change.pristineResource.dueDate; const initialDueDate = change.pristineResource.dueDate;
@ -166,8 +167,7 @@ export class TimelineCellRenderer {
public onMouseDown(ev:MouseEvent, public onMouseDown(ev:MouseEvent,
dateForCreate:string|null, dateForCreate:string|null,
renderInfo:RenderInfo, renderInfo:RenderInfo,
labels:WorkPackageCellLabels, labels:WorkPackageCellLabels):MouseDirection {
elem:HTMLElement):'left'|'right'|'both'|'dragright'|'create' {
// check for active selection mode // check for active selection mode
if (renderInfo.viewParams.activeSelectionMode) { if (renderInfo.viewParams.activeSelectionMode) {
renderInfo.viewParams.activeSelectionMode(renderInfo.workPackage); renderInfo.viewParams.activeSelectionMode(renderInfo.workPackage);
@ -176,29 +176,34 @@ export class TimelineCellRenderer {
} }
const projection = renderInfo.change.projectedResource; const projection = renderInfo.change.projectedResource;
let direction:'left'|'right'|'both'|'dragright'; let direction:Exclude<MouseDirection, 'create'>;
// Update the cursor and maybe set start/due values // Update the cursor and maybe set start/due values
if (jQuery(ev.target!).hasClass(classNameLeftHandle)) { if (jQuery(ev.target!).hasClass(classNameLeftHandle)) {
// only left // only left
direction = 'left'; direction = 'left';
this.workPackageTimeline.forceCursor('col-resize'); this.mouseDownCursorType = 'col-resize';
if (projection.startDate === null) { if (projection.startDate === null) {
projection.startDate = projection.dueDate; projection.startDate = projection.dueDate;
} }
} else if (jQuery(ev.target!).hasClass(classNameRightHandle) || dateForCreate) { } else if (jQuery(ev.target!).hasClass(classNameRightHandle) || dateForCreate) {
// only right // only right
direction = 'right'; direction = 'right';
this.workPackageTimeline.forceCursor('col-resize'); this.mouseDownCursorType = 'col-resize';
} else { } else {
// both // both
direction = 'both'; direction = 'both';
this.workPackageTimeline.forceCursor('ew-resize'); this.mouseDownCursorType = 'ew-resize';
} }
this.workPackageTimeline.forceCursor(this.mouseDownCursorType);
if (dateForCreate) { if (dateForCreate) {
const dateUnderCursor = this.cursorDateAndDayOffset(ev, renderInfo)[0];
const duration = this.displayDurationForDate(renderInfo, dateUnderCursor) - 1;
projection.startDate = dateForCreate; projection.startDate = dateForCreate;
projection.dueDate = dateForCreate; projection.dueDate = moment(dateForCreate).add(duration, 'days').format('YYYY-MM-DD');
direction = 'dragright'; direction = 'dragright';
} }
@ -208,6 +213,9 @@ export class TimelineCellRenderer {
} }
public onMouseDownEnd(labels:WorkPackageCellLabels, change:WorkPackageChangeset) { public onMouseDownEnd(labels:WorkPackageCellLabels, change:WorkPackageChangeset) {
// Reset the cursor set by onMouseDown
this.mouseDownCursorType = '';
this.workPackageTimeline.forceCursor(this.mouseDownCursorType);
this.updateLabels(false, labels, change); this.updateLabels(false, labels, change);
} }
@ -254,6 +262,12 @@ export class TimelineCellRenderer {
return true; return true;
} }
public cursorDateAndDayOffset(ev:MouseEvent, renderInfo:RenderInfo):[Moment, number] {
const dayOffset = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay);
const dateUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(dayOffset, 'days');
return [dateUnderCursor, dayOffset];
}
protected checkForActiveSelectionMode(renderInfo:RenderInfo, element:HTMLElement) { protected checkForActiveSelectionMode(renderInfo:RenderInfo, element:HTMLElement) {
if (renderInfo.viewParams.activeSelectionMode) { if (renderInfo.viewParams.activeSelectionMode) {
element.style.backgroundImage = ''; // required! unable to disable "fade out bar" with css element.style.backgroundImage = ''; // required! unable to disable "fade out bar" with css
@ -265,6 +279,41 @@ export class TimelineCellRenderer {
} }
} }
/**
* Takes the date under the cursor and the work package's duration.
* It calculates the adjusted duration based on the number of NonWorkingDays
* that fall in the range of the ( date .. date + duration ).
* @param renderInfo
* @param date where we start the duration calculation from
* @return {number} the NonWorkingDays adjusted duration
*/
protected displayDurationForDate(renderInfo:RenderInfo, date:Moment):number {
const { workPackage } = renderInfo;
let duration = Number(moment.duration(workPackage.duration || 'P1D').asDays().toFixed(0));
if (workPackage.ignoreNonWorkingDays) {
return duration;
}
const { dateDisplayEnd } = renderInfo.viewParams;
let newDuration = 0;
for (newDuration; newDuration < duration; newDuration++) {
const currentDate = date.clone().add(newDuration, 'days');
// Stop adding duration when we reach end of the visible table
if (currentDate > dateDisplayEnd) {
break;
}
// Extend the duration if the currentDate is non-working
if (this.weekdayService.isNonWorkingDay(currentDate.toDate())) {
duration += 1;
}
}
return newDuration;
}
getMarginLeftOfLeftSide(renderInfo:RenderInfo):number { getMarginLeftOfLeftSide(renderInfo:RenderInfo):number {
const projection = renderInfo.change.projectedResource; const projection = renderInfo.change.projectedResource;
@ -401,6 +450,18 @@ export class TimelineCellRenderer {
} }
} }
cursorOrDatesAreNonWorking(evOrDates:MouseEvent|Moment[], renderInfo:RenderInfo):boolean {
if (renderInfo.workPackage.ignoreNonWorkingDays) {
return false;
}
const dates = (evOrDates instanceof MouseEvent)
? [this.cursorDateAndDayOffset(evOrDates, renderInfo)[0]]
: evOrDates;
return dates.some((date) => this.weekdayService.isNonWorkingDay(date.toDate()));
}
/** /**
* Changes the presentation of the work package. * Changes the presentation of the work package.
* *
@ -451,6 +512,18 @@ export class TimelineCellRenderer {
childrenDurationBar.appendChild(childrenDurationHoverContainer); childrenDurationBar.appendChild(childrenDurationHoverContainer);
row.appendChild(childrenDurationBar); row.appendChild(childrenDurationBar);
} }
// Check for non-working days and display a not-allowed cursor
// when the startDate, dueDate are non-working days
const { startDate, dueDate } = renderInfo.change.projectedResource;
const invalidDates = this.cursorOrDatesAreNonWorking([moment(startDate), moment(dueDate)], renderInfo);
if (invalidDates) {
this.workPackageTimeline.forceCursor('not-allowed');
} else {
// Restore the previous cursor set by onMouseDown
this.workPackageTimeline.forceCursor(this.mouseDownCursorType);
}
} }
protected updateLabels(activeDragNDrop:boolean, protected updateLabels(activeDragNDrop:boolean,

@ -6,7 +6,7 @@ import {
RenderInfo, RenderInfo,
timelineElementCssClass, timelineElementCssClass,
} from '../wp-timeline'; } from '../wp-timeline';
import { CellDateMovement, LabelPosition, TimelineCellRenderer } from './timeline-cell-renderer';
import { import {
classNameFarRightLabel, classNameFarRightLabel,
classNameHideOnHover, classNameHideOnHover,
@ -17,8 +17,12 @@ import {
classNameRightHoverLabel, classNameRightHoverLabel,
classNameRightLabel, classNameRightLabel,
classNameShowOnHover, classNameShowOnHover,
WorkPackageCellLabels, CellDateMovement,
} from './wp-timeline-cell'; LabelPosition,
TimelineCellRenderer,
MouseDirection,
} from './timeline-cell-renderer';
import { WorkPackageCellLabels } from './wp-timeline-cell-labels';
import Moment = moment.Moment; import Moment = moment.Moment;
export class TimelineMilestoneCellRenderer extends TimelineCellRenderer { export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
@ -76,7 +80,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
public onDaysMoved(change:WorkPackageChangeset, public onDaysMoved(change:WorkPackageChangeset,
dayUnderCursor:Moment, dayUnderCursor:Moment,
delta:number, delta:number,
direction:'left' | 'right' | 'both' | 'create' | 'dragright') { _direction:MouseDirection):CellDateMovement {
const initialDate = change.pristineResource.date; const initialDate = change.pristineResource.date;
const dates:CellDateMovement = {}; const dates:CellDateMovement = {};
@ -90,8 +94,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
public onMouseDown(ev:MouseEvent, public onMouseDown(ev:MouseEvent,
dateForCreate:string | null, dateForCreate:string | null,
renderInfo:RenderInfo, renderInfo:RenderInfo,
labels:WorkPackageCellLabels, labels:WorkPackageCellLabels):MouseDirection {
elem:HTMLElement):'left' | 'right' | 'both' | 'create' | 'dragright' {
// check for active selection mode // check for active selection mode
if (renderInfo.viewParams.activeSelectionMode) { if (renderInfo.viewParams.activeSelectionMode) {
renderInfo.viewParams.activeSelectionMode(renderInfo.workPackage); renderInfo.viewParams.activeSelectionMode(renderInfo.workPackage);

@ -0,0 +1,38 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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.
//++
export class WorkPackageCellLabels {
constructor(public readonly center:HTMLDivElement|null,
public readonly left:HTMLDivElement,
public readonly leftHover:HTMLDivElement|null,
public readonly right:HTMLDivElement,
public readonly rightHover:HTMLDivElement|null,
public readonly farRight:HTMLDivElement,
public readonly withAlternativeLabels?:boolean) {
}
}

@ -39,17 +39,12 @@ import { WorkPackageNotificationService } from 'core-app/features/work-packages/
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { WorkPackageCellLabels } from './wp-timeline-cell'; import { WorkPackageCellLabels } from './wp-timeline-cell-labels';
import { TimelineCellRenderer } from './timeline-cell-renderer'; import { MouseDirection, TimelineCellRenderer } from './timeline-cell-renderer';
import { RenderInfo } from '../wp-timeline'; import { RenderInfo } from '../wp-timeline';
import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive'; import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive';
import Moment = moment.Moment; import Moment = moment.Moment;
export const classNameBar = 'bar';
export const classNameLeftHandle = 'leftHandle';
export const classNameRightHandle = 'rightHandle';
export const classNameBarLabel = 'bar-label';
export function registerWorkPackageMouseHandler(this:void, export function registerWorkPackageMouseHandler(this:void,
injector:Injector, injector:Injector,
getRenderInfo:() => RenderInfo, getRenderInfo:() => RenderInfo,
@ -68,7 +63,6 @@ export function registerWorkPackageMouseHandler(this:void,
let mouseDownStartDay:number|null = null; // also flag to signal active drag'n'drop let mouseDownStartDay:number|null = null; // also flag to signal active drag'n'drop
renderInfo.change = halEditing.changeFor(renderInfo.workPackage); renderInfo.change = halEditing.changeFor(renderInfo.workPackage);
let dateStates:any;
let placeholderForEmptyCell:HTMLElement; let placeholderForEmptyCell:HTMLElement;
const jBody = jQuery('body'); const jBody = jQuery('body');
@ -83,9 +77,10 @@ export function registerWorkPackageMouseHandler(this:void,
// handles initial creation of start/due values // handles initial creation of start/due values
cell.onmousemove = handleMouseMoveOnEmptyCell; cell.onmousemove = handleMouseMoveOnEmptyCell;
function applyDateValues(renderInfo:RenderInfo, dates:{ [name:string]:Moment }) { function applyRendererMoveChanges(dayUnderCursor:Moment, days:number, direction:MouseDirection) {
// Let the renderer decide which fields we change const moved = renderer.onDaysMoved(renderInfo.change, dayUnderCursor, days, direction);
renderer.assignDateValues(renderInfo.change, labels, dates); renderer.assignDateValues(renderInfo.change, labels, moved);
renderer.update(bar, labels, renderInfo);
} }
function getCursorOffsetInDaysFromLeft(renderInfo:RenderInfo, ev:MouseEvent) { function getCursorOffsetInDaysFromLeft(renderInfo:RenderInfo, ev:MouseEvent) {
@ -114,22 +109,20 @@ export function registerWorkPackageMouseHandler(this:void,
} }
// Determine what attributes of the work package should be changed // Determine what attributes of the work package should be changed
const direction = renderer.onMouseDown(ev, null, renderInfo, labels, bar); const direction = renderer.onMouseDown(ev, null, renderInfo, labels);
jBody.on('mousemove.timelinecell', createMouseMoveFn(direction)); jBody.on('mousemove.timelinecell', createMouseMoveFn(direction));
jBody.on('keyup.timelinecell', keyPressFn); jBody.on('keyup.timelinecell', keyPressFn);
jBody.on('mouseup.timelinecell', () => deactivate(false)); jBody.on('mouseup.timelinecell', () => deactivate(false));
} }
function createMouseMoveFn(direction:'left'|'right'|'both'|'create'|'dragright') { function createMouseMoveFn(direction:MouseDirection) {
return (ev:JQuery.MouseMoveEvent) => { return (ev:JQuery.MouseMoveEvent) => {
const days = getCursorOffsetInDaysFromLeft(renderInfo, ev.originalEvent!) - mouseDownStartDay!; const days = getCursorOffsetInDaysFromLeft(renderInfo, ev.originalEvent!) - mouseDownStartDay!;
const offsetDayCurrent = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay); const offsetDayCurrent = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay);
const dayUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayCurrent, 'days'); const dayUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayCurrent, 'days');
dateStates = renderer.onDaysMoved(renderInfo.change, dayUnderCursor, days, direction); applyRendererMoveChanges(dayUnderCursor, days, direction);
applyDateValues(renderInfo, dateStates);
renderer.update(bar, labels, renderInfo);
}; };
} }
@ -147,17 +140,21 @@ export function registerWorkPackageMouseHandler(this:void,
return; return;
} }
const isEditable = (wp.isLeaf || wp.scheduleManually) && renderer.canMoveDates(wp); // placeholder logic
placeholderForEmptyCell?.remove();
placeholderForEmptyCell = renderer.displayPlaceholderUnderCursor(ev, renderInfo);
const isEditable = (wp.isLeaf || wp.scheduleManually)
&& renderer.canMoveDates(wp)
&& !renderer.cursorOrDatesAreNonWorking(ev, renderInfo);
if (!isEditable) { if (!isEditable) {
cell.style.cursor = 'not-allowed'; cell.style.cursor = 'not-allowed';
return; return;
} }
// placeholder logic // display placeholder only if the timeline is editable
cell.style.cursor = ''; cell.style.cursor = '';
placeholderForEmptyCell && placeholderForEmptyCell.remove();
placeholderForEmptyCell = renderer.displayPlaceholderUnderCursor(ev, renderInfo);
cell.appendChild(placeholderForEmptyCell); cell.appendChild(placeholderForEmptyCell);
// abort if mouse leaves cell // abort if mouse leaves cell
@ -168,18 +165,22 @@ export function registerWorkPackageMouseHandler(this:void,
// create logic // create logic
cell.onmousedown = (ev) => { cell.onmousedown = (ev) => {
placeholderForEmptyCell.remove(); placeholderForEmptyCell.remove();
bar.style.pointerEvents = 'none';
ev.preventDefault(); ev.preventDefault();
const offsetDayStart = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay); if (renderer.cursorOrDatesAreNonWorking(ev, renderInfo)) {
const clickStart = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayStart, 'days'); return;
}
bar.style.pointerEvents = 'none';
const [clickStart, offsetDayStart] = renderer.cursorDateAndDayOffset(ev, renderInfo);
const dateForCreate = clickStart.format('YYYY-MM-DD'); const dateForCreate = clickStart.format('YYYY-MM-DD');
const mouseDownType = renderer.onMouseDown(ev, dateForCreate, renderInfo, labels, bar); const mouseDownType = renderer.onMouseDown(ev, dateForCreate, renderInfo, labels);
renderer.update(bar, labels, renderInfo); renderer.update(bar, labels, renderInfo);
if (mouseDownType === 'create') { if (mouseDownType === 'create') {
deactivate(false); deactivate(false);
ev.preventDefault();
return; return;
} }
@ -194,15 +195,15 @@ export function registerWorkPackageMouseHandler(this:void,
}; };
} }
function mouseMoveOnEmptyCellFn(offsetDayStart:number, mouseDownType:any) { function mouseMoveOnEmptyCellFn(offsetDayStart:number, mouseDownType:MouseDirection) {
return (ev:JQuery.MouseMoveEvent) => { return (ev:JQuery.MouseMoveEvent) => {
placeholderForEmptyCell.remove();
const relativePosition = Math.abs(cell.getBoundingClientRect().x - ev.clientX); const relativePosition = Math.abs(cell.getBoundingClientRect().x - ev.clientX);
const offsetDayCurrent = Math.floor(relativePosition / renderInfo.viewParams.pixelPerDay); const offsetDayCurrent = Math.floor(relativePosition / renderInfo.viewParams.pixelPerDay);
const dayUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayCurrent, 'days'); const dayUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayCurrent, 'days');
const widthInDays = offsetDayCurrent - offsetDayStart; const widthInDays = offsetDayCurrent - offsetDayStart;
const moved = renderer.onDaysMoved(renderInfo.change, dayUnderCursor, widthInDays, mouseDownType);
renderer.assignDateValues(renderInfo.change, labels, moved); applyRendererMoveChanges(dayUnderCursor, widthInDays, mouseDownType);
renderer.update(bar, labels, renderInfo);
}; };
} }
@ -220,10 +221,12 @@ export function registerWorkPackageMouseHandler(this:void,
jBody.off('.emptytimelinecell'); jBody.off('.emptytimelinecell');
workPackageTimeline.resetCursor(); workPackageTimeline.resetCursor();
mouseDownStartDay = null; mouseDownStartDay = null;
dateStates = {};
// const renderInfo = getRenderInfo(); // Cancel changes if the startDate or dueDate are not allowed
if (cancelled || renderInfo.change.isEmpty()) { const { startDate, dueDate } = renderInfo.change.projectedResource;
const invalidDates = renderer.cursorOrDatesAreNonWorking([moment(startDate), moment(dueDate)], renderInfo);
if (cancelled || renderInfo.change.isEmpty() || invalidDates) {
cancelChange(); cancelChange();
} else { } else {
const stopAndRefresh = () => { const stopAndRefresh = () => {

@ -38,30 +38,10 @@ import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { registerWorkPackageMouseHandler } from './wp-timeline-cell-mouse-handler'; import { registerWorkPackageMouseHandler } from './wp-timeline-cell-mouse-handler';
import { TimelineMilestoneCellRenderer } from './timeline-milestone-cell-renderer'; import { TimelineMilestoneCellRenderer } from './timeline-milestone-cell-renderer';
import { TimelineCellRenderer } from './timeline-cell-renderer'; import { TimelineCellRenderer } from './timeline-cell-renderer';
import { WorkPackageCellLabels } from './wp-timeline-cell-labels';
import { RenderInfo } from '../wp-timeline'; import { RenderInfo } from '../wp-timeline';
import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive'; import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive';
export const classNameLeftLabel = 'labelLeft';
export const classNameRightContainer = 'containerRight';
export const classNameRightLabel = 'labelRight';
export const classNameLeftHoverLabel = 'labelHoverLeft';
export const classNameRightHoverLabel = 'labelHoverRight';
export const classNameHoverStyle = '-label-style';
export const classNameFarRightLabel = 'labelFarRight';
export const classNameShowOnHover = 'show-on-hover';
export const classNameHideOnHover = 'hide-on-hover';
export class WorkPackageCellLabels {
constructor(public readonly center:HTMLDivElement|null,
public readonly left:HTMLDivElement,
public readonly leftHover:HTMLDivElement|null,
public readonly right:HTMLDivElement,
public readonly rightHover:HTMLDivElement|null,
public readonly farRight:HTMLDivElement,
public readonly withAlternativeLabels?:boolean) {
}
}
export class WorkPackageTimelineCell { export class WorkPackageTimelineCell {
@InjectField() halEditing:HalResourceEditingService; @InjectField() halEditing:HalResourceEditingService;

@ -78,7 +78,7 @@ export class WorkPackageTabsService {
...tab, ...tab,
counter: tab.count counter: tab.count
? (injector:Injector) => tab.count!(workPackage, injector || this.injector) // eslint-disable-line @typescript-eslint/no-non-null-assertion ? (injector:Injector) => tab.count!(workPackage, injector || this.injector) // eslint-disable-line @typescript-eslint/no-non-null-assertion
: (_:Injector) => from([0]), // eslint-disable-line @typescript-eslint/no-unused-vars : (_:Injector) => from([0]),
}), }),
); );
} }

@ -219,10 +219,8 @@ export class ProjectAutocompleterComponent implements ControlValueAccessor {
this.value = value; this.value = value;
} }
// eslint-disable-next-line no-unused-vars
onChange = (_:IProjectAutocompleterData|IProjectAutocompleterData[]|null):void => {}; onChange = (_:IProjectAutocompleterData|IProjectAutocompleterData[]|null):void => {};
// eslint-disable-next-line no-unused-vars
onTouched = (_:IProjectAutocompleterData|IProjectAutocompleterData[]|null):void => {}; onTouched = (_:IProjectAutocompleterData|IProjectAutocompleterData[]|null):void => {};
registerOnChange(fn:(_:IProjectAutocompleterData|IProjectAutocompleterData[]|null) => void):void { registerOnChange(fn:(_:IProjectAutocompleterData|IProjectAutocompleterData[]|null) => void):void {

@ -202,10 +202,8 @@ export class UserAutocompleterComponent extends UntilDestroyedMixin implements O
this.value = value; this.value = value;
} }
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
onChange = (_:IUserAutocompleteItem|IUserAutocompleteItem[]|null):void => {}; onChange = (_:IUserAutocompleteItem|IUserAutocompleteItem[]|null):void => {};
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
onTouched = (_:IUserAutocompleteItem|IUserAutocompleteItem[]|null):void => {}; onTouched = (_:IUserAutocompleteItem|IUserAutocompleteItem[]|null):void => {};
registerOnChange(fn:(_:IUserAutocompleteItem|IUserAutocompleteItem[]|null) => void):void { registerOnChange(fn:(_:IUserAutocompleteItem|IUserAutocompleteItem[]|null) => void):void {

@ -76,10 +76,8 @@ export class WpDestroyModalComponent extends OpModalComponent implements OnInit
label_confirm_children_deletion: this.I18n.t('js.modals.destroy_work_package.confirm_deletion_children'), label_confirm_children_deletion: this.I18n.t('js.modals.destroy_work_package.confirm_deletion_children'),
title: '', title: '',
text: '', text: '',
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars childCount: (_wp:WorkPackageResource):string => '',
childCount: (wp:WorkPackageResource):string => '', hasChildren: (_wp:WorkPackageResource):string => '',
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
hasChildren: (wp:WorkPackageResource):string => '',
deletesChildren: '', deletesChildren: '',
}; };

@ -67,8 +67,7 @@ export class ScrollableTabsComponent implements AfterViewInit, OnChanges {
this.updateScrollableArea(); this.updateScrollableArea();
} }
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars ngOnChanges(_changes:SimpleChanges):void {
ngOnChanges(changes:SimpleChanges):void {
if (this.pane) { if (this.pane) {
this.updateScrollableArea(); this.updateScrollableArea();
} }

@ -32,18 +32,39 @@ RSpec.describe 'Work package timeline date formatting',
with_settings: { date_format: '%Y-%m-%d' }, with_settings: { date_format: '%Y-%m-%d' },
js: true, js: true,
selenium: true do selenium: true do
shared_let(:type) { create(:type_bug) } shared_let(:type) { create(:type_bug, color: create(:color_green)) }
shared_let(:project) { create(:project, types: [type]) } shared_let(:project) { create(:project, types: [type]) }
shared_let(:start_date) { Date.parse('2020-12-31') }
shared_let(:due_date) { Date.parse('2021-01-01') }
shared_let(:duration) { due_date - start_date + 1 }
shared_let(:work_package) do shared_let(:work_package) do
create :work_package, create :work_package,
project:, project:,
type:, type:,
start_date: Date.parse('2020-12-31'), start_date:,
due_date: Date.parse('2021-01-01'), due_date:,
duration:,
subject: 'My subject' subject: 'My subject'
end end
shared_let(:work_package_with_non_working_days) do
create :work_package,
project:,
type:,
duration: 5,
subject: 'My Subject 2'
end
shared_let(:work_package_without_non_workign_days) do
create :work_package,
project:,
type:,
duration: 5,
ignore_non_working_days: true,
subject: 'Work Package ignoring non working days'
end
let(:week_days) { nil } let(:week_days) { nil }
let(:wp_timeline) { Pages::WorkPackagesTimeline.new(project) } let(:wp_timeline) { Pages::WorkPackagesTimeline.new(project) }
let!(:query_tl) do let!(:query_tl) do
@ -137,4 +158,152 @@ RSpec.describe 'Work package timeline date formatting',
expect_date_week '2021-01-03', '02' expect_date_week '2021-01-03', '02'
end end
end end
describe 'setting dates' do
let(:current_user) { create :admin }
let(:week_days) { create :week_days }
let(:row) { wp_timeline.timeline_row work_package_with_non_working_days.id }
shared_examples "sets dates, duration and displays bar" do
it 'sets dates, duration and duration bar' do
subject
row.expect_bar(duration: expected_bar_duration)
row.expect_labels left: nil,
right: nil,
farRight: expected_label
row.expect_hovered_labels left: expected_start_date.iso8601,
right: expected_due_date.iso8601
target_wp.reload.tap do |wp|
expect(wp.start_date).to eq(expected_start_date)
expect(wp.due_date).to eq(expected_due_date)
expect(wp.duration).to eq(expected_duration)
end
end
end
context 'with an existing duration only' do
before do
# Reset dates on each run
work_package_with_non_working_days.update({ start_date: nil, due_date: nil, duration: 5 })
end
it 'displays the hover bar correctly' do
# Expect no hover bar when hovering over a non working day
row.hover_bar(offset_days: -1)
row.expect_no_hovered_bar
# Expect timeline bar when clicking on a non working day
row.click_bar(offset_days: -1)
row.expect_no_bar
# Expect hovered bar size to equal duration
row.hover_bar
row.expect_hovered_bar(duration: work_package_with_non_working_days.duration)
# Expect hovered bar size to equal duration + non working days
# when the hovered bar passes through non working days
row.hover_bar(offset_days: 1)
row.expect_hovered_bar(duration: work_package_with_non_working_days.duration + 2)
end
describe 'set the start, due date while preserving duration' do
subject { row.click_bar }
it_behaves_like 'sets dates, duration and displays bar' do
let(:target_wp) { work_package_with_non_working_days }
let(:expected_bar_duration) { work_package_with_non_working_days.duration }
let(:expected_start_date) { Date.parse('2021-01-04') }
let(:expected_due_date) { Date.parse('2021-01-08') }
let(:expected_duration) { 5 }
let(:expected_label) { work_package_with_non_working_days.subject }
end
end
describe 'set the start, due date while preserving duration over the weekend' do
subject { row.click_bar(offset_days: 1) }
it_behaves_like 'sets dates, duration and displays bar' do
let(:target_wp) { work_package_with_non_working_days }
let(:expected_bar_duration) { work_package_with_non_working_days.duration + 2 }
let(:expected_start_date) { Date.parse('2021-01-05') }
let(:expected_due_date) { Date.parse('2021-01-11') }
let(:expected_duration) { 5 }
let(:expected_label) { work_package_with_non_working_days.subject }
end
end
describe 'sets the start, due dates while preserving duration on a drag and drop create' do
subject { row.drag_and_drop(days: 5) }
it_behaves_like 'sets dates, duration and displays bar' do
let(:target_wp) { work_package_with_non_working_days }
let(:expected_bar_duration) { work_package_with_non_working_days.duration }
let(:expected_start_date) { Date.parse('2021-01-04') }
let(:expected_due_date) { Date.parse('2021-01-08') }
let(:expected_duration) { 5 }
let(:expected_label) { work_package_with_non_working_days.subject }
end
end
describe 'sets the start, due dates while preserving duration on a drag and drop create over the weekend' do
subject { row.drag_and_drop(offset_days: 1, days: 7) }
it_behaves_like 'sets dates, duration and displays bar' do
let(:target_wp) { work_package_with_non_working_days }
let(:expected_bar_duration) { work_package_with_non_working_days.duration + 2 }
let(:expected_start_date) { Date.parse('2021-01-05') }
let(:expected_due_date) { Date.parse('2021-01-11') }
let(:expected_duration) { 5 }
let(:expected_label) { work_package_with_non_working_days.subject }
end
end
describe 'sets the start, due dates and duration on a drag and drop create over the weekend' do
subject { row.drag_and_drop(offset_days: 1, days: 8) }
it_behaves_like 'sets dates, duration and displays bar' do
let(:target_wp) { work_package_with_non_working_days }
let(:expected_bar_duration) { work_package_with_non_working_days.duration + 3 }
let(:expected_start_date) { Date.parse('2021-01-05') }
let(:expected_due_date) { Date.parse('2021-01-12') }
let(:expected_duration) { 6 }
let(:expected_label) { work_package_with_non_working_days.subject }
end
end
it 'cancels when the drag starts or finishes on a weekend' do
# Finish on the weekend
row.drag_and_drop(offset_days: 1, days: 5)
row.expect_no_bar
expect { work_package_with_non_working_days.reload }.not_to change { work_package_with_non_working_days }
# Start on the weekend
row.drag_and_drop(offset_days: -1, days: 5)
row.expect_no_bar
expect { work_package_with_non_working_days.reload }.not_to change { work_package_with_non_working_days }
end
context 'when ignore_non_working_days if true' do
let(:row) { wp_timeline.timeline_row work_package_without_non_workign_days.id }
describe 'sets the start, due dates and duration on a drag and drop create over the weekend' do
subject { row.drag_and_drop(offset_days: 1, days: 8) }
it_behaves_like 'sets dates, duration and displays bar' do
let(:target_wp) { work_package_without_non_workign_days }
let(:expected_bar_duration) { work_package_without_non_workign_days.duration + 3 }
let(:expected_start_date) { Date.parse('2021-01-05') }
let(:expected_due_date) { Date.parse('2021-01-12') }
let(:expected_duration) { 8 }
let(:expected_label) { work_package_without_non_workign_days.subject }
end
end
end
end
end
end end

@ -71,6 +71,46 @@ module Components
end end
end end
end end
def hover_bar(offset_days: 0)
container.hover
offset_x = offset_days * 30
page.driver.browser.action.move_to(@container.native, offset_x).perform
end
def click_bar(offset_days: 0)
hover_bar(offset_days:)
page.driver.browser.action.click.perform
end
def expect_hovered_bar(duration: 1)
expected_length = duration * 30
expect(container).to have_selector('div[class^="__hl_background_"', style: { width: "#{expected_length}px" })
end
def expect_bar(duration: 1)
loading_indicator_saveguard
expected_length = duration * 30
expect(container).to have_selector('.timeline-element', style: { width: "#{expected_length}px" })
end
def expect_no_hovered_bar
expect(container).not_to have_selector('div[class^="__hl_background_"')
end
def expect_no_bar
loading_indicator_saveguard
expect(container).not_to have_selector('.timeline-element')
end
def drag_and_drop(offset_days: 0, days: 1)
container.hover
offset_x_start = offset_days * 30
start_dragging(container, offset_x: offset_x_start)
offset_x = ((days - 1) * 30) + offset_x_start
drag_element_to(container, offset_x:)
drag_release
end
end end
end end
end end

@ -26,14 +26,14 @@
# See COPYRIGHT and LICENSE files for more details. # See COPYRIGHT and LICENSE files for more details.
#++ #++
def start_dragging(from) def start_dragging(from, offset_x: nil, offset_y: nil)
scroll_to_element(from) scroll_to_element(from)
page page
.driver .driver
.browser .browser
.action .action
.move_to(from.native) .move_to(from.native, offset_x, offset_y)
.click_and_hold(from.native) .click_and_hold
.perform .perform
end end

Loading…
Cancel
Save