Commit c0acae35 authored by Mark Florian's avatar Mark Florian

Merge branch '262852-adjsut-schedule-mobile-css' into 'master'

Fix(oncallschedule): Week draw edge cases and small rotation CSS

See merge request gitlab-org/gitlab!54003
parents 2e6c3610 2749deac
...@@ -37,6 +37,11 @@ ...@@ -37,6 +37,11 @@
&.gl-modal .modal-md { &.gl-modal .modal-md {
max-width: 640px; max-width: 640px;
} }
.dropdown-menu {
max-height: $dropdown-max-height;
@include gl-overflow-y-auto;
}
} }
//// Copied from roadmaps.scss - adapted for on-call schedules //// Copied from roadmaps.scss - adapted for on-call schedules
...@@ -182,10 +187,3 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi ...@@ -182,10 +187,3 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
.gl-token {
.gl-avatar-labeled-label {
@include gl-text-white;
@include gl-font-weight-normal;
}
}
...@@ -32,6 +32,10 @@ export const i18n = { ...@@ -32,6 +32,10 @@ export const i18n = {
deleteScheduleLabel: s__('OnCallSchedules|Delete schedule'), deleteScheduleLabel: s__('OnCallSchedules|Delete schedule'),
rotationTitle: s__('OnCallSchedules|Rotations'), rotationTitle: s__('OnCallSchedules|Rotations'),
addARotation: s__('OnCallSchedules|Add a rotation'), addARotation: s__('OnCallSchedules|Add a rotation'),
presetTypeLabels: {
DAYS: s__('OnCallSchedules|1 day'),
WEEKS: s__('OnCallSchedules|2 weeks'),
},
}; };
export const editScheduleModalId = 'editScheduleModal'; export const editScheduleModalId = 'editScheduleModal';
export const deleteScheduleModalId = 'deleteScheduleModal'; export const deleteScheduleModalId = 'deleteScheduleModal';
...@@ -69,6 +73,7 @@ export default { ...@@ -69,6 +73,7 @@ export default {
rotations: { rotations: {
query: getShiftsForRotations, query: getShiftsForRotations,
variables() { variables() {
this.timeframeStartDate.setHours(0, 0, 0, 0);
const startsAt = this.timeframeStartDate; const startsAt = this.timeframeStartDate;
const endsAt = nWeeksAfter(startsAt, 2); const endsAt = nWeeksAfter(startsAt, 2);
...@@ -191,14 +196,30 @@ export default { ...@@ -191,14 +196,30 @@ export default {
</gl-button-group> </gl-button-group>
</div> </div>
</template> </template>
<p <p class="gl-text-gray-500 gl-mb-3" data-testid="scheduleBody">
class="gl-text-gray-500 gl-mb-3 gl-display-flex gl-justify-content-space-between gl-align-items-center"
data-testid="scheduleBody"
>
<gl-sprintf :message="$options.i18n.scheduleForTz"> <gl-sprintf :message="$options.i18n.scheduleForTz">
<template #timezone>{{ schedule.timezone }}</template> <template #timezone>{{ schedule.timezone }}</template>
</gl-sprintf> </gl-sprintf>
| {{ offset }} | {{ offset }}
</p>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
<div class="gl-display-flex gl-align-items-center">
<gl-button-group>
<gl-button
data-testid="previous-timeframe-btn"
icon="chevron-left"
:disabled="loading"
@click="updateToViewPreviousTimeframe"
/>
<gl-button
data-testid="next-timeframe-btn"
icon="chevron-right"
:disabled="loading"
@click="updateToViewNextTimeframe"
/>
</gl-button-group>
<div class="gl-ml-3">{{ scheduleRange }}</div>
</div>
<gl-button-group data-testid="shift-preset-change"> <gl-button-group data-testid="shift-preset-change">
<gl-button <gl-button
v-for="type in $options.PRESET_TYPES" v-for="type in $options.PRESET_TYPES"
...@@ -207,26 +228,9 @@ export default { ...@@ -207,26 +228,9 @@ export default {
:title="formatPresetType(type)" :title="formatPresetType(type)"
@click="switchPresetType(type)" @click="switchPresetType(type)"
> >
{{ formatPresetType(type) }} {{ $options.i18n.presetTypeLabels[type] }}
</gl-button> </gl-button>
</gl-button-group> </gl-button-group>
</p>
<div class="gl-w-full gl-display-flex gl-align-items-center gl-pb-3">
<gl-button-group>
<gl-button
data-testid="previous-timeframe-btn"
icon="chevron-left"
:disabled="loading"
@click="updateToViewPreviousTimeframe"
/>
<gl-button
data-testid="next-timeframe-btn"
icon="chevron-right"
:disabled="loading"
@click="updateToViewNextTimeframe"
/>
</gl-button-group>
<p class="gl-ml-3 gl-mb-0">{{ scheduleRange }}</p>
</div> </div>
<gl-card header-class="gl-bg-transparent"> <gl-card header-class="gl-bg-transparent">
......
...@@ -30,7 +30,12 @@ export const i18n = { ...@@ -30,7 +30,12 @@ export const i18n = {
title: __('Participants'), title: __('Participants'),
error: s__('OnCallSchedules|Rotation participants cannot be empty'), error: s__('OnCallSchedules|Rotation participants cannot be empty'),
}, },
rotationLength: { title: s__('OnCallSchedules|Rotation length') }, rotationLength: {
title: s__('OnCallSchedules|Rotation length'),
description: s__(
'OnCallSchedules|Please note, rotations with shifts that are less than four hours are currently not supported in the weekly view.',
),
},
startsAt: { startsAt: {
title: __('Starts on'), title: __('Starts on'),
error: s__('OnCallSchedules|Rotation start date cannot be empty'), error: s__('OnCallSchedules|Rotation start date cannot be empty'),
...@@ -153,6 +158,7 @@ export default { ...@@ -153,6 +158,7 @@ export default {
<gl-form-group <gl-form-group
:label="$options.i18n.fields.rotationLength.title" :label="$options.i18n.fields.rotationLength.title"
:description="$options.i18n.fields.rotationLength.description"
label-size="sm" label-size="sm"
label-for="rotation-length" label-for="rotation-length"
> >
......
...@@ -71,7 +71,7 @@ export default { ...@@ -71,7 +71,7 @@ export default {
participants: [], participants: [],
rotationLength: { rotationLength: {
length: 1, length: 1,
unit: this.$options.LENGTH_ENUM.hours, unit: this.$options.LENGTH_ENUM.days,
}, },
startsAt: { startsAt: {
date: null, date: null,
......
<script> <script>
import { GlToken, GlAvatarLabeled, GlPopover } from '@gitlab/ui'; import { GlToken, GlAvatar, GlPopover } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import { truncate } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
export const SHIFT_WIDTHS = {
md: 140,
sm: 90,
xs: 40,
};
export default { export default {
components: { components: {
GlAvatarLabeled, GlAvatar,
GlPopover, GlPopover,
GlToken, GlToken,
}, },
...@@ -26,6 +33,10 @@ export default { ...@@ -26,6 +33,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
shiftWidth: {
type: Number,
required: true,
},
}, },
computed: { computed: {
chevronClass() { chevronClass() {
...@@ -45,34 +56,40 @@ export default { ...@@ -45,34 +56,40 @@ export default {
endsAt: formatDate(this.rotationAssigneeEndsAt, 'mmmm d, yyyy, h:MMtt Z'), endsAt: formatDate(this.rotationAssigneeEndsAt, 'mmmm d, yyyy, h:MMtt Z'),
}); });
}, },
rotationMobileView() {
return this.shiftWidth <= SHIFT_WIDTHS.xs;
},
assigneeName() {
if (this.shiftWidth <= SHIFT_WIDTHS.sm) {
return truncate(this.assignee.user.username, 3);
}
return this.assignee.user.username;
},
}, },
}; };
</script> </script>
<template> <template>
<div <div class="gl-absolute gl-h-7 gl-mt-3" :style="rotationAssigneeStyle">
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden"
:style="rotationAssigneeStyle"
>
<gl-token <gl-token
:id="rotationAssigneeUniqueID" :id="rotationAssigneeUniqueID"
class="gl-w-full gl-h-6 gl-align-items-center" class="gl-w-full gl-h-6 gl-align-items-center"
:class="chevronClass" :class="chevronClass"
:view-only="true" :view-only="true"
> >
<gl-avatar-labeled <div class="gl-display-flex gl-text-white gl-font-weight-normal">
shape="circle" <gl-avatar :src="assignee.avatarUrl" :size="16" />
:size="16" <span v-if="!rotationMobileView" class="gl-ml-2" data-testid="rotation-assignee-name">{{
:src="assignee.avatarUrl" assigneeName
:label="assignee.user.username" }}</span>
:title="assignee.user.username" </div>
/>
</gl-token> </gl-token>
<gl-popover <gl-popover
:target="rotationAssigneeUniqueID" :target="rotationAssigneeUniqueID"
:title="assignee.user.username" :title="assignee.user.username"
triggers="hover" triggers="hover"
placement="left" placement="top"
> >
<p class="gl-m-0" data-testid="rotation-assignee-starts-at">{{ startsAt }}</p> <p class="gl-m-0" data-testid="rotation-assignee-starts-at">{{ startsAt }}</p>
<p class="gl-m-0" data-testid="rotation-assignee-ends-at">{{ endsAt }}</p> <p class="gl-m-0" data-testid="rotation-assignee-ends-at">{{ endsAt }}</p>
......
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
<template> <template>
<span class="timeline-header-item" :style="timelineHeaderStyles"> <span class="timeline-header-item" :style="timelineHeaderStyles">
<div class="item-label gl-pl-6 gl-py-4" data-testid="timeline-header-label"> <div class="item-label gl-pl-5 gl-py-4" data-testid="timeline-header-label">
{{ timelineHeaderLabel }} {{ timelineHeaderLabel }}
</div> </div>
<days-header-sub-item :timeframe-item="timeframeItem" /> <days-header-sub-item :timeframe-item="timeframeItem" />
......
...@@ -126,14 +126,15 @@ export default { ...@@ -126,14 +126,15 @@ export default {
> >
<span class="gl-text-truncated">{{ rotation.name }}</span> <span class="gl-text-truncated">{{ rotation.name }}</span>
<gl-button-group class="gl-px-2"> <gl-button-group class="gl-px-2">
<!-- TODO: Un-hide this button when: https://gitlab.com/gitlab-org/gitlab/-/issues/262862 is completed -->
<gl-button <gl-button
v-gl-modal="$options.editRotationModalId" v-gl-modal="$options.editRotationModalId"
v-gl-tooltip v-gl-tooltip
class="gl-display-none"
category="tertiary" category="tertiary"
:title="$options.i18n.editRotationLabel" :title="$options.i18n.editRotationLabel"
icon="pencil" icon="pencil"
:aria-label="$options.i18n.editRotationLabel" :aria-label="$options.i18n.editRotationLabel"
:disabled="true"
/> />
<gl-button <gl-button
v-gl-modal="$options.deleteRotationModalId" v-gl-modal="$options.deleteRotationModalId"
......
<script> <script>
import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue'; import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
import { HOURS_IN_DAY, ASSIGNEE_SPACER } from 'ee/oncall_schedules/constants'; import { HOURS_IN_DAY, ASSIGNEE_SPACER } from 'ee/oncall_schedules/constants';
import { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility'; import { getOverlapDateInPeriods } from '~/lib/utils/datetime_utility';
import { currentTimeframeEndsAt } from './shift_utils';
export default { export default {
components: { components: {
...@@ -35,36 +36,28 @@ export default { ...@@ -35,36 +36,28 @@ export default {
}, },
computed: { computed: {
currentTimeframeEndsAt() { currentTimeframeEndsAt() {
return nDaysAfter(this.timeframeItem, 1); return currentTimeframeEndsAt(this.timeframeItem, this.presetType);
}, },
hoursUntilEndOfTimeFrame() { hoursUntilEndOfTimeFrame() {
return HOURS_IN_DAY - new Date(this.shiftRangeOverlap.overlapStartDate).getHours(); return HOURS_IN_DAY - new Date(this.shiftRangeOverlap.overlapStartDate).getHours();
}, },
rotationAssigneeStyle() { rotationAssigneeStyle() {
const startHour = this.shiftStartsAt.getHours();
const isFirstCell = startHour === 0;
const shouldStartAtBeginningOfCell = isFirstCell || this.shiftStartHourOutOfRange;
const widthOffset = shouldStartAtBeginningOfCell ? 0 : 1;
const width =
this.shiftEndsAt.getTime() > this.currentTimeframeEndsAt.getTime()
? HOURS_IN_DAY
: this.shiftRangeOverlap.hoursOverlap + widthOffset;
const left = shouldStartAtBeginningOfCell
? '0px'
: `${(23 - this.hoursUntilEndOfTimeFrame) * this.shiftTimeUnitWidth + ASSIGNEE_SPACER}px`;
return { return {
left, left: `${this.shiftLeft}px`,
width: `${this.shiftTimeUnitWidth * width}px`, width: `${this.shiftWidth}px`,
}; };
}, },
shiftStartsAt() {
return new Date(this.shift.startsAt);
},
shiftEndsAt() { shiftEndsAt() {
return new Date(this.shift.endsAt); return new Date(this.shift.endsAt);
}, },
shiftLeft() {
const shouldStartAtBeginningOfCell =
this.shiftStartsAt.getHours() === 0 || this.shiftStartHourOutOfRange;
return shouldStartAtBeginningOfCell
? 0
: (HOURS_IN_DAY - this.hoursUntilEndOfTimeFrame) * this.shiftTimeUnitWidth;
},
shiftRangeOverlap() { shiftRangeOverlap() {
try { try {
return getOverlapDateInPeriods( return getOverlapDateInPeriods(
...@@ -75,14 +68,18 @@ export default { ...@@ -75,14 +68,18 @@ export default {
return { hoursOverlap: 0 }; return { hoursOverlap: 0 };
} }
}, },
shiftStartsAt() {
return new Date(this.shift.startsAt);
},
shiftStartHourOutOfRange() { shiftStartHourOutOfRange() {
return this.shiftStartsAt.getTime() < this.timeframeItem.getTime(); return this.shiftStartsAt.getTime() < this.timeframeItem.getTime();
}, },
shiftShouldRender() { shiftWidth() {
return Boolean( const baseWidth =
this.shiftRangeOverlap.hoursOverlap && this.shiftEndsAt.getTime() >= this.currentTimeframeEndsAt.getTime()
!(this.shiftStartsAt.getDate() > this.timeframeItem.getDate()), ? HOURS_IN_DAY
); : this.shiftRangeOverlap.hoursOverlap;
return this.shiftTimeUnitWidth * baseWidth - ASSIGNEE_SPACER;
}, },
}, },
}; };
...@@ -90,10 +87,10 @@ export default { ...@@ -90,10 +87,10 @@ export default {
<template> <template>
<rotation-assignee <rotation-assignee
v-if="shiftShouldRender"
:assignee="shift.participant" :assignee="shift.participant"
:rotation-assignee-style="rotationAssigneeStyle" :rotation-assignee-style="rotationAssigneeStyle"
:rotation-assignee-starts-at="shift.startsAt" :rotation-assignee-starts-at="shift.startsAt"
:rotation-assignee-ends-at="shift.endsAt" :rotation-assignee-ends-at="shift.endsAt"
:shift-width="shiftWidth"
/> />
</template> </template>
<script> <script>
import { PRESET_TYPES, DAYS_IN_DATE_WEEK } from 'ee/oncall_schedules/constants'; import { PRESET_TYPES, SHIFT_WIDTH_CALCULATION_DELAY } from 'ee/oncall_schedules/constants';
import getShiftTimeUnitWidthQuery from 'ee/oncall_schedules/graphql/queries/get_shift_time_unit_width.query.graphql'; import getShiftTimeUnitWidthQuery from 'ee/oncall_schedules/graphql/queries/get_shift_time_unit_width.query.graphql';
import { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility';
import DaysScheduleShift from './days_schedule_shift.vue'; import DaysScheduleShift from './days_schedule_shift.vue';
import { shiftsToRender } from './shift_utils';
import WeeksScheduleShift from './weeks_schedule_shift.vue'; import WeeksScheduleShift from './weeks_schedule_shift.vue';
export default { export default {
...@@ -40,31 +40,26 @@ export default { ...@@ -40,31 +40,26 @@ export default {
apollo: { apollo: {
shiftTimeUnitWidth: { shiftTimeUnitWidth: {
query: getShiftTimeUnitWidthQuery, query: getShiftTimeUnitWidthQuery,
debounce: SHIFT_WIDTH_CALCULATION_DELAY,
}, },
}, },
computed: { computed: {
currentTimeframeEndsAt() { rotationLength() {
return new Date( const { length, lengthUnit } = this.rotation;
nDaysAfter( return { length, lengthUnit };
this.timeframeItem,
this.presetType === PRESET_TYPES.DAYS ? 1 : DAYS_IN_DATE_WEEK,
),
);
}, },
shiftsToRender() { shiftsToRender() {
const validShifts = this.rotation.shifts.nodes.filter( return Object.freeze(
({ startsAt, endsAt }) => this.shiftRangeOverlap(startsAt, endsAt).hoursOverlap > 0, shiftsToRender(
this.rotation.shifts.nodes,
this.timeframeItem,
this.presetType,
this.timeframeIndex,
),
); );
// TODO: If week view and on same day, dont show more than 1 assignee or use CSS to limit their size to be readable
return Object.freeze(validShifts);
}, },
}, timeframeIndex() {
methods: { return this.timeframe.indexOf(this.timeframeItem);
shiftRangeOverlap(shiftStartsAt, shiftEndsAt) {
return getOverlapDateInPeriods(
{ start: this.timeframeItem, end: this.currentTimeframeEndsAt },
{ start: shiftStartsAt, end: shiftEndsAt },
);
}, },
}, },
}; };
...@@ -82,6 +77,7 @@ export default { ...@@ -82,6 +77,7 @@ export default {
:timeframe-item="timeframeItem" :timeframe-item="timeframeItem"
:timeframe="timeframe" :timeframe="timeframe"
:shift-time-unit-width="shiftTimeUnitWidth" :shift-time-unit-width="shiftTimeUnitWidth"
:rotation-length="rotationLength"
/> />
</div> </div>
</template> </template>
import {
PRESET_TYPES,
DAYS_IN_WEEK,
ASSIGNEE_SPACER,
HOURS_IN_DAY,
} from 'ee/oncall_schedules/constants';
import {
getOverlapDateInPeriods,
getDayDifference,
nDaysAfter,
} from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
/**
* This method returns a Date that is
* n days after the start Date provided. This
* is used to calculate the end Date of a time
* frame item.
*
*
* @param {Date} timeframeStart - the current timeframe start Date.
* @param {String} presetType - the current grid type i.e. Week, Day, Hour.
* @returns {Date}
* @throws {Error} Uncaught Error: Invalid date
*
* @example
* currentTimeframeEndsAt(new Date(2021, 01, 07), 'WEEKS') => new Date(2021, 01, 14)
* currentTimeframeEndsAt(new Date(2021, 01, 07), 'DAYS') => new Date(2021, 01, 08)
*
*/
export const currentTimeframeEndsAt = (timeframeStart, presetType) => {
if (!(timeframeStart instanceof Date)) {
throw new Error(__('Invalid date'));
}
return nDaysAfter(timeframeStart, presetType === PRESET_TYPES.DAYS ? 1 : DAYS_IN_WEEK);
};
/**
* This method returns a Boolean
* to decide if a current shift item
* is valid for render by checking if there
* is an hoursOverlap greater than 0
*
*
* @param {Object} shiftRangeOverlap - current shift range overlap object.
* @returns {Boolean}
*
* @example
* shiftShouldRender({ hoursOverlap: 48 })
* => true
*
*/
export const shiftShouldRender = (shiftRangeOverlap = {}) => {
return Boolean(shiftRangeOverlap?.hoursOverlap);
};
/**
* This method extends shiftShouldRender for a week item
* by adding a conditional check for if the
* shift occurs after the first timeframe
* item, we need to check if the current shift
* starts on the timeframe start Date
*
*
* @param {Object} shiftRangeOverlap - current shift range overlap object.
* @param {Number} timeframeIndex - current timeframe index.
* @param {Date} shiftStartsAt - current shift start Date.
* @param {Date} timeframeItem - the current timeframe start Date.
* @returns {Boolean}
*
* @example
* weekShiftShouldRender({ overlapStartDate: 1610074800000, hoursOverlap: 3 }, 0, new Date(2021-01-07), new Date(2021-01-08))
* => true
*
*/
export const weekShiftShouldRender = (
shiftRangeOverlap,
timeframeIndex,
shiftStartsAt,
timeframeItem,
) => {
if (timeframeIndex === 0) {
return shiftShouldRender(shiftRangeOverlap);
}
return (
(shiftStartsAt >= timeframeItem ||
new Date(shiftRangeOverlap.overlapStartDate) > timeframeItem) &&
new Date(shiftRangeOverlap.overlapStartDate) <
currentTimeframeEndsAt(timeframeItem, PRESET_TYPES.WEEKS)
);
};
/**
* This method returns array of shifts to render
* against a current timeframe Date i.e.
* return any shifts that have an overlap with the current
* timeframe Date
*
*
* @param {Array} shifts - current array of shifts for a given rotation timeframe.
* @param {Date} timeframeItem - the current timeframe start Date.
* @param {String} presetType - the current grid type i.e. Week, Day, Hour.
* @param {Number} timeframeIndex - the index of the current timeframe.
* @returns {Array}
*
* @example
* shiftsToRender([{ startsAt: '2021-01-07', endsAt: '2021-01-08' }, { startsAt: '2021-01-016', endsAt: '2021-01-19' }], new Date(2021, 01, 07), 'WEEKS')
* => [{ startsAt: '2021-01-07', endsAt: '2021-01-08' }]
*
*/
export const shiftsToRender = (shifts, timeframeItem, presetType, timeframeIndex) => {
try {
const timeframeEndsAt = currentTimeframeEndsAt(timeframeItem, presetType);
const overlap = (startsAt, endsAt) =>
getOverlapDateInPeriods(
{ start: timeframeItem, end: timeframeEndsAt },
{ start: startsAt, end: endsAt },
);
if (presetType === PRESET_TYPES.DAYS) {
return shifts.filter(({ startsAt, endsAt }) => overlap(startsAt, endsAt).hoursOverlap > 0);
}
return shifts.filter(({ startsAt, endsAt }) =>
weekShiftShouldRender(
overlap(startsAt, endsAt),
timeframeIndex,
new Date(startsAt),
timeframeItem,
),
);
} catch (error) {
return [];
}
};
/**
* This method calculates the amount of days until the end of the current
* timeframe from where the current shift overlap begins at, taking
* into account when a timeframe might transition month during render
*
*
* @param {Object} shiftRangeOverlap - current shift range overlap object.
* @param {Date} timeframeItem - the current timeframe start Date.
* @param {String} presetType - the current grid type i.e. Week, Day, Hour.
* @returns {Number}
*
* @example
* daysUntilEndOfTimeFrame({ overlapStartDate: 1612814725387 }, Date Mon Feb 08 2021 15:04:57, 'WEEKS')
* => 7
* Where overlapStartDate is the timestamp equal to Date Mon Feb 08 2021 15:04:57
*
*/
export const daysUntilEndOfTimeFrame = (shiftRangeOverlap, timeframeItem, presetType) => {
const timeframeEndsAt = currentTimeframeEndsAt(timeframeItem, presetType);
const startDate = new Date(shiftRangeOverlap?.overlapStartDate);
if (timeframeEndsAt.getMonth() !== startDate.getMonth()) {
return Math.abs(getDayDifference(timeframeEndsAt, startDate));
}
return timeframeEndsAt.getDate() - startDate.getDate();
};
/**
* This method calculates the total left position of a current week
* rotation cell for less than 24 hours, equal to 24 hours
* or more than 24 hours
*
*
* @param {Boolean} shiftUnitIsHour - true if the current shift length is less than 24 hours.
* @param {Object} shiftRangeOverlap - current shift range overlap object.
* @param {Boolean} shiftStartDateOutOfRange - true if the current shift start date is outside of the current grid range.
* @param {String} shiftTimeUnitWidth - the current grid type i.e. Week, Day, Hour.
* @param {Date} shiftStartsAt - current shift start Date.
* @param {Date} timeframeItem - the current timeframe start Date.
* * @param {String} presetType - the current grid type i.e. Week, Day, Hour.
* @returns {Number}
*
* @example
* weekDisplayShiftLeft(false, { daysOverlap: 3 }, false , 50, Date Mon Feb 08 2021 15:04:57, Date Mon Feb 08 2021 15:04:57, 'WEEKS')
* => 148
*
*/
export const weekDisplayShiftLeft = (
shiftUnitIsHour,
shiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
shiftStartsAt,
timeframeItem,
presetType,
) => {
const startDate = shiftStartsAt.getDate();
const firstDayOfWeek = timeframeItem.getDate();
const shiftStartsEarly = startDate === firstDayOfWeek || shiftStartDateOutOfRange;
const daysUntilEnd = daysUntilEndOfTimeFrame(shiftRangeOverlap, timeframeItem, presetType);
const dayOffSet = (DAYS_IN_WEEK - daysUntilEnd) * shiftTimeUnitWidth;
if (shiftUnitIsHour) {
const hourOffset =
(shiftTimeUnitWidth / HOURS_IN_DAY) * new Date(shiftRangeOverlap.overlapStartDate).getHours();
return dayOffSet + Math.floor(hourOffset);
}
if (shiftStartsEarly) {
return 0;
}
return dayOffSet;
};
/**
* This method calculates the total width of a current week
* rotation cell for less than 24 hours, equal to 24 hours
* or more than 24 hours
*
*
* @param {Boolean} shiftUnitIsHour - true if the current shift length is less than 24 hours.
* @param {Object} shiftRangeOverlap - current shift range overlap object.
* @param {Boolean} shiftStartDateOutOfRange - true if the current shift start date is outside of the current grid range.
* @param {String} shiftTimeUnitWidth - the current grid type i.e. Week, Day, Hour.
* @returns {Number}
*
* @example
* weekDisplayShiftWidth(false, { daysOverlap: 3 }, false , 50)
* => 148
*
*/
export const weekDisplayShiftWidth = (
shiftUnitIsHour,
shiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
) => {
if (shiftUnitIsHour) {
return (
Math.floor((shiftTimeUnitWidth / HOURS_IN_DAY) * shiftRangeOverlap.hoursOverlap) -
ASSIGNEE_SPACER
);
}
const widthOffset = shiftStartDateOutOfRange ? 1 : 0;
return shiftTimeUnitWidth * (shiftRangeOverlap.daysOverlap - widthOffset) - ASSIGNEE_SPACER;
};
<script> <script>
import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue'; import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
import { DAYS_IN_WEEK, DAYS_IN_DATE_WEEK, ASSIGNEE_SPACER } from 'ee/oncall_schedules/constants'; import { DAYS_IN_WEEK, HOURS_IN_DAY } from 'ee/oncall_schedules/constants';
import { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility'; import { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility';
import { weekDisplayShiftLeft, weekDisplayShiftWidth } from './shift_utils';
export default { export default {
components: { components: {
...@@ -32,35 +33,46 @@ export default { ...@@ -32,35 +33,46 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
rotationLength: {
type: Object,
required: true,
},
}, },
computed: { computed: {
currentTimeframeEndsAt() { currentTimeFrameEnd() {
return nDaysAfter(this.timeframeItem, DAYS_IN_DATE_WEEK); return nDaysAfter(this.timeframeEndsAt, DAYS_IN_WEEK);
}, },
daysUntilEndOfTimeFrame() { shiftStyles() {
if (this.currentTimeframeEndsAt.getMonth() !== this.timeframeItem.getMonth()) { const {
// TODO: Handle Edge case where timeframe spans two different months shiftUnitIsHour,
} totalShiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
shiftStartsAt,
timeframeItem,
presetType,
} = this;
return ( return {
this.currentTimeframeEndsAt.getDate() - left: weekDisplayShiftLeft(
new Date(this.shiftRangeOverlap.overlapStartDate).getDate() + shiftUnitIsHour,
1 totalShiftRangeOverlap,
); shiftStartDateOutOfRange,
shiftTimeUnitWidth,
shiftStartsAt,
timeframeItem,
presetType,
),
width: weekDisplayShiftWidth(
shiftUnitIsHour,
totalShiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
),
};
}, },
rotationAssigneeStyle() { rotationAssigneeStyle() {
const startDate = this.shiftStartsAt.getDay(); const { left, width } = this.shiftStyles;
const firstDayOfWeek = this.timeframeItem.getDay();
const isFirstCell = startDate === firstDayOfWeek;
let left = 0;
if (!(isFirstCell || this.shiftStartDateOutOfRange)) {
left =
(DAYS_IN_WEEK - this.daysUntilEndOfTimeFrame) * this.shiftTimeUnitWidth + ASSIGNEE_SPACER;
}
const width = this.shiftTimeUnitWidth * this.shiftWidth;
return { return {
left: `${left}px`, left: `${left}px`,
width: `${width}px`, width: `${width}px`,
...@@ -75,62 +87,38 @@ export default { ...@@ -75,62 +87,38 @@ export default {
shiftStartDateOutOfRange() { shiftStartDateOutOfRange() {
return this.shiftStartsAt.getTime() < this.timeframeItem.getTime(); return this.shiftStartsAt.getTime() < this.timeframeItem.getTime();
}, },
shiftShouldRender() { shiftUnitIsHour() {
if (this.timeFrameIndex !== 0) { return (
// TDOD: Handle edge case where this.shiftRangeOverlap.overlapStartDate is the same as this.timeframeItem this.totalShiftRangeOverlap.hoursOverlap <= HOURS_IN_DAY &&
this.rotationLength?.lengthUnit === 'HOURS'
return ( );
new Date(this.shiftRangeOverlap.overlapStartDate) > this.timeframeItem &&
new Date(this.shiftRangeOverlap.overlapStartDate) < this.currentTimeframeEndsAt
);
}
return Boolean(this.shiftRangeOverlap.daysOverlap);
}, },
shiftRangeOverlap() { timeframeEndsAt() {
return this.timeframe[this.timeframe.length - 1];
},
totalShiftRangeOverlap() {
try { try {
return getOverlapDateInPeriods( return getOverlapDateInPeriods(
{ start: this.timeframeItem, end: this.currentTimeframeEndsAt }, {
start: this.timeframeItem,
end: this.currentTimeFrameEnd,
},
{ start: this.shiftStartsAt, end: this.shiftEndsAt }, { start: this.shiftStartsAt, end: this.shiftEndsAt },
); );
} catch (error) { } catch (error) {
return { daysOverlap: 0 }; return { hoursOverlap: 0 };
} }
}, },
shiftWidth() {
const offset = this.shiftStartDateOutOfRange ? 0 : 1;
const baseWidth =
this.timeFrameIndex === 0
? this.totalShiftRangeOverlap.daysOverlap
: this.shiftRangeOverlap.daysOverlap + offset;
return baseWidth;
},
timeFrameIndex() {
return this.timeframe.indexOf(this.timeframeItem);
},
timeFrameEndsAt() {
return this.timeframe[this.timeframe.length - 1];
},
totalShiftRangeOverlap() {
return getOverlapDateInPeriods(
{
start: this.timeframeItem,
end: nDaysAfter(this.timeFrameEndsAt, DAYS_IN_DATE_WEEK),
},
{ start: this.shiftStartsAt, end: this.shiftEndsAt },
);
},
}, },
}; };
</script> </script>
<template> <template>
<rotation-assignee <rotation-assignee
v-if="shiftShouldRender"
:assignee="shift.participant" :assignee="shift.participant"
:rotation-assignee-style="rotationAssigneeStyle" :rotation-assignee-style="rotationAssigneeStyle"
:rotation-assignee-starts-at="shift.startsAt" :rotation-assignee-starts-at="shift.startsAt"
:rotation-assignee-ends-at="shift.endsAt" :rotation-assignee-ends-at="shift.endsAt"
:shift-width="shiftStyles.width"
/> />
</template> </template>
...@@ -39,9 +39,6 @@ export const addRotationModalId = 'addRotationModal'; ...@@ -39,9 +39,6 @@ export const addRotationModalId = 'addRotationModal';
export const editRotationModalId = 'editRotationModal'; export const editRotationModalId = 'editRotationModal';
export const deleteRotationModalId = 'deleteRotationModal'; export const deleteRotationModalId = 'deleteRotationModal';
/**
* Used as a JavaScript week is represented as 0 - 6
*/
export const DAYS_IN_DATE_WEEK = 6;
export const ASSIGNEE_SPACER = 2; export const ASSIGNEE_SPACER = 2;
export const TIMELINE_CELL_WIDTH = 180; export const TIMELINE_CELL_WIDTH = 180;
export const SHIFT_WIDTH_CALCULATION_DELAY = 250;
import { GlToken, GlAvatarLabeled, GlPopover } from '@gitlab/ui'; import { GlToken, GlAvatar, GlPopover } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue'; import RotationAssignee, {
SHIFT_WIDTHS,
} from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import { truncate } from '~/lib/utils/text_utility';
import mockRotations from '../../mocks/mock_rotation.json'; import mockRotations from '../../mocks/mock_rotation.json';
describe('RotationAssignee', () => { describe('RotationAssignee', () => {
let wrapper; let wrapper;
const shiftWidth = 100;
const assignee = mockRotations[0].shifts.nodes[0]; const assignee = mockRotations[0].shifts.nodes[0];
const findToken = () => wrapper.findComponent(GlToken); const findToken = () => wrapper.findComponent(GlToken);
const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); const findAvatar = () => wrapper.findComponent(GlAvatar);
const findPopOver = () => wrapper.findComponent(GlPopover); const findPopOver = () => wrapper.findComponent(GlPopover);
const findStartsAt = () => wrapper.findByTestId('rotation-assignee-starts-at'); const findStartsAt = () => wrapper.findByTestId('rotation-assignee-starts-at');
const findEndsAt = () => wrapper.findByTestId('rotation-assignee-ends-at'); const findEndsAt = () => wrapper.findByTestId('rotation-assignee-ends-at');
const findName = () => wrapper.findByTestId('rotation-assignee-name');
const formattedDate = (date) => { const formattedDate = (date) => {
return formatDate(date, 'mmmm d, yyyy, h:MMtt Z'); return formatDate(date, 'mmmm d, yyyy, h:MMtt Z');
}; };
function createComponent() { function createComponent({ props = {} } = {}) {
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(RotationAssignee, { shallowMount(RotationAssignee, {
propsData: { propsData: {
assignee: assignee.participant, assignee: { avatarUrl: '/url', ...assignee.participant },
rotationAssigneeStartsAt: assignee.startsAt, rotationAssigneeStartsAt: assignee.startsAt,
rotationAssigneeEndsAt: assignee.endsAt, rotationAssigneeEndsAt: assignee.endsAt,
rotationAssigneeStyle: { left: '0px', width: '100px' }, rotationAssigneeStyle: { left: '0px', width: `${shiftWidth}px` },
shiftWidth,
...props,
}, },
}), }),
); );
...@@ -41,8 +48,19 @@ describe('RotationAssignee', () => { ...@@ -41,8 +48,19 @@ describe('RotationAssignee', () => {
}); });
describe('rotation assignee token', () => { describe('rotation assignee token', () => {
it('should render an assignee name', () => { it('should render an assignee name and avatar', () => {
expect(findAvatar().attributes('label')).toBe(assignee.participant.user.username); expect(findAvatar().props('src')).toBe(wrapper.vm.assignee.avatarUrl);
expect(findName().text()).toBe(assignee.participant.user.username);
});
it('truncate the rotation name on small screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.sm } });
expect(findName().text()).toBe(truncate(assignee.participant.user.username, 3));
});
it('hide the rotation name on mobile screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.xs } });
expect(findName().exists()).toBe(false);
}); });
it('should render an assignee color based on the chevron skipping color pallette', () => { it('should render an assignee color based on the chevron skipping color pallette', () => {
......
...@@ -23,8 +23,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -23,8 +23,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
> >
<button <button
aria-label="Edit rotation" aria-label="Edit rotation"
class="btn btn-default btn-md disabled gl-button btn-default-tertiary btn-icon" class="btn gl-display-none btn-default btn-md gl-button btn-default-tertiary btn-icon"
disabled="disabled"
title="Edit rotation" title="Edit rotation"
type="button" type="button"
> >
...@@ -78,8 +77,8 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -78,8 +77,8 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div> <div>
<div <div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden" class="gl-absolute gl-h-7 gl-mt-3"
style="left: 0px; width: 0px;" style="left: 0px; width: -2px;"
> >
<span <span
class="gl-w-full gl-h-6 gl-align-items-center gl-token gl-token-default-variant gl-bg-data-viz-blue-500" class="gl-w-full gl-h-6 gl-align-items-center gl-token gl-token-default-variant gl-bg-data-viz-blue-500"
...@@ -89,40 +88,17 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -89,40 +88,17 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
class="gl-token-content" class="gl-token-content"
> >
<div <div
class="gl-avatar-labeled" class="gl-display-flex gl-text-white gl-font-weight-normal"
shape="circle"
size="16"
title="nora.schaden"
> >
<div <div
class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1" class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1"
title="nora.schaden"
> >
</div> </div>
<div <!---->
class="gl-avatar-labeled-labels gl-text-left!"
>
<div
class="gl-display-flex gl-flex-wrap gl-align-items-center gl-text-left! gl-mx-n1 gl-my-n1"
>
<span
class="gl-avatar-labeled-label"
>
nora.schaden
</span>
</div>
<span
class="gl-avatar-labeled-sublabel"
>
</span>
</div>
</div> </div>
<!----> <!---->
...@@ -149,51 +125,28 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -149,51 +125,28 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
</div> </div>
</div> </div>
<div <div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden" class="gl-absolute gl-h-7 gl-mt-3"
style="left: 2px; width: 0px;" style="left: 0px; width: -2px;"
> >
<span <span
class="gl-w-full gl-h-6 gl-align-items-center gl-token gl-token-default-variant gl-bg-data-viz-orange-500" class="gl-w-full gl-h-6 gl-align-items-center gl-token gl-token-default-variant gl-bg-data-viz-orange-500"
id="2-18" id="2-17"
> >
<span <span
class="gl-token-content" class="gl-token-content"
> >
<div <div
class="gl-avatar-labeled" class="gl-display-flex gl-text-white gl-font-weight-normal"
shape="circle"
size="16"
title="racheal.loving"
> >
<div <div
class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1" class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1"
title="racheal.loving"
> >
</div> </div>
<div <!---->
class="gl-avatar-labeled-labels gl-text-left!"
>
<div
class="gl-display-flex gl-flex-wrap gl-align-items-center gl-text-left! gl-mx-n1 gl-my-n1"
>
<span
class="gl-avatar-labeled-label"
>
racheal.loving
</span>
</div>
<span
class="gl-avatar-labeled-sublabel"
>
</span>
</div>
</div> </div>
<!----> <!---->
......
...@@ -59,14 +59,14 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d ...@@ -59,14 +59,14 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d
createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } }); createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '0px', left: '0px',
width: '250px', width: '248px',
}); });
}); });
it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => { it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => {
/** /**
* Where left should be 502px i.e. ((HOURS_IN_DAY - (HOURS_IN_DAY - overlapStartTime)) * CELL_WIDTH) + ASSIGNEE_SPACER(((24 - (24 - 9)) * 50)) + 2 * Where left should be 500px i.e. ((HOURS_IN_DAY - (HOURS_IN_DAY - overlapStartTime)) * CELL_WIDTH)(((24 - (24 - 10)) * 50))
* and width should be overlapping hours * CELL_WIDTH(12 * 50 + 50) * and width should be overlapping hours * CELL_WIDTH(12 * 50 - 2)
*/ */
createComponent({ createComponent({
props: { props: {
...@@ -79,30 +79,9 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d ...@@ -79,30 +79,9 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d
data: { shiftTimeUnitWidth: CELL_WIDTH }, data: { shiftTimeUnitWidth: CELL_WIDTH },
}); });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '452px', left: '500px',
width: '650px', width: '598px',
}); });
}); });
}); });
describe('shift does not overlap inside the current time-frame or contains an invalid date', () => {
it.each`
reason | setTimeframeItem | startsAt | endsAt
${'timeframe is an invalid date'} | ${new Date(NaN)} | ${shift.startsAt} | ${shift.endsAt}
${'shift start date is an invalid date'} | ${timeframeItem} | ${'Invalid date string'} | ${shift.endsAt}
${'shift end date is an invalid date'} | ${timeframeItem} | ${shift.startsAt} | ${'Invalid date string'}
${'shift is not inside the timeframe'} | ${timeframeItem} | ${'2021-03-12T10:00:00.000Z'} | ${'2021-03-16T10:00:00.000Z'}
${'timeframe does not represent the shift times'} | ${new Date(2021, 3, 21)} | ${shift.startsAt} | ${shift.endsAt}
`(`should not render a rotation item when $reason`, (data) => {
const { setTimeframeItem, startsAt, endsAt } = data;
createComponent({
props: {
timeframeItem: setTimeframeItem,
shift: { ...shift, startsAt, endsAt },
},
});
expect(findRotationAssignee().exists()).toBe(false);
});
});
}); });
...@@ -8,6 +8,7 @@ import mockRotations from '../../../../mocks/mock_rotation.json'; ...@@ -8,6 +8,7 @@ import mockRotations from '../../../../mocks/mock_rotation.json';
const timeframeItem = new Date(2021, 0, 13); const timeframeItem = new Date(2021, 0, 13);
const timeframe = [timeframeItem, nDaysAfter(timeframeItem, DAYS_IN_WEEK)]; const timeframe = [timeframeItem, nDaysAfter(timeframeItem, DAYS_IN_WEEK)];
const shift = mockRotations[0].shifts.nodes[0];
describe('ee/oncall_schedules/components/schedule/components/shifts/components/schedule_shift_wrapper.vue', () => { describe('ee/oncall_schedules/components/schedule/components/shifts/components/schedule_shift_wrapper.vue', () => {
let wrapper; let wrapper;
...@@ -46,11 +47,39 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/s ...@@ -46,11 +47,39 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/s
const findDaysScheduleShifts = () => wrapper.findAllComponents(DaysScheduleShift); const findDaysScheduleShifts = () => wrapper.findAllComponents(DaysScheduleShift);
const findWeeksScheduleShifts = () => wrapper.findAllComponents(WeeksScheduleShift); const findWeeksScheduleShifts = () => wrapper.findAllComponents(WeeksScheduleShift);
const updateShifts = (startsAt, endsAt) =>
mockRotations[0].shifts.nodes.map((el) => ({ ...el, startsAt, endsAt }));
describe('when the preset type is WEEKS', () => { describe('when the preset type is WEEKS', () => {
it('should render a selection of week grid shifts inside the rotation', () => { it('should render a selection of week grid shifts inside the rotation', () => {
expect(findWeeksScheduleShifts()).toHaveLength(2); expect(findWeeksScheduleShifts()).toHaveLength(2);
}); });
it.each`
reason | setTimeframeItem | startsAt | endsAt
${'timeframe is an invalid date'} | ${new Date(NaN)} | ${shift.startsAt} | ${shift.endsAt}
${'shift start date is an invalid date'} | ${timeframeItem} | ${'Invalid date string'} | ${shift.endsAt}
${'shift end date is an invalid date'} | ${timeframeItem} | ${shift.startsAt} | ${'Invalid date string'}
${'shift is not inside the timeframe'} | ${timeframeItem} | ${'2021-03-12T10:00:00.000Z'} | ${'2021-03-16T10:00:00.000Z'}
${'timeframe does not represent the shift times'} | ${new Date(2021, 3, 21)} | ${shift.startsAt} | ${shift.endsAt}
`(`should not render a rotation item when $reason`, (data) => {
const { setTimeframeItem, startsAt, endsAt } = data;
const shifts = updateShifts(startsAt, endsAt);
createComponent({
props: {
presetType: PRESET_TYPES.WEEKS,
timeframeItem: setTimeframeItem,
rotation: {
...mockRotations[0],
shifts: {
...shifts,
},
},
},
});
expect(findWeeksScheduleShifts().exists()).toBe(false);
});
}); });
describe('when the preset type is DAYS', () => { describe('when the preset type is DAYS', () => {
...@@ -58,5 +87,31 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/s ...@@ -58,5 +87,31 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/s
createComponent({ props: { presetType: PRESET_TYPES.DAYS } }); createComponent({ props: { presetType: PRESET_TYPES.DAYS } });
expect(findDaysScheduleShifts()).toHaveLength(1); expect(findDaysScheduleShifts()).toHaveLength(1);
}); });
it.each`
reason | setTimeframeItem | startsAt | endsAt
${'timeframe is an invalid date'} | ${new Date(NaN)} | ${shift.startsAt} | ${shift.endsAt}
${'shift start date is an invalid date'} | ${timeframeItem} | ${'Invalid date string'} | ${shift.endsAt}
${'shift end date is an invalid date'} | ${timeframeItem} | ${shift.startsAt} | ${'Invalid date string'}
${'shift is not inside the timeframe'} | ${timeframeItem} | ${'2021-03-12T10:00:00.000Z'} | ${'2021-03-16T10:00:00.000Z'}
${'timeframe does not represent the shift times'} | ${new Date(2021, 3, 21)} | ${shift.startsAt} | ${shift.endsAt}
`(`should not render a rotation item when $reason`, (data) => {
const { setTimeframeItem, startsAt, endsAt } = data;
const shifts = updateShifts(startsAt, endsAt);
createComponent({
props: {
presetType: PRESET_TYPES.DAYS,
timeframeItem: setTimeframeItem,
rotation: {
...mockRotations[0],
shifts: {
...shifts,
},
},
},
});
expect(findDaysScheduleShifts().exists()).toBe(false);
});
}); });
}); });
...@@ -31,6 +31,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w ...@@ -31,6 +31,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
timeframe, timeframe,
presetType: PRESET_TYPES.WEEKS, presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH, shiftTimeUnitWidth: CELL_WIDTH,
rotationLength: { lengthUnit: 'DAYS' },
...props, ...props,
}, },
}); });
...@@ -46,7 +47,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w ...@@ -46,7 +47,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
const findRotationAssignee = () => wrapper.findComponent(RotationsAssignee); const findRotationAssignee = () => wrapper.findComponent(RotationsAssignee);
describe('shift overlaps inside the current time-frame', () => { describe('shift overlaps inside the current time-frame with a shift greater than 24 hours', () => {
it('should render a rotation assignee child component', () => { it('should render a rotation assignee child component', () => {
expect(findRotationAssignee().exists()).toBe(true); expect(findRotationAssignee().exists()).toBe(true);
}); });
...@@ -54,50 +55,89 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w ...@@ -54,50 +55,89 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
it('calculates the correct rotation assignee styles when the shift starts at the beginning of the time-frame cell', () => { it('calculates the correct rotation assignee styles when the shift starts at the beginning of the time-frame cell', () => {
/** /**
* Where left should be 0px i.e. beginning of time-frame cell * Where left should be 0px i.e. beginning of time-frame cell
* and width should be overlapping days * CELL_WIDTH(3 * 50) * and width should be overlapping days * CELL_WIDTH - ASSIGNEE_SPACER((3 * 50) - 2)
*/ */
createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } }); createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '0px', left: '0px',
width: '150px', width: '98px',
}); });
}); });
it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => { it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => {
/** /**
* Where left should be 52px i.e. ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH) + ASSIGNEE_SPACER(((7 - (20 - 14)) * 50)) + 2 * Where left should be 52x i.e. ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH)(((7 - (20 - 14)) * 50))
* and width should be overlapping days * (CELL_WIDTH + offset)(1 * (50 + 50)) * and width should be overlapping (days * CELL_WIDTH) - ASSIGNEE_SPACER((4 * 50) - 2)
* where offset is either CELL_WIDTH * 0 or CELL_WIDTH * 1 depending on the index of the timeframe
*/ */
createComponent({
props: {
shift: {
...shift,
startsAt: '2021-01-14T10:04:56.333Z',
endsAt: '2021-01-18T10:04:56.333Z',
},
},
data: { shiftTimeUnitWidth: CELL_WIDTH },
});
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '50px',
width: '198px',
});
});
});
describe('shift overlaps inside the current time-frame with a shift equal to 24 hours', () => {
beforeEach(() => {
createComponent({ createComponent({
props: { shift: { ...shift, startsAt: '2021-01-14T10:04:56.333Z' } }, props: { shift: { ...shift, startsAt: '2021-01-14T10:04:56.333Z' } },
data: { shiftTimeUnitWidth: CELL_WIDTH }, data: { shiftTimeUnitWidth: CELL_WIDTH },
}); });
});
it('should render a rotation assignee child component', () => {
expect(findRotationAssignee().exists()).toBe(true);
});
it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => {
/**
* Where left should be ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH)(((7 - (20 - 14)) * 50))
* and width should be (overlappingDays * CELL_WIDTH) - ASSIGNEE_SPACER((1 * 50) - 2)
*/
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '52px', left: '50px',
width: '50px', width: '48px',
}); });
}); });
}); });
describe('shift does not overlap inside the current time-frame or contains an invalid date', () => { describe('shift overlaps inside the current time-frame with a shift less than 24 hours', () => {
it.each` beforeEach(() => {
reason | expectedTimeframeItem | startsAt | endsAt
${'timeframe is an invalid date'} | ${new Date(NaN)} | ${shift.startsAt} | ${shift.endsAt}
${'shift start date is an invalid date'} | ${timeframeItem} | ${'Invalid date string'} | ${shift.endsAt}
${'shift end date is an invalid date'} | ${timeframeItem} | ${shift.startsAt} | ${'Invalid date string'}
${'shift is not inside the timeframe'} | ${timeframeItem} | ${'2021-03-12T10:00:00.000Z'} | ${'2021-03-16T10:00:00.000Z'}
${'timeframe does not represent the shift times'} | ${new Date(2021, 3, 21)} | ${shift.startsAt} | ${shift.endsAt}
`(`should not render a rotation item when $reason`, (data) => {
const { expectedTimeframeItem, startsAt, endsAt } = data;
createComponent({ createComponent({
props: { props: {
timeframeItem: expectedTimeframeItem, shift: {
shift: { ...shift, startsAt, endsAt }, ...shift,
startsAt: '2021-01-14T10:04:56.333Z',
endsAt: '2021-01-14T12:04:56.333Z',
},
rotationLength: { lengthUnit: 'HOURS' },
}, },
data: { shiftTimeUnitWidth: CELL_WIDTH },
}); });
});
expect(findRotationAssignee().exists()).toBe(false); it('should render a rotation assignee child component', () => {
expect(findRotationAssignee().exists()).toBe(true);
});
it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => {
/**
* Where left should be 70px i.e. ((CELL_WIDTH / HOURS_IN_DAY) * overlapStartDate + dayOffSet)(50 / 24 * 10) + 50;
* and width should be 2px ((CELL_WIDTH / HOURS_IN_DAY) * hoursOverlap - ASSIGNEE_SPACER) (((50 / 24) * 2) - 2)
*/
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '70px',
width: '2px',
});
}); });
}); });
}); });
...@@ -20733,6 +20733,12 @@ msgstr "" ...@@ -20733,6 +20733,12 @@ msgstr ""
msgid "On-call schedules" msgid "On-call schedules"
msgstr "" msgstr ""
msgid "OnCallSchedules|1 day"
msgstr ""
msgid "OnCallSchedules|2 weeks"
msgstr ""
msgid "OnCallSchedules|Add a rotation" msgid "OnCallSchedules|Add a rotation"
msgstr "" msgstr ""
...@@ -20790,6 +20796,9 @@ msgstr "" ...@@ -20790,6 +20796,9 @@ msgstr ""
msgid "OnCallSchedules|On-call schedule for the %{timezone}" msgid "OnCallSchedules|On-call schedule for the %{timezone}"
msgstr "" msgstr ""
msgid "OnCallSchedules|Please note, rotations with shifts that are less than four hours are currently not supported in the weekly view."
msgstr ""
msgid "OnCallSchedules|Restrict to time intervals" msgid "OnCallSchedules|Restrict to time intervals"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment