Commit 680a9297 authored by David O'Regan's avatar David O'Regan Committed by Olena Horal-Koretska

Feat(oncallschedules): add rotation grid calucations

Allow rotation assignes to be calculated
and assigned to the rotation grid from
shift times
parent 757cb10d
......@@ -4,6 +4,8 @@ import * as timeago from 'timeago.js';
import dateFormat from 'dateformat';
import { languageCode, s__, __, n__ } from '../../locale';
const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000;
window.timeago = timeago;
/**
......@@ -851,3 +853,45 @@ export const format24HourTimeStringFromInt = (time) => {
const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`;
return formatted24HourString;
};
/**
* A utility function which checks if two date ranges overlap.
*
* @param {Object} givenPeriodLeft - the first period to compare.
* @param {Object} givenPeriodRight - the second period to compare.
* @returns {Object} { overlap: number of days the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format }
* @throws {Error} Uncaught Error: Invalid period
*
* @example
* getOverlappingDaysInPeriods(
* { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) },
* { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) }
* ) => { daysOverlap: 2, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 }
*
*/
export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => {
const leftStartTime = new Date(givenPeriodLeft.start).getTime();
const leftEndTime = new Date(givenPeriodLeft.end).getTime();
const rightStartTime = new Date(givenPeriodRight.start).getTime();
const rightEndTime = new Date(givenPeriodRight.end).getTime();
if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) {
throw new Error(__('Invalid period'));
}
const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime;
if (!isOverlapping) {
return { daysOverlap: 0 };
}
const overlapStartDate = Math.max(leftStartTime, rightStartTime);
const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime;
const differenceInMs = overlapEndDate - overlapStartDate;
return {
daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY),
overlapStartDate,
overlapEndDate,
};
};
......@@ -113,8 +113,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
}
.item-label {
@include gl-py-4;
@include gl-pl-4;
border-right: $border-style;
border-bottom: $border-style;
}
......
<script>
import { GlToken, GlAvatarLabeled, GlPopover } from '@gitlab/ui';
import { assigneeScheduleDateStart } from 'ee/oncall_schedules/utils/common_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
export default {
......@@ -10,66 +10,65 @@ export default {
GlPopover,
},
props: {
assigneeIndex: {
type: Number,
required: true,
},
assignee: {
type: Object,
required: true,
},
rotationLength: {
type: Number,
rotationAssigneeStartsAt: {
type: String,
required: true,
},
rotationStartsAt: {
rotationAssigneeEndsAt: {
type: String,
required: true,
},
rotationAssigneeStyle: {
type: Object,
required: true,
},
},
computed: {
chevronClass() {
return `gl-bg-data-viz-${this.assignee.colorPalette}-${this.assignee.colorWeight}`;
},
startsAt() {
const startsAt = assigneeScheduleDateStart(
new Date(this.rotationStartsAt),
this.rotationLength * 7 * this.assigneeIndex,
).toLocaleString();
return sprintf(__('Starts at %{startsAt}'), { startsAt });
return sprintf(__('Starts: %{startsAt}'), {
startsAt: formatDate(this.rotationAssigneeStartsAt, 'mmmm d, yyyy, hh:mm'),
});
},
endsAt() {
const endsAt = assigneeScheduleDateStart(
new Date(this.rotationStartsAt),
this.rotationLength * 7 * this.assigneeIndex + this.rotationLength * 7,
).toLocaleString();
return sprintf(__('Ends at %{endsAt}'), { endsAt });
return sprintf(__('Ends: %{endsAt}'), {
endsAt: formatDate(this.rotationAssigneeEndsAt, 'mmmm d, yyyy, hh:mm'),
});
},
},
};
</script>
<template>
<div class="gl-w-full gl-mt-3 gl-px-3">
<div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden"
:style="rotationAssigneeStyle"
>
<gl-token
:id="assignee.user.id"
class="gl-w-full gl-align-items-center"
:id="assignee.id"
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.user.avatarUrl"
:src="assignee.avatarUrl"
:label="assignee.user.username"
:title="assignee.user.username"
/>
</gl-token>
<gl-popover
:target="assignee.user.id"
:target="assignee.id"
: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>
......
......@@ -58,7 +58,11 @@ export default {
<template>
<span class="timeline-header-item">
<div :class="timelineHeaderClass" class="item-label" data-testid="timeline-header-label">
<div
:class="timelineHeaderClass"
class="item-label gl-pl-6 gl-py-4"
data-testid="timeline-header-label"
>
{{ timelineHeaderLabel }}
</div>
<weeks-header-sub-item :timeframe-item="timeframeItem" />
......
<script>
import CommonMixin from '../../../../mixins/common_mixin';
import updateShiftTimeUnitWidthMutation from 'ee/oncall_schedules/graphql/mutations/update_shift_time_unit_width.mutation.graphql';
import CommonMixin from 'ee/oncall_schedules/mixins/common_mixin';
import { GlResizeObserverDirective } from '@gitlab/ui';
export default {
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [CommonMixin],
props: {
timeframeItem: {
......@@ -26,6 +31,9 @@ export default {
return headerSubItems;
},
},
mounted() {
this.updateShiftStyles();
},
methods: {
getSubItemValueClass(subItem) {
// Show dark color text only for current & upcoming dates
......@@ -36,15 +44,28 @@ export default {
}
return '';
},
updateShiftStyles() {
this.$apollo.mutate({
mutation: updateShiftTimeUnitWidthMutation,
variables: {
shiftTimeUnitWidth: this.$refs.weeklyDayCell[0].offsetWidth,
},
});
},
},
};
</script>
<template>
<div class="item-sublabel">
<div
v-gl-resize-observer="updateShiftStyles"
class="item-sublabel"
data-testid="week-item-sublabel"
>
<span
v-for="(subItem, index) in headerSubItems"
:key="index"
ref="weeklyDayCell"
:class="getSubItemValueClass(subItem)"
class="sublabel-value"
data-testid="sublabel-value"
......
<script>
import { GlButtonGroup, GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import DeleteRotationModal from 'ee/oncall_schedules/components/rotations/components/delete_rotation_modal.vue';
import { editRotationModalId, deleteRotationModalId } from 'ee/oncall_schedules/constants';
import { s__ } from '~/locale';
import CurrentDayIndicator from './current_day_indicator.vue';
import RotationAssignee from '../../rotations/components/rotation_assignee.vue';
import DeleteRotationModal from '../../rotations/components/delete_rotation_modal.vue';
import { editRotationModalId, deleteRotationModalId } from '../../../constants';
import ScheduleShift from './schedule_shift.vue';
export const i18n = {
editRotationLabel: s__('OnCallSchedules|Edit rotation'),
......@@ -19,8 +19,8 @@ export default {
GlButtonGroup,
GlButton,
CurrentDayIndicator,
RotationAssignee,
DeleteRotationModal,
ScheduleShift,
},
directives: {
GlModal: GlModalDirective,
......@@ -43,12 +43,16 @@ export default {
data() {
return {
rotationToUpdate: {},
shiftWidths: 0,
};
},
methods: {
setRotationToUpdate(rotation) {
this.rotationToUpdate = rotation;
},
isLastCell(index) {
return index + 1 === this.timeframe.length;
},
},
};
</script>
......@@ -87,15 +91,19 @@ export default {
<span
v-for="(timeframeItem, index) in timeframe"
:key="index"
class="timeline-cell"
class="timeline-cell gl-border-b-solid gl-border-b-gray-100 gl-border-b-1"
:class="{ 'gl-overflow-hidden': isLastCell(index) }"
data-testid="timelineCell"
>
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
<rotation-assignee
:assignee="rotation.participants.nodes[index]"
:assignee-index="index"
:rotation-length="rotation.length"
:rotation-starts-at="rotation.startsAt"
<schedule-shift
v-for="(shift, shiftIndex) in rotation.shifts.nodes"
:key="shift.startAt"
:shift="shift"
:shift-index="shiftIndex"
:preset-type="presetType"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
/>
</span>
</div>
......
<script>
import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
import {
PRESET_TYPES,
DAYS_IN_WEEK,
DAYS_IN_DATE_WEEK,
ASSIGNEE_SPACER,
} from 'ee/oncall_schedules/constants';
import getShiftTimeUnitWidthQuery from 'ee/oncall_schedules/graphql/queries/get_shift_time_unit_width.query.graphql';
import { getOverlappingDaysInPeriods } from '~/lib/utils/datetime_utility';
import { incrementDateByDays } from '../utils';
export default {
components: {
RotationAssignee,
},
props: {
shift: {
type: Object,
required: true,
},
shiftIndex: {
type: Number,
required: true,
},
timeframeItem: {
type: [Date, Object],
required: true,
},
timeframe: {
type: Array,
required: true,
},
presetType: {
type: String,
required: true,
},
},
data() {
return {
shiftTimeUnitWidth: 0,
};
},
apollo: {
shiftTimeUnitWidth: {
query: getShiftTimeUnitWidthQuery,
},
},
computed: {
currentTimeframeEndsAt() {
let UnitOfIncrement = 0;
if (this.presetType === PRESET_TYPES.WEEKS) {
UnitOfIncrement = DAYS_IN_DATE_WEEK;
}
return incrementDateByDays(this.timeframeItem, UnitOfIncrement);
},
daysUntilEndOfTimeFrame() {
return (
this.currentTimeframeEndsAt.getDate() -
new Date(this.shiftRangeOverlap.overlapStartDate).getDate() +
1
);
},
rotationAssigneeStyle() {
const startDate = this.shiftStartsAt.getDay();
const firstDayOfWeek = this.timeframeItem.getDay();
const isFirstCell = startDate === firstDayOfWeek;
const left =
isFirstCell || this.shiftStartDateOutOfRange
? '0px'
: `${
(DAYS_IN_WEEK - this.daysUntilEndOfTimeFrame) * this.shiftTimeUnitWidth +
ASSIGNEE_SPACER
}px`;
const width = `${this.shiftTimeUnitWidth * this.shiftWidth}px`;
return {
left,
width,
};
},
shiftStartsAt() {
return new Date(this.shift.startsAt);
},
shiftEndsAt() {
return new Date(this.shift.endsAt);
},
shiftStartDateOutOfRange() {
return this.shiftStartsAt.getTime() < this.timeframeItem.getTime();
},
shiftShouldRender() {
if (this.timeFrameIndex !== 0) {
return (
new Date(this.shiftRangeOverlap.overlapStartDate) > this.timeframeItem &&
new Date(this.shiftRangeOverlap.overlapStartDate) < this.currentTimeframeEndsAt
);
}
return Boolean(this.shiftRangeOverlap.daysOverlap);
},
shiftRangeOverlap() {
try {
return getOverlappingDaysInPeriods(
{ start: this.timeframeItem, end: this.currentTimeframeEndsAt },
{ start: this.shiftStartsAt, end: this.shiftEndsAt },
);
} catch (error) {
// TODO: We need to decide the UX implications of a invalid date creation.
return { daysOverlap: 0 };
}
},
shiftWidth() {
const offset = this.shiftStartDateOutOfRange ? 0 : 1;
const baseWidth =
this.timeFrameIndex === 0
? this.totalShiftRangeOverlap.daysOverlap
: this.shiftRangeOverlap.daysOverlap;
return baseWidth + offset;
},
timeFrameIndex() {
return this.timeframe.indexOf(this.timeframeItem);
},
timeFrameEndsAt() {
return this.timeframe[this.timeframe.length - 1];
},
totalShiftRangeOverlap() {
return getOverlappingDaysInPeriods(
{
start: this.timeframeItem,
end: incrementDateByDays(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"
/>
</template>
......@@ -32,3 +32,18 @@ export const getTimeframeForWeeksView = (initialDate = new Date()) => {
return timeframe;
};
/**
* A utility function which extends a given date value by a certain amount of days.
*
* @param {Date} initial - the initial date to extend.
* @param {Number} increment - the amount of days to extend by.
* @returns {Date}
*
* @example
* incrementDateByDays(new Date(2021, 0, 10), 6) => new Date(2021, 0, 16)
*
*/
export const incrementDateByDays = (initial, increment) => {
return new Date(new Date().setDate(initial.getDate() + increment));
};
......@@ -24,3 +24,9 @@ export const PRESET_DEFAULTS = {
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;
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import produce from 'immer';
import createDefaultClient from '~/lib/graphql';
import getShiftTimeUnitWidthQuery from './graphql/queries/get_shift_time_unit_width.query.graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient: createDefaultClient(
{},
{
cacheConfig: {},
assumeImmutableResults: true,
const resolvers = {
Mutation: {
updateShiftTimeUnitWidth: (_, { shiftTimeUnitWidth = 0 }, { cache }) => {
const sourceData = cache.readQuery({ query: getShiftTimeUnitWidthQuery });
const data = produce(sourceData, (draftData) => {
// eslint-disable-next-line no-param-reassign
draftData.shiftTimeUnitWidth = shiftTimeUnitWidth;
});
cache.writeQuery({ query: getShiftTimeUnitWidthQuery, data });
},
),
},
};
export default new VueApollo({
defaultClient: createDefaultClient(resolvers, {
cacheConfig: {},
assumeImmutableResults: true,
}),
});
mutation updateShiftTimeUnitWidth($shiftTimeUnitWidth: Int) {
updateShiftTimeUnitWidth(shiftTimeUnitWidth: $shiftTimeUnitWidth) @client
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import OnCallSchedulesWrapper from './components/oncall_schedules_wrapper.vue';
import getShiftTimeUnitWidthQuery from './graphql/queries/get_shift_time_unit_width.query.graphql';
import apolloProvider from './graphql';
Vue.use(VueApollo);
......@@ -12,6 +13,13 @@ export default () => {
const { projectPath, emptyOncallSchedulesSvgPath, timezones } = el.dataset;
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getShiftTimeUnitWidthQuery,
data: {
shiftTimeUnitWidth: 0,
},
});
return new Vue({
el,
apolloProvider,
......
import { sprintf, __ } from '~/locale';
import { getDateInFuture } from '~/lib/utils/datetime_utility';
/**
* Returns formatted timezone string, e.g. (UTC-09:00) AKST Alaska
......@@ -18,19 +17,6 @@ export const getFormattedTimezone = (tz) => {
});
};
/**
* Returns formatted date of the rotation assignee
* based on the rotation start time and length
*
* @param {Date} startDate
* @param {Number} daysToAdd
*
* @returns {Date}
*/
export const assigneeScheduleDateStart = (startDate, daysToAdd) => {
return getDateInFuture(startDate, daysToAdd);
};
/**
* Returns `true` for non-empty string, otherwise returns `false`
*
......
......@@ -61,9 +61,6 @@ describe('AddScheduleModal', () => {
}
async function updateSchedule(localWrapper) {
await jest.runOnlyPendingTimers();
await localWrapper.vm.$nextTick();
localWrapper.find(GlModal).vm.$emit('primary', { preventDefault: jest.fn() });
}
......
[{
"id": "gid://gitlab/IncidentManagement::OncallRotation/2",
"name": "Rotation 242",
"startsAt": "2020-12-09T09:00:53Z",
"startsAt": "2021-01-13T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"participants": {
"nodes": [
{
"user": {
"id": "gid://gitlab/User/1",
"username": "root",
"id": "gid://gitlab/IncidentManagement::OncallParticipant/49",
"username": "nora.schaden",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "blue"
}
]
},
"shifts": {
"nodes": [
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/49",
"colorWeight": "500",
"colorPalette": "blue",
"user": {
"username": "nora.schaden"
}
},
"startsAt": "2021-01-12T10:04:56.333Z",
"endsAt": "2021-01-15T10:04:56.333Z"
},
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/232",
"colorWeight": "500",
"colorPalette": "orange",
"user": {
"username": "racheal.loving"
}
},
"startsAt": "2021-01-16T10:04:56.333Z",
"endsAt": "2021-01-18T10:04:56.333Z"
}
]
}
},
{
"id": "gid://gitlab/IncidentManagement::OncallRotation/55",
"name": "Rotation 242",
"startsAt": "2021-01-13T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"participants": {
"nodes": [
{
"user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/99",
"username": "david.oregan",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "aqua"
}
]
},
"shifts": {
"nodes": [
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/99",
"colorWeight": "500",
"colorPalette": "aqua",
"user": {
"id": "gid://gitlab/User/2",
"username": "david",
"avatarUrl": "/url"
},
"username": "david.oregan"
}
},
"startsAt": "2021-01-14T10:04:56.333Z",
"endsAt": "2021-01-20T10:04:56.333Z"
},
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/300",
"colorWeight": "500",
"colorPalette": "magenta"
}
"colorPalette": "green",
"user": {
"username": "david.keagan"
}
},
"startsAt": "2021-01-21T10:04:56.333Z",
"endsAt": "2021-01-26T10:04:56.333Z"
}
]
}
},
{
"id": "gid://gitlab/IncidentManagement::OncallRotation/3",
"name": "Rotation 244",
"startsAt": "2020-12-16T09:00:53Z",
"startsAt": "2021-01-06T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"participants": {
"nodes": [
{
"user": {
"id": "gid://gitlab/User/3",
"username": "root 2",
"id": "gid://gitlab/IncidentManagement::OncallParticipant/48",
"username": "root",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "orange"
"colorPalette": "magenta"
}
]
},
"shifts": {
"nodes": [
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/100",
"colorWeight": "500",
"colorPalette": "magenta",
"user": {
"username": "root"
}
},
"startsAt": "2021-01-10T10:04:56.333Z",
"endsAt": "2021-01-13T10:04:56.333Z"
},
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/109",
"colorWeight": "600",
"colorPalette": "blue",
"user": {
"id": "gid://gitlab/User/4",
"username": "david 2",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "aqua"
}
"username": "root2"
}
},
"startsAt": "2021-01-15T10:04:56.333Z",
"endsAt": "2021-01-18T10:04:56.333Z"
}
]
}
}]
},
{
"id": "gid://gitlab/IncidentManagement::OncallRotation/5",
"name": "Rotation 247",
"startsAt": "2021-01-06T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"participants": {
"nodes": [
{
"user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/51",
"username": "oregand",
"avatarUrl": "/url"
},
"colorWeight": "600",
"colorPalette": "orange"
}
]
},
"shifts": {
"nodes": [
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/52",
"colorWeight": "600",
"colorPalette": "orange",
"user": {
"username": "oregand"
}
},
"startsAt": "2021-01-12T10:04:56.333Z",
"endsAt": "2021-01-15T10:04:56.333Z"
},
{
"participant": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/77",
"colorWeight": "600",
"colorPalette": "aqua",
"user": {
"username": "sarah.w"
}
},
"startsAt": "2021-01-16T10:04:56.333Z",
"endsAt": "2021-01-30T10:04:56.333Z"
}
]
}
}]
\ No newline at end of file
......@@ -11,7 +11,6 @@ import VueApollo from 'vue-apollo';
import { preExistingSchedule, newlyCreatedSchedule } from './mocks/apollo_mock';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('On-call schedule wrapper', () => {
let wrapper;
......@@ -45,6 +44,7 @@ describe('On-call schedule wrapper', () => {
function mountComponentWithApollo() {
const fakeApollo = createMockApollo([[getOncallSchedulesQuery, getOncallSchedulesQuerySpy]]);
localVue.use(VueApollo);
wrapper = shallowMount(OnCallScheduleWrapper, {
localVue,
......@@ -64,7 +64,6 @@ describe('On-call schedule wrapper', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
......
......@@ -2,26 +2,31 @@ import { shallowMount } from '@vue/test-utils';
import { GlToken, GlAvatarLabeled, GlPopover } from '@gitlab/ui';
import RotationAssignee 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 mockRotations from '../../mocks/mock_rotation.json';
describe('RotationAssignee', () => {
let wrapper;
const assignee = mockRotations[0].participants.nodes[1];
const findToken = () => wrapper.find(GlToken);
const findAvatar = () => wrapper.find(GlAvatarLabeled);
const findPopOver = () => wrapper.find(GlPopover);
const assignee = mockRotations[0].shifts.nodes[0];
const findToken = () => wrapper.findComponent(GlToken);
const findAvatar = () => wrapper.findComponent(GlAvatarLabeled);
const findPopOver = () => wrapper.findComponent(GlPopover);
const findStartsAt = () => wrapper.findByTestId('rotation-assignee-starts-at');
const findEndsAt = () => wrapper.findByTestId('rotation-assignee-ends-at');
const formattedDate = (date) => {
return formatDate(date, 'mmmm d, yyyy, hh:mm');
};
function createComponent() {
wrapper = extendedWrapper(
shallowMount(RotationAssignee, {
propsData: {
assignee,
assigneeIndex: 1,
rotationLength: mockRotations[0].length,
rotationStartsAt: mockRotations[0].startsAt,
assignee: assignee.participant,
rotationAssigneeStartsAt: assignee.startsAt,
rotationAssigneeEndsAt: assignee.endsAt,
rotationAssigneeStyle: { left: '0px', width: '100px' },
},
}),
);
......@@ -37,26 +42,20 @@ describe('RotationAssignee', () => {
describe('rotation assignee token', () => {
it('should render an assignee name', () => {
expect(findAvatar().attributes('label')).toBe(assignee.user.username);
});
it('should render an assignee avatar', () => {
expect(findAvatar().attributes('src')).toBe(assignee.user.avatarUrl);
expect(findAvatar().attributes('label')).toBe(assignee.participant.user.username);
});
it('should render an assignee color based on the chevron skipping color pallette', () => {
const token = findToken();
expect(token.classes()).toContain(
`gl-bg-data-viz-${assignee.colorPalette}-${assignee.colorWeight}`,
`gl-bg-data-viz-${assignee.participant.colorPalette}-${assignee.participant.colorWeight}`,
);
});
it('should render an assignee schedule and rotation information in a popover', () => {
expect(findPopOver().attributes('target')).toBe(assignee.user.id);
// starts at the beginning of the rotation time
expect(findStartsAt().text()).toContain('12/16/2020');
// ends at the calculated length of the rotation for this user: rotation length * which user index assignee is at
expect(findEndsAt().text()).toContain('12/23/2020');
expect(findPopOver().attributes('target')).toBe(assignee.participant.id);
expect(findStartsAt().text()).toContain(formattedDate(assignee.startsAt));
expect(findEndsAt().text()).toContain(formattedDate(assignee.endsAt));
});
});
});
......@@ -16,72 +16,217 @@ exports[`RotationsListSectionComponent renders component layout 1`] = `
Rotation 242
</span>
<gl-button-group-stub
class="gl-px-2"
<div
class="gl-px-2 btn-group"
role="group"
>
<gl-button-stub
<button
aria-label="Edit rotation"
buttontextclasses=""
category="tertiary"
icon="pencil"
role="button"
size="medium"
tabindex="0"
class="btn btn-default btn-md gl-button btn-default-tertiary btn-icon"
title="Edit rotation"
variant="default"
/>
type="button"
>
<!---->
<svg
aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="pencil-icon"
>
<use
href="#pencil"
/>
</svg>
<!---->
</button>
<gl-button-stub
<button
aria-label="Delete rotation"
buttontextclasses=""
category="tertiary"
icon="remove"
role="button"
size="medium"
tabindex="0"
class="btn btn-default btn-md gl-button btn-default-tertiary btn-icon"
title="Delete rotation"
variant="default"
/>
</gl-button-group-stub>
type="button"
>
<!---->
<svg
aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="remove-icon"
>
<use
href="#remove"
/>
</svg>
<!---->
</button>
</div>
</span>
<span
class="timeline-cell"
class="timeline-cell gl-border-b-solid gl-border-b-gray-100 gl-border-b-1"
data-testid="timelineCell"
>
<current-day-indicator-stub
presettype="WEEKS"
timeframeitem="Mon Jan 01 2018 00:00:00 GMT+0000 (Greenwich Mean Time)"
<span
class="current-day-indicator"
style="left: 7.142857142857143%;"
/>
<rotation-assignee-stub
assignee="[object Object]"
assigneeindex="0"
rotationlength="1"
rotationstartsat="2020-12-09T09:00:53Z"
/>
<div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden"
style="left: 0px; width: 0px;"
>
<span
class="gl-w-full gl-h-6 gl-align-items-center gl-token gl-token-default-variant gl-bg-data-viz-blue-500"
id="gid://gitlab/IncidentManagement::OncallParticipant/49"
>
<span
class="gl-token-content"
>
<div
class="gl-avatar-labeled"
shape="circle"
size="16"
title="nora.schaden"
>
<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>
<!---->
</span>
</span>
<div
class="gl-popover"
title="nora.schaden"
>
<p
class="gl-m-0"
data-testid="rotation-assignee-starts-at"
>
Starts: January 12, 2021, 10:01
</p>
<p
class="gl-m-0"
data-testid="rotation-assignee-ends-at"
>
Ends: January 15, 2021, 10:01
</p>
</div>
</div>
<div
class="gl-absolute gl-h-7 gl-mt-3 gl-z-index-1 gl-overflow-hidden"
style="left: 2px; width: 0px;"
>
<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="gid://gitlab/IncidentManagement::OncallParticipant/232"
>
<span
class="gl-token-content"
>
<div
class="gl-avatar-labeled"
shape="circle"
size="16"
title="racheal.loving"
>
<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>
<!---->
</span>
</span>
<div
class="gl-popover"
title="racheal.loving"
>
<p
class="gl-m-0"
data-testid="rotation-assignee-starts-at"
>
Starts: January 16, 2021, 10:01
</p>
<p
class="gl-m-0"
data-testid="rotation-assignee-ends-at"
>
Ends: January 18, 2021, 10:01
</p>
</div>
</div>
</span>
<span
class="timeline-cell"
class="timeline-cell gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-overflow-hidden"
data-testid="timelineCell"
>
<current-day-indicator-stub
presettype="WEEKS"
timeframeitem="Mon Jan 08 2018 00:00:00 GMT+0000 (Greenwich Mean Time)"
/>
<!---->
<rotation-assignee-stub
assignee="[object Object]"
assigneeindex="1"
rotationlength="1"
rotationstartsat="2020-12-09T09:00:53Z"
/>
<!---->
<!---->
</span>
</div>
<delete-rotation-modal-stub
modalid="deleteRotationModal"
rotation="[object Object]"
/>
<!---->
</div>
`;
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import WeeksHeaderSubItemComponent from 'ee/oncall_schedules/components/schedule/components/preset_weeks/weeks_header_sub_item.vue';
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
import updateShiftTimeUnitWidthMutation from 'ee/oncall_schedules/graphql/mutations/update_shift_time_unit_width.mutation.graphql';
import { useFakeDate } from 'helpers/fake_date';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('WeeksHeaderSubItemComponent', () => {
let wrapper;
......@@ -11,11 +14,21 @@ describe('WeeksHeaderSubItemComponent', () => {
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
function mountComponent({ timeframeItem = mockTimeframeWeeks[0] }) {
wrapper = shallowMount(WeeksHeaderSubItemComponent, {
propsData: {
timeframeItem,
},
});
wrapper = extendedWrapper(
shallowMount(WeeksHeaderSubItemComponent, {
propsData: {
timeframeItem,
},
directives: {
GlResizeObserver: createMockDirective(),
},
mocks: {
$apollo: {
mutate: jest.fn(),
},
},
}),
);
}
beforeEach(() => {
......@@ -25,11 +38,11 @@ describe('WeeksHeaderSubItemComponent', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findSublabelValues = () => wrapper.findAll('[data-testid="sublabel-value"]');
const findWeeksHeaderSubItemComponent = () => wrapper.findByTestId('week-item-sublabel');
describe('computed', () => {
describe('headerSubItems', () => {
......@@ -71,5 +84,24 @@ describe('WeeksHeaderSubItemComponent', () => {
expect.arrayContaining(['label-dark', 'label-bold']),
);
});
it('should store the rendered cell width in Apollo cache via `updateShiftTimeUnitWidthMutation` when mounted', async () => {
wrapper.vm.$apollo.mutate.mockResolvedValueOnce({});
await wrapper.vm.$nextTick();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateShiftTimeUnitWidthMutation,
variables: {
shiftTimeUnitWidth: wrapper.vm.$refs.weeklyDayCell[0].offsetWidth,
},
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
});
it('should re-calculate cell width inside Apollo cache on page resize', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
const { value } = getBinding(findWeeksHeaderSubItemComponent().element, 'gl-resize-observer');
value();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { GlCard } from '@gitlab/ui';
import RotationsListSection from 'ee/oncall_schedules/components/schedule/components/rotations_list_section.vue';
import CurrentDayIndicator from 'ee/oncall_schedules/components/schedule/components/current_day_indicator.vue';
......@@ -9,19 +9,23 @@ import mockRotations from '../../mocks/mock_rotation.json';
describe('RotationsListSectionComponent', () => {
let wrapper;
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const mockTimeframeInitialDate = new Date(mockRotations[0].shifts.nodes[0].startsAt);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const projectPath = 'group/project';
function createComponent({
presetType = PRESET_TYPES.WEEKS,
timeframe = mockTimeframeWeeks,
} = {}) {
wrapper = shallowMount(RotationsListSection, {
wrapper = mount(RotationsListSection, {
propsData: {
presetType,
timeframe,
rotations: [mockRotations[0]],
},
provide: {
projectPath,
},
stubs: {
GlCard,
},
......@@ -39,7 +43,7 @@ describe('RotationsListSectionComponent', () => {
});
const findTimelineCells = () => wrapper.findAll('[data-testid="timelineCell"]');
const findRotationAssignees = () => wrapper.findAll(RotationsAssignee);
const findRotationAssignees = () => wrapper.findAllComponents(RotationsAssignee);
it('renders component layout', () => {
expect(wrapper.element).toMatchSnapshot();
......@@ -53,10 +57,10 @@ describe('RotationsListSectionComponent', () => {
expect(findTimelineCells().at(0).find(CurrentDayIndicator).exists()).toBe(true);
});
it('render the correct amount of rotation assignees with their name, avatar and color', () => {
it('render the correct amount of rotation assignees with their related information', () => {
expect(findRotationAssignees()).toHaveLength(2);
expect(findRotationAssignees().at(0).props().assignee.user).toEqual(
mockRotations[0].participants.nodes[0].user,
mockRotations[0].shifts.nodes[0].participant.user,
);
});
});
import { shallowMount } from '@vue/test-utils';
import ScheduleShift from 'ee/oncall_schedules/components/schedule/components/schedule_shift.vue';
import RotationsAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
import { incrementDateByDays } from 'ee/oncall_schedules/components/schedule/utils';
import { PRESET_TYPES, DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
const shift = {
participant: {
id: '1',
user: {
username: 'nora.schaden',
},
},
startsAt: '2021-01-12T10:04:56.333Z',
endsAt: '2021-01-15T10:04:56.333Z',
};
const CELL_WIDTH = 50;
const timeframeItem = new Date(2021, 0, 13);
const timeframe = [timeframeItem, incrementDateByDays(timeframeItem, DAYS_IN_WEEK)];
describe('ee/oncall_schedules/components/schedule/components/schedule_shift.vue', () => {
let wrapper;
function createComponent({ props = {}, data = {} } = {}) {
wrapper = shallowMount(ScheduleShift, {
propsData: {
shift,
shiftIndex: 0,
timeframeItem,
timeframe,
presetType: PRESET_TYPES.WEEKS,
...props,
},
data() {
return {
shiftTimeUnitWidth: 0,
...data,
};
},
mocks: {
$apollo: {
queries: {
shiftTimeUnitWidth: 0,
},
},
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findRotationAssignee = () => wrapper.findComponent(RotationsAssignee);
describe('shift overlaps inside the current time-frame', () => {
it('should render a rotation assignee child component', () => {
expect(findRotationAssignee().exists()).toBe(true);
});
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)
*/
createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '0px',
width: '150px',
});
});
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
*/
createComponent({
props: { shift: { ...shift, startsAt: '2021-01-14T10:04:56.333Z' } },
data: { shiftTimeUnitWidth: CELL_WIDTH },
});
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '52px',
width: '100px',
});
});
});
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;
createComponent({
props: {
timeframeItem: expectedTimeframeItem,
shift: { ...shift, startsAt, endsAt },
},
});
expect(findRotationAssignee().exists()).toBe(false);
});
});
});
......@@ -10851,15 +10851,15 @@ msgstr ""
msgid "End Time"
msgstr ""
msgid "Ends at %{endsAt}"
msgstr ""
msgid "Ends at (UTC)"
msgstr ""
msgid "Ends on"
msgstr ""
msgid "Ends: %{endsAt}"
msgstr ""
msgid "Enforce DNS rebinding attack protection"
msgstr ""
......@@ -15506,6 +15506,9 @@ msgstr ""
msgid "Invalid login or password"
msgstr ""
msgid "Invalid period"
msgstr ""
msgid "Invalid pin code"
msgstr ""
......@@ -26899,15 +26902,15 @@ msgstr ""
msgid "Starts %{startsIn}"
msgstr ""
msgid "Starts at %{startsAt}"
msgstr ""
msgid "Starts at (UTC)"
msgstr ""
msgid "Starts on"
msgstr ""
msgid "Starts: %{startsAt}"
msgstr ""
msgid "State your message to activate"
msgstr ""
......
......@@ -842,3 +842,58 @@ describe('format24HourTimeStringFromInt', () => {
});
});
});
describe('getOverlappingDaysInPeriods', () => {
const start = new Date(2021, 0, 11);
const end = new Date(2021, 0, 13);
describe('when date periods overlap', () => {
const givenPeriodLeft = new Date(2021, 0, 11);
const givenPeriodRight = new Date(2021, 0, 14);
it('returns an overlap object that contains the amount of days overlapping, start date of overlap and end date of overlap', () => {
expect(
datetimeUtility.getOverlappingDaysInPeriods(
{ start, end },
{ start: givenPeriodLeft, end: givenPeriodRight },
),
).toEqual({
daysOverlap: 2,
overlapStartDate: givenPeriodLeft.getTime(),
overlapEndDate: end.getTime(),
});
});
});
describe('when date periods do not overlap', () => {
const givenPeriodLeft = new Date(2021, 0, 9);
const givenPeriodRight = new Date(2021, 0, 10);
it('returns an overlap object that contains a 0 value for days overlapping', () => {
expect(
datetimeUtility.getOverlappingDaysInPeriods(
{ start, end },
{ start: givenPeriodLeft, end: givenPeriodRight },
),
).toEqual({ daysOverlap: 0 });
});
});
describe('when date periods contain an invalid Date', () => {
const startInvalid = new Date(NaN);
const endInvalid = new Date(NaN);
const error = __('Invalid period');
it('throws an exception when the left period contains an invalid date', () => {
expect(() =>
datetimeUtility.getOverlappingDaysInPeriods({ start, end }, { start: startInvalid, end }),
).toThrow(error);
});
it('throws an exception when the right period contains an invalid date', () => {
expect(() =>
datetimeUtility.getOverlappingDaysInPeriods({ start, end }, { start, end: endInvalid }),
).toThrow(error);
});
});
});
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