Commit 2749deac authored by David O'Regan's avatar David O'Regan

Refactor(oncallschedules): weekly grid draw updates

Allow users to add rotations with
shifts that are less than 24 hours,
24 hours and introduce small
screen CSS for user names
and avatars
parent 0933b156
...@@ -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,27 +196,14 @@ export default { ...@@ -191,27 +196,14 @@ 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 }}
<gl-button-group data-testid="shift-preset-change">
<gl-button
v-for="type in $options.PRESET_TYPES"
:key="type"
:selected="type === presetType"
:title="formatPresetType(type)"
@click="switchPresetType(type)"
>
{{ formatPresetType(type) }}
</gl-button>
</gl-button-group>
</p> </p>
<div class="gl-w-full gl-display-flex gl-align-items-center gl-pb-3"> <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-group>
<gl-button <gl-button
data-testid="previous-timeframe-btn" data-testid="previous-timeframe-btn"
...@@ -226,7 +218,19 @@ export default { ...@@ -226,7 +218,19 @@ export default {
@click="updateToViewNextTimeframe" @click="updateToViewNextTimeframe"
/> />
</gl-button-group> </gl-button-group>
<p class="gl-ml-3 gl-mb-0">{{ scheduleRange }}</p> <div class="gl-ml-3">{{ scheduleRange }}</div>
</div>
<gl-button-group data-testid="shift-preset-change">
<gl-button
v-for="type in $options.PRESET_TYPES"
:key="type"
:selected="type === presetType"
:title="formatPresetType(type)"
@click="switchPresetType(type)"
>
{{ $options.i18n.presetTypeLabels[type] }}
</gl-button>
</gl-button-group>
</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);
},
}, },
methods: { timeframeIndex() {
shiftRangeOverlap(shiftStartsAt, shiftEndsAt) { return this.timeframe.indexOf(this.timeframeItem);
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: {
currentTimeframeEndsAt() {
return nDaysAfter(this.timeframeItem, DAYS_IN_DATE_WEEK);
}, },
daysUntilEndOfTimeFrame() { computed: {
if (this.currentTimeframeEndsAt.getMonth() !== this.timeframeItem.getMonth()) { currentTimeFrameEnd() {
// TODO: Handle Edge case where timeframe spans two different months return nDaysAfter(this.timeframeEndsAt, DAYS_IN_WEEK);
} },
shiftStyles() {
const {
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,51 +87,27 @@ export default { ...@@ -75,51 +87,27 @@ export default {
shiftStartDateOutOfRange() { shiftStartDateOutOfRange() {
return this.shiftStartsAt.getTime() < this.timeframeItem.getTime(); return this.shiftStartsAt.getTime() < this.timeframeItem.getTime();
}, },
shiftShouldRender() { shiftUnitIsHour() {
if (this.timeFrameIndex !== 0) {
// TDOD: Handle edge case where this.shiftRangeOverlap.overlapStartDate is the same as this.timeframeItem
return ( return (
new Date(this.shiftRangeOverlap.overlapStartDate) > this.timeframeItem && this.totalShiftRangeOverlap.hoursOverlap <= HOURS_IN_DAY &&
new Date(this.shiftRangeOverlap.overlapStartDate) < this.currentTimeframeEndsAt this.rotationLength?.lengthUnit === 'HOURS'
); );
}
return Boolean(this.shiftRangeOverlap.daysOverlap);
},
shiftRangeOverlap() {
try {
return getOverlapDateInPeriods(
{ start: this.timeframeItem, end: this.currentTimeframeEndsAt },
{ start: this.shiftStartsAt, end: this.shiftEndsAt },
);
} catch (error) {
return { daysOverlap: 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() { timeframeEndsAt() {
return this.timeframe[this.timeframe.length - 1]; return this.timeframe[this.timeframe.length - 1];
}, },
totalShiftRangeOverlap() { totalShiftRangeOverlap() {
try {
return getOverlapDateInPeriods( return getOverlapDateInPeriods(
{ {
start: this.timeframeItem, start: this.timeframeItem,
end: nDaysAfter(this.timeFrameEndsAt, DAYS_IN_DATE_WEEK), end: this.currentTimeFrameEnd,
}, },
{ start: this.shiftStartsAt, end: this.shiftEndsAt }, { start: this.shiftStartsAt, end: this.shiftEndsAt },
); );
} catch (error) {
return { hoursOverlap: 0 };
}
}, },
}, },
}; };
...@@ -127,10 +115,10 @@ export default { ...@@ -127,10 +115,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="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',
});
}); });
}); });
}); });
...@@ -20739,6 +20739,12 @@ msgstr "" ...@@ -20739,6 +20739,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 ""
...@@ -20796,6 +20802,9 @@ msgstr "" ...@@ -20796,6 +20802,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