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 @@
&.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',
});
});
});
});
......@@ -20733,6 +20733,12 @@ msgstr ""
msgid "On-call schedules"
msgstr ""
msgid "OnCallSchedules|1 day"
msgstr ""
msgid "OnCallSchedules|2 weeks"
msgstr ""
msgid "OnCallSchedules|Add a rotation"
msgstr ""
......@@ -20790,6 +20796,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