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 @@
&.gl-modal .modal-md {
max-width: 640px;
}
.dropdown-menu {
max-height: $dropdown-max-height;
@include gl-overflow-y-auto;
}
}
//// 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
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 = {
deleteScheduleLabel: s__('OnCallSchedules|Delete schedule'),
rotationTitle: s__('OnCallSchedules|Rotations'),
addARotation: s__('OnCallSchedules|Add a rotation'),
presetTypeLabels: {
DAYS: s__('OnCallSchedules|1 day'),
WEEKS: s__('OnCallSchedules|2 weeks'),
},
};
export const editScheduleModalId = 'editScheduleModal';
export const deleteScheduleModalId = 'deleteScheduleModal';
......@@ -69,6 +73,7 @@ export default {
rotations: {
query: getShiftsForRotations,
variables() {
this.timeframeStartDate.setHours(0, 0, 0, 0);
const startsAt = this.timeframeStartDate;
const endsAt = nWeeksAfter(startsAt, 2);
......@@ -191,14 +196,30 @@ export default {
</gl-button-group>
</div>
</template>
<p
class="gl-text-gray-500 gl-mb-3 gl-display-flex gl-justify-content-space-between gl-align-items-center"
data-testid="scheduleBody"
>
<p class="gl-text-gray-500 gl-mb-3" data-testid="scheduleBody">
<gl-sprintf :message="$options.i18n.scheduleForTz">
<template #timezone>{{ schedule.timezone }}</template>
</gl-sprintf>
| {{ 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
v-for="type in $options.PRESET_TYPES"
......@@ -207,26 +228,9 @@ export default {
:title="formatPresetType(type)"
@click="switchPresetType(type)"
>
{{ formatPresetType(type) }}
{{ $options.i18n.presetTypeLabels[type] }}
</gl-button>
</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>
<gl-card header-class="gl-bg-transparent">
......
......@@ -30,7 +30,12 @@ export const i18n = {
title: __('Participants'),
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: {
title: __('Starts on'),
error: s__('OnCallSchedules|Rotation start date cannot be empty'),
......@@ -153,6 +158,7 @@ export default {
<gl-form-group
:label="$options.i18n.fields.rotationLength.title"
:description="$options.i18n.fields.rotationLength.description"
label-size="sm"
label-for="rotation-length"
>
......
......@@ -71,7 +71,7 @@ export default {
participants: [],
rotationLength: {
length: 1,
unit: this.$options.LENGTH_ENUM.hours,
unit: this.$options.LENGTH_ENUM.days,
},
startsAt: {
date: null,
......
<script>
import { GlToken, GlAvatarLabeled, GlPopover } from '@gitlab/ui';
import { GlToken, GlAvatar, GlPopover } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { truncate } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
export const SHIFT_WIDTHS = {
md: 140,
sm: 90,
xs: 40,
};
export default {
components: {
GlAvatarLabeled,
GlAvatar,
GlPopover,
GlToken,
},
......@@ -26,6 +33,10 @@ export default {
type: Object,
required: true,
},
shiftWidth: {
type: Number,
required: true,
},
},
computed: {
chevronClass() {
......@@ -45,34 +56,40 @@ export default {
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>
<template>
<div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden"
:style="rotationAssigneeStyle"
>
<div class="gl-absolute gl-h-7 gl-mt-3" :style="rotationAssigneeStyle">
<gl-token
:id="rotationAssigneeUniqueID"
class="gl-w-full gl-h-6 gl-align-items-center"
:class="chevronClass"
:view-only="true"
>
<gl-avatar-labeled
shape="circle"
:size="16"
:src="assignee.avatarUrl"
:label="assignee.user.username"
:title="assignee.user.username"
/>
<div class="gl-display-flex gl-text-white gl-font-weight-normal">
<gl-avatar :src="assignee.avatarUrl" :size="16" />
<span v-if="!rotationMobileView" class="gl-ml-2" data-testid="rotation-assignee-name">{{
assigneeName
}}</span>
</div>
</gl-token>
<gl-popover
:target="rotationAssigneeUniqueID"
:title="assignee.user.username"
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-ends-at">{{ endsAt }}</p>
......
......@@ -29,7 +29,7 @@ export default {
<template>
<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 }}
</div>
<days-header-sub-item :timeframe-item="timeframeItem" />
......
......@@ -126,14 +126,15 @@ export default {
>
<span class="gl-text-truncated">{{ rotation.name }}</span>
<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
v-gl-modal="$options.editRotationModalId"
v-gl-tooltip
class="gl-display-none"
category="tertiary"
:title="$options.i18n.editRotationLabel"
icon="pencil"
:aria-label="$options.i18n.editRotationLabel"
:disabled="true"
/>
<gl-button
v-gl-modal="$options.deleteRotationModalId"
......
<script>
import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
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 {
components: {
......@@ -35,36 +36,28 @@ export default {
},
computed: {
currentTimeframeEndsAt() {
return nDaysAfter(this.timeframeItem, 1);
return currentTimeframeEndsAt(this.timeframeItem, this.presetType);
},
hoursUntilEndOfTimeFrame() {
return HOURS_IN_DAY - new Date(this.shiftRangeOverlap.overlapStartDate).getHours();
},
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 {
left,
width: `${this.shiftTimeUnitWidth * width}px`,
left: `${this.shiftLeft}px`,
width: `${this.shiftWidth}px`,
};
},
shiftStartsAt() {
return new Date(this.shift.startsAt);
},
shiftEndsAt() {
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() {
try {
return getOverlapDateInPeriods(
......@@ -75,14 +68,18 @@ export default {
return { hoursOverlap: 0 };
}
},
shiftStartsAt() {
return new Date(this.shift.startsAt);
},
shiftStartHourOutOfRange() {
return this.shiftStartsAt.getTime() < this.timeframeItem.getTime();
},
shiftShouldRender() {
return Boolean(
this.shiftRangeOverlap.hoursOverlap &&
!(this.shiftStartsAt.getDate() > this.timeframeItem.getDate()),
);
shiftWidth() {
const baseWidth =
this.shiftEndsAt.getTime() >= this.currentTimeframeEndsAt.getTime()
? HOURS_IN_DAY
: this.shiftRangeOverlap.hoursOverlap;
return this.shiftTimeUnitWidth * baseWidth - ASSIGNEE_SPACER;
},
},
};
......@@ -90,10 +87,10 @@ export default {
<template>
<rotation-assignee
v-if="shiftShouldRender"
:assignee="shift.participant"
:rotation-assignee-style="rotationAssigneeStyle"
:rotation-assignee-starts-at="shift.startsAt"
:rotation-assignee-ends-at="shift.endsAt"
:shift-width="shiftWidth"
/>
</template>
<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 { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility';
import DaysScheduleShift from './days_schedule_shift.vue';
import { shiftsToRender } from './shift_utils';
import WeeksScheduleShift from './weeks_schedule_shift.vue';
export default {
......@@ -40,31 +40,26 @@ export default {
apollo: {
shiftTimeUnitWidth: {
query: getShiftTimeUnitWidthQuery,
debounce: SHIFT_WIDTH_CALCULATION_DELAY,
},
},
computed: {
currentTimeframeEndsAt() {
return new Date(
nDaysAfter(
this.timeframeItem,
this.presetType === PRESET_TYPES.DAYS ? 1 : DAYS_IN_DATE_WEEK,
),
);
rotationLength() {
const { length, lengthUnit } = this.rotation;
return { length, lengthUnit };
},
shiftsToRender() {
const validShifts = this.rotation.shifts.nodes.filter(
({ startsAt, endsAt }) => this.shiftRangeOverlap(startsAt, endsAt).hoursOverlap > 0,
return Object.freeze(
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: {
shiftRangeOverlap(shiftStartsAt, shiftEndsAt) {
return getOverlapDateInPeriods(
{ start: this.timeframeItem, end: this.currentTimeframeEndsAt },
{ start: shiftStartsAt, end: shiftEndsAt },
);
timeframeIndex() {
return this.timeframe.indexOf(this.timeframeItem);
},
},
};
......@@ -82,6 +77,7 @@ export default {
:timeframe-item="timeframeItem"
:timeframe="timeframe"
:shift-time-unit-width="shiftTimeUnitWidth"
:rotation-length="rotationLength"
/>
</div>
</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>
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 { weekDisplayShiftLeft, weekDisplayShiftWidth } from './shift_utils';
export default {
components: {
......@@ -32,35 +33,46 @@ export default {
type: Number,
required: true,
},
rotationLength: {
type: Object,
required: true,
},
},
computed: {
currentTimeframeEndsAt() {
return nDaysAfter(this.timeframeItem, DAYS_IN_DATE_WEEK);
},
daysUntilEndOfTimeFrame() {
if (this.currentTimeframeEndsAt.getMonth() !== this.timeframeItem.getMonth()) {
// TODO: Handle Edge case where timeframe spans two different months
}
currentTimeFrameEnd() {
return nDaysAfter(this.timeframeEndsAt, DAYS_IN_WEEK);
},
shiftStyles() {
const {
shiftUnitIsHour,
totalShiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
shiftStartsAt,
timeframeItem,
presetType,
} = this;
return (
this.currentTimeframeEndsAt.getDate() -
new Date(this.shiftRangeOverlap.overlapStartDate).getDate() +
1
);
return {
left: weekDisplayShiftLeft(
shiftUnitIsHour,
totalShiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
shiftStartsAt,
timeframeItem,
presetType,
),
width: weekDisplayShiftWidth(
shiftUnitIsHour,
totalShiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
),
};
},
rotationAssigneeStyle() {
const startDate = this.shiftStartsAt.getDay();
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;
const { left, width } = this.shiftStyles;
return {
left: `${left}px`,
width: `${width}px`,
......@@ -75,62 +87,38 @@ export default {
shiftStartDateOutOfRange() {
return this.shiftStartsAt.getTime() < this.timeframeItem.getTime();
},
shiftShouldRender() {
if (this.timeFrameIndex !== 0) {
// TDOD: Handle edge case where this.shiftRangeOverlap.overlapStartDate is the same as this.timeframeItem
return (
new Date(this.shiftRangeOverlap.overlapStartDate) > this.timeframeItem &&
new Date(this.shiftRangeOverlap.overlapStartDate) < this.currentTimeframeEndsAt
);
}
return Boolean(this.shiftRangeOverlap.daysOverlap);
shiftUnitIsHour() {
return (
this.totalShiftRangeOverlap.hoursOverlap <= HOURS_IN_DAY &&
this.rotationLength?.lengthUnit === 'HOURS'
);
},
shiftRangeOverlap() {
timeframeEndsAt() {
return this.timeframe[this.timeframe.length - 1];
},
totalShiftRangeOverlap() {
try {
return getOverlapDateInPeriods(
{ start: this.timeframeItem, end: this.currentTimeframeEndsAt },
{
start: this.timeframeItem,
end: this.currentTimeFrameEnd,
},
{ start: this.shiftStartsAt, end: this.shiftEndsAt },
);
} 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>
<template>
<rotation-assignee
v-if="shiftShouldRender"
:assignee="shift.participant"
:rotation-assignee-style="rotationAssigneeStyle"
:rotation-assignee-starts-at="shift.startsAt"
:rotation-assignee-ends-at="shift.endsAt"
:shift-width="shiftStyles.width"
/>
</template>
......@@ -39,9 +39,6 @@ export const addRotationModalId = 'addRotationModal';
export const editRotationModalId = 'editRotationModal';
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 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 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 { formatDate } from '~/lib/utils/datetime_utility';
import { truncate } from '~/lib/utils/text_utility';
import mockRotations from '../../mocks/mock_rotation.json';
describe('RotationAssignee', () => {
let wrapper;
const shiftWidth = 100;
const assignee = mockRotations[0].shifts.nodes[0];
const findToken = () => wrapper.findComponent(GlToken);
const findAvatar = () => wrapper.findComponent(GlAvatarLabeled);
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findPopOver = () => wrapper.findComponent(GlPopover);
const findStartsAt = () => wrapper.findByTestId('rotation-assignee-starts-at');
const findEndsAt = () => wrapper.findByTestId('rotation-assignee-ends-at');
const findName = () => wrapper.findByTestId('rotation-assignee-name');
const formattedDate = (date) => {
return formatDate(date, 'mmmm d, yyyy, h:MMtt Z');
};
function createComponent() {
function createComponent({ props = {} } = {}) {
wrapper = extendedWrapper(
shallowMount(RotationAssignee, {
propsData: {
assignee: assignee.participant,
assignee: { avatarUrl: '/url', ...assignee.participant },
rotationAssigneeStartsAt: assignee.startsAt,
rotationAssigneeEndsAt: assignee.endsAt,
rotationAssigneeStyle: { left: '0px', width: '100px' },
rotationAssigneeStyle: { left: '0px', width: `${shiftWidth}px` },
shiftWidth,
...props,
},
}),
);
......@@ -41,8 +48,19 @@ describe('RotationAssignee', () => {
});
describe('rotation assignee token', () => {
it('should render an assignee name', () => {
expect(findAvatar().attributes('label')).toBe(assignee.participant.user.username);
it('should render an assignee name and avatar', () => {
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', () => {
......
......@@ -23,8 +23,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
>
<button
aria-label="Edit rotation"
class="btn btn-default btn-md disabled gl-button btn-default-tertiary btn-icon"
disabled="disabled"
class="btn gl-display-none btn-default btn-md gl-button btn-default-tertiary btn-icon"
title="Edit rotation"
type="button"
>
......@@ -78,8 +77,8 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div>
<div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden"
style="left: 0px; width: 0px;"
class="gl-absolute gl-h-7 gl-mt-3"
style="left: 0px; width: -2px;"
>
<span
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
class="gl-token-content"
>
<div
class="gl-avatar-labeled"
shape="circle"
size="16"
title="nora.schaden"
class="gl-display-flex gl-text-white gl-font-weight-normal"
>
<div
class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1"
title="nora.schaden"
>
</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>
<!---->
......@@ -149,51 +125,28 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
</div>
</div>
<div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden"
style="left: 2px; width: 0px;"
class="gl-absolute gl-h-7 gl-mt-3"
style="left: 0px; width: -2px;"
>
<span
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
class="gl-token-content"
>
<div
class="gl-avatar-labeled"
shape="circle"
size="16"
title="racheal.loving"
class="gl-display-flex gl-text-white gl-font-weight-normal"
>
<div
class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1"
title="racheal.loving"
>
</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>
<!---->
......
......@@ -59,14 +59,14 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d
createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
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', () => {
/**
* Where left should be 502px i.e. ((HOURS_IN_DAY - (HOURS_IN_DAY - overlapStartTime)) * CELL_WIDTH) + ASSIGNEE_SPACER(((24 - (24 - 9)) * 50)) + 2
* and width should be overlapping hours * CELL_WIDTH(12 * 50 + 50)
* 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 - 2)
*/
createComponent({
props: {
......@@ -79,30 +79,9 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d
data: { shiftTimeUnitWidth: CELL_WIDTH },
});
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '452px',
width: '650px',
left: '500px',
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';
const timeframeItem = new Date(2021, 0, 13);
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', () => {
let wrapper;
......@@ -46,11 +47,39 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/s
const findDaysScheduleShifts = () => wrapper.findAllComponents(DaysScheduleShift);
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', () => {
it('should render a selection of week grid shifts inside the rotation', () => {
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', () => {
......@@ -58,5 +87,31 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/s
createComponent({ props: { presetType: PRESET_TYPES.DAYS } });
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
timeframe,
presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH,
rotationLength: { lengthUnit: 'DAYS' },
...props,
},
});
......@@ -46,7 +47,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
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', () => {
expect(findRotationAssignee().exists()).toBe(true);
});
......@@ -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', () => {
/**
* 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 } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
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', () => {
/**
* Where left should be 52px i.e. ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH) + ASSIGNEE_SPACER(((7 - (20 - 14)) * 50)) + 2
* and width should be overlapping days * (CELL_WIDTH + offset)(1 * (50 + 50))
* where offset is either CELL_WIDTH * 0 or CELL_WIDTH * 1 depending on the index of the timeframe
* 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) - ASSIGNEE_SPACER((4 * 50) - 2)
*/
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({
props: { shift: { ...shift, startsAt: '2021-01-14T10:04:56.333Z' } },
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({
left: '52px',
width: '50px',
left: '50px',
width: '48px',
});
});
});
describe('shift does not overlap inside the current time-frame or contains an invalid date', () => {
it.each`
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;
describe('shift overlaps inside the current time-frame with a shift less than 24 hours', () => {
beforeEach(() => {
createComponent({
props: {
timeframeItem: expectedTimeframeItem,
shift: { ...shift, startsAt, endsAt },
shift: {
...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 ""
msgid "On-call schedules"
msgstr ""
msgid "OnCallSchedules|1 day"
msgstr ""
msgid "OnCallSchedules|2 weeks"
msgstr ""
msgid "OnCallSchedules|Add a rotation"
msgstr ""
......@@ -20796,6 +20802,9 @@ msgstr ""
msgid "OnCallSchedules|On-call schedule for the %{timezone}"
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"
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