Commit 75fa9fd2 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '262848-display-schedule' into 'master'

Display schedule

See merge request gitlab-org/gitlab!48560
parents f3eb4750 4ce0c767
...@@ -28,3 +28,115 @@ ...@@ -28,3 +28,115 @@
} }
} }
} }
//// Copied from roadmaps.scss - adapted for on-call schedules
$header-item-height: 60px;
$details-cell-width: px-to-rem(150px);
$timeline-cell-height: 32px;
$timeline-cell-width: 180px;
$border-style: 1px solid var(--gray-100, $gray-100);
$gradient-dark-gray: rgba(0, 0, 0, 0.15);
$gradient-gray: rgba(255, 255, 255, 0.001);
$scroll-top-gradient: linear-gradient(to bottom, $gradient-dark-gray 0%, $gradient-gray 100%);
$scroll-bottom-gradient: linear-gradient(to bottom, $gradient-gray 0%, $gradient-dark-gray 100%);
$column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradient-gray 100%);
$epic-details-cell-width: 150px;
.schedule-shell {
@include gl-relative;
@include gl-h-full;
@include gl-w-full;
@include gl-overflow-x-auto;
@include gl-border-gray-100;
@include gl-border-1;
@include gl-border-solid;
@include gl-rounded-base;
}
.timeline-section {
@include gl-sticky;
position: -webkit-sticky;
@include gl-top-0;
z-index: 20;
.timeline-header-blank,
.timeline-header-item {
@include float-left;
height: $header-item-height;
border-bottom: $border-style;
background-color: var(--white, $white);
}
.timeline-header-blank {
@include gl-sticky;
position: -webkit-sticky;
@include gl-top-0;
@include gl-left-0;
width: $details-cell-width;
z-index: 2;
&::after {
height: $header-item-height;
@include gl-content-empty;
@include gl-absolute;
@include gl-top-0;
right: -$grid-size;
width: $grid-size;
@include gl-pointer-events-none;
background: $column-right-gradient;
}
}
.timeline-header-item {
// container size minus left panel width divided by 2 week timeframes
width: calc((100% - #{$epic-details-cell-width}) / 2);
&:last-of-type .item-label {
@include gl-border-r-0;
}
.item-label,
.item-sublabel .sublabel-value {
color: var(--gray-400, $gray-400);
@include gl-font-weight-normal;
&.label-dark {
@include gl-text-gray-900;
}
&.label-bold {
@include gl-font-weight-bold;
}
}
.item-label {
padding: $gl-padding-8 $gl-padding;
border-right: $border-style;
border-bottom: $border-style;
}
.item-sublabel {
@include gl-relative;
@include gl-display-flex;
.sublabel-value {
@include gl-flex-grow-1;
@include gl-flex-basis-0;
text-align: center;
font-size: $code-font-size;
line-height: 1.5;
padding: 2px 0;
}
}
.current-day-indicator-header {
@include gl-bottom-0;
height: $gl-vert-padding;
width: $gl-vert-padding;
background-color: var(--red-500, $red-500);
border-radius: 50%;
transform: translateX(-3px);
}
}
}
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import createOncallScheduleMutation from '../graphql/create_oncall_schedule.mutation.graphql'; import createOncallScheduleMutation from '../graphql/create_oncall_schedule.mutation.graphql';
import { getFormattedTimezone } from '../utils';
export const i18n = { export const i18n = {
selectTimezone: s__('OnCallSchedules|Select timezone'), selectTimezone: s__('OnCallSchedules|Select timezone'),
...@@ -145,7 +146,7 @@ export default { ...@@ -145,7 +146,7 @@ export default {
this.form.timezone = tz; this.form.timezone = tz;
}, },
getFormattedTimezone(tz) { getFormattedTimezone(tz) {
return __(`(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`); return getFormattedTimezone(tz);
}, },
isTimezoneSelected(tz) { isTimezoneSelected(tz) {
return isEqual(tz, this.form.timezone); return isEqual(tz, this.form.timezone);
......
<script>
import { GlSprintf, GlCard } from '@gitlab/ui';
import { s__ } from '~/locale';
import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue';
import { getTimeframeForWeeksView } from './schedule/utils';
import { PRESET_TYPES } from './schedule/constants';
import { getFormattedTimezone } from '../utils';
export const i18n = {
title: s__('OnCallSchedules|On-call schedule'),
scheduleForTz: s__('OnCallSchedules|On-call schedule for the %{tzShort}'),
};
export default {
i18n,
presetType: PRESET_TYPES.WEEKS,
inject: ['timezones'],
components: {
GlSprintf,
GlCard,
ScheduleTimelineSection,
},
props: {
schedule: {
type: Object,
required: true,
},
},
computed: {
tzLong() {
const selectedTz = this.timezones.find(tz => tz.identifier === this.schedule.timezone);
return getFormattedTimezone(selectedTz);
},
timeframe() {
return getTimeframeForWeeksView();
},
},
};
</script>
<template>
<div>
<h2>{{ $options.i18n.title }}</h2>
<gl-card>
<template #header>
<h3 class="gl-font-weight-bold gl-font-lg gl-m-0">{{ schedule.name }}</h3>
</template>
<p class="gl-text-gray-500 gl-mb-5">
<gl-sprintf :message="$options.i18n.scheduleForTz">
<template #tzShort>{{ schedule.timezone }}</template>
</gl-sprintf>
| {{ tzLong }}
</p>
<div class="schedule-shell">
<schedule-timeline-section :preset-type="$options.presetType" :timeframe="timeframe" />
</div>
</gl-card>
</div>
</template>
<script> <script>
import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui'; import { GlEmptyState, GlButton, GlLoadingIcon, GlModalDirective } from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper';
import AddScheduleModal from './add_schedule_modal.vue'; import AddScheduleModal from './add_schedule_modal.vue';
import AddRotationModal from './rotations/add_rotation_modal.vue'; import AddRotationModal from './rotations/add_rotation_modal.vue';
import OncallSchedule from './oncall_schedule.vue';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import getOncallSchedules from '../graphql/get_oncall_schedules.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
const addScheduleModalId = 'addScheduleModal'; const addScheduleModalId = 'addScheduleModal';
...@@ -17,23 +21,54 @@ export const i18n = { ...@@ -17,23 +21,54 @@ export const i18n = {
export default { export default {
i18n, i18n,
addScheduleModalId, addScheduleModalId,
inject: ['emptyOncallSchedulesSvgPath'], inject: ['emptyOncallSchedulesSvgPath', 'projectPath'],
components: { components: {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
GlLoadingIcon,
AddScheduleModal, AddScheduleModal,
AddRotationModal, AddRotationModal,
OncallSchedule,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
}, },
methods: {}, data() {
return {
schedule: {},
};
},
apollo: {
schedule: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getOncallSchedules,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
return data?.project?.incidentManagementOncallSchedules?.nodes?.[0] ?? null;
},
error(error) {
Sentry.captureException(error);
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.schedule.loading;
},
},
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<oncall-schedule v-else-if="schedule" :schedule="schedule" />
<gl-empty-state <gl-empty-state
v-else
:title="$options.i18n.emptyState.title" :title="$options.i18n.emptyState.title"
:description="$options.i18n.emptyState.description" :description="$options.i18n.emptyState.description"
:svg-path="emptyOncallSchedulesSvgPath" :svg-path="emptyOncallSchedulesSvgPath"
......
<script>
import { monthInWords } from '~/lib/utils/datetime_utility';
import WeeksHeaderSubItem from './weeks_header_sub_item.vue';
export default {
components: {
WeeksHeaderSubItem,
},
props: {
timeframeIndex: {
type: Number,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
timeframe: {
type: Array,
required: true,
},
},
data() {
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
return {
currentDate,
};
},
computed: {
lastDayOfCurrentWeek() {
const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
return lastDayOfCurrentWeek;
},
timelineHeaderLabel() {
const timeframeItemMonth = this.timeframeItem.getMonth();
const timeframeItemDate = this.timeframeItem.getDate();
if (this.timeframeIndex === 0 || (timeframeItemMonth === 0 && timeframeItemDate <= 7)) {
return `${this.timeframeItem.getFullYear()} ${monthInWords(
this.timeframeItem,
true,
)} ${timeframeItemDate}`;
}
return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`;
},
timelineHeaderClass() {
const currentDateTime = this.currentDate.getTime();
const lastDayOfCurrentWeekTime = this.lastDayOfCurrentWeek.getTime();
if (
currentDateTime >= this.timeframeItem.getTime() &&
currentDateTime <= lastDayOfCurrentWeekTime
) {
return 'label-dark label-bold';
}
return '';
},
},
};
</script>
<template>
<span class="timeline-header-item">
<div :class="timelineHeaderClass" class="item-label">{{ timelineHeaderLabel }}</div>
<weeks-header-sub-item :timeframe-item="timeframeItem" :current-date="currentDate" />
</span>
</template>
<script>
import { PRESET_TYPES } from '../../constants';
import CommonMixin from '../../mixins/common_mixin';
export default {
mixins: [CommonMixin],
props: {
currentDate: {
type: Date,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
},
data() {
return {
presetType: PRESET_TYPES.WEEKS,
indicatorStyle: {},
};
},
computed: {
headerSubItems() {
const timeframeItem = new Date(this.timeframeItem.getTime());
const headerSubItems = new Array(7)
.fill()
.map(
(val, i) =>
new Date(
timeframeItem.getFullYear(),
timeframeItem.getMonth(),
timeframeItem.getDate() + i,
),
);
return headerSubItems;
},
},
mounted() {
this.$nextTick(() => {
this.indicatorStyle = this.getIndicatorStyles();
});
},
methods: {
getSubItemValueClass(subItem) {
// Show dark color text only for current & upcoming dates
if (subItem.getTime() === this.currentDate.getTime()) {
return 'label-dark label-bold';
} else if (subItem > this.currentDate) {
return 'label-dark';
}
return '';
},
},
};
</script>
<template>
<div class="item-sublabel">
<span
v-for="(subItem, index) in headerSubItems"
:key="index"
:class="getSubItemValueClass(subItem)"
class="sublabel-value"
>{{ subItem.getDate() }}</span
>
<span
v-if="hasToday"
:style="indicatorStyle"
class="current-day-indicator-header preset-weeks gl-absolute"
></span>
</div>
</template>
<script>
import WeeksHeaderItem from './preset_weeks/weeks_header_item.vue';
export default {
components: {
WeeksHeaderItem,
},
props: {
presetType: {
type: String,
required: true,
},
timeframe: {
type: Array,
required: true,
},
},
};
</script>
<template>
<div class="timeline-section clearfix">
<span class="timeline-header-blank"></span>
<weeks-header-item
v-for="(timeframeItem, index) in timeframe"
:key="index"
:timeframe-index="index"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
/>
</div>
</template>
export const DAYS_IN_WEEK = 7;
export const PRESET_TYPES = {
WEEKS: 'WEEKS',
};
export const PRESET_DEFAULTS = {
WEEKS: {
TIMEFRAME_LENGTH: 2,
},
};
export const PAST_DATE = new Date(new Date().getFullYear() - 100, 0, 1);
export const FUTURE_DATE = new Date(new Date().getFullYear() + 100, 0, 1);
import { DAYS_IN_WEEK } from '../constants';
export default {
computed: {
hasToday() {
const timeframeItem = new Date(this.timeframeItem.getTime());
const headerSubItems = new Array(7)
.fill()
.map(
(val, i) =>
new Date(
timeframeItem.getFullYear(),
timeframeItem.getMonth(),
timeframeItem.getDate() + i,
),
);
return (
this.currentDate.getTime() >= headerSubItems[0].getTime() &&
this.currentDate.getTime() <= headerSubItems[headerSubItems.length - 1].getTime()
);
},
},
methods: {
getIndicatorStyles() {
// as we start schedule scale from the current date the indicator will always be on the first date. So we find
// the percentage of space one day cell takes and divide it by 2 cause the tick is in the middle of the cell.
// It might be updated to more precise position - time of the day
const left = 100 / DAYS_IN_WEEK / 2;
return {
left: `${left}%`,
};
},
},
};
import { newDate } from '~/lib/utils/datetime_utility';
import { PRESET_DEFAULTS, DAYS_IN_WEEK } from './constants';
/**
* This method returns array of Dates representing 2-weeks timeframe based on provided initialDate
*
* For eg; If initialDate is 31th Dec 2017
* we show 2 weeks starting from the current date
* So returned array from this method will be;
* [
* 31 Dec 2017, 7 Jan 2018
* ]
*
* @param {Date} initialDate
*/
export const getTimeframeForWeeksView = (initialDate = new Date()) => {
const timeframe = [];
const startDate = newDate(initialDate);
startDate.setHours(0, 0, 0, 0);
const rangeLength = PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH;
// Iterate for the length of this preset
for (let i = 0; i < rangeLength; i += 1) {
// Push date to timeframe only when day is
// the first day of the next week (if initial date is Tuesday next date will be also Tuesday but of the next week)
timeframe.push(newDate(startDate));
// Move date to the next in a week
startDate.setDate(startDate.getDate() + DAYS_IN_WEEK);
}
return timeframe;
};
query getOncallSchedules($projectPath: ID!) {
project(fullPath: $projectPath) {
incidentManagementOncallSchedules {
nodes {
iid
name
description
timezone
}
}
}
}
import { sprintf, __ } from '~/locale';
/**
* Returns formatted timezone string, e.g. (UTC-09:00) AKST Alaska
*
* @param {Object} tz
* @param {String} tz.name
* @param {String} tz.formatted_offset
* @param {String} tz.abbr
*
* @returns {String}
*/
export const getFormattedTimezone = tz => {
return sprintf(__('(UTC%{offset}) %{timezone}'), {
offset: tz.formatted_offset,
timezone: `${tz.abbr} ${tz.name}`,
});
};
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddScheduleModal renders modal layout 1`] = ` exports[`Add schedule modal renders modal layout 1`] = `
<gl-modal-stub <gl-modal-stub
actioncancel="[object Object]" actioncancel="[object Object]"
actionprimary="[object Object]" actionprimary="[object Object]"
......
...@@ -4,7 +4,7 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -4,7 +4,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import AddScheduleModal, { i18n } from 'ee/oncall_schedules/components/add_schedule_modal.vue'; import AddScheduleModal, { i18n } from 'ee/oncall_schedules/components/add_schedule_modal.vue';
import mockTimezones from './mocks/mockTimezones.json'; import mockTimezones from './mocks/mockTimezones.json';
describe('AddScheduleModal', () => { describe('Add schedule modal', () => {
let wrapper; let wrapper;
const projectPath = 'group/project'; const projectPath = 'group/project';
const mutate = jest.fn(); const mutate = jest.fn();
......
import { shallowMount } from '@vue/test-utils';
import { GlCard, GlSprintf } from '@gitlab/ui';
import OnCallSchedule, { i18n } from 'ee/oncall_schedules/components/oncall_schedule.vue';
import ScheduleTimelineSection from 'ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue';
import * as utils from 'ee/oncall_schedules/components/schedule/utils';
import * as commonUtils from 'ee/oncall_schedules/utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants';
import mockTimezones from './mocks/mockTimezones.json';
describe('On-call schedule', () => {
let wrapper;
const lastTz = mockTimezones[mockTimezones.length - 1];
const mockSchedule = {
description: 'monitor description',
iid: '3',
name: 'monitor schedule',
timezone: lastTz.identifier,
};
const mockWeeksTimeFrame = ['31 Dec 2020', '7 Jan 2021', '14 Jan 2021'];
const formattedTimezone = '(UTC-09:00) AKST Alaska';
function mountComponent({ schedule } = {}) {
wrapper = shallowMount(OnCallSchedule, {
propsData: {
schedule,
},
provide: {
timezones: mockTimezones,
},
stubs: {
GlCard,
GlSprintf,
},
});
}
beforeEach(() => {
jest.spyOn(utils, 'getTimeframeForWeeksView').mockReturnValue(mockWeeksTimeFrame);
jest.spyOn(commonUtils, 'getFormattedTimezone').mockReturnValue(formattedTimezone);
mountComponent({ schedule: mockSchedule });
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findCardHeader = () => wrapper.find('.gl-card-header');
const findCardDescription = () => wrapper.find('.gl-card-body');
const findScheduleTimeline = () => findCardDescription().find(ScheduleTimelineSection);
it('shows schedule title', () => {
expect(findCardHeader().text()).toBe(mockSchedule.name);
});
it('shows timezone info', () => {
const shortTz = i18n.scheduleForTz.replace('%{tzShort}', lastTz.identifier);
const longTz = formattedTimezone;
const description = findCardDescription().text();
expect(description).toContain(shortTz);
expect(description).toContain(longTz);
});
it('renders ScheduleShell', () => {
const timeline = findScheduleTimeline();
expect(timeline.exists()).toBe(true);
expect(timeline.props()).toEqual({
presetType: PRESET_TYPES.WEEKS,
timeframe: mockWeeksTimeFrame,
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import OnCallScheduleWrapper, { import OnCallScheduleWrapper, {
i18n, i18n,
} from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue'; } from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue';
import OnCallSchedule from 'ee/oncall_schedules/components/oncall_schedule.vue';
describe('AlertManagementEmptyState', () => { describe('On-call schedule wrapper', () => {
let wrapper; let wrapper;
const emptyOncallSchedulesSvgPath = 'illustration/path.svg'; const emptyOncallSchedulesSvgPath = 'illustration/path.svg';
const projectPath = 'group/project';
function mountComponent({ loading, schedule } = {}) {
const $apollo = {
queries: {
schedule: {
loading,
},
},
};
function mountComponent() {
wrapper = shallowMount(OnCallScheduleWrapper, { wrapper = shallowMount(OnCallScheduleWrapper, {
data() {
return {
schedule,
};
},
provide: { provide: {
emptyOncallSchedulesSvgPath, emptyOncallSchedulesSvgPath,
projectPath,
}, },
mocks: { $apollo },
}); });
} }
beforeEach(() => {
mountComponent();
});
afterEach(() => { afterEach(() => {
if (wrapper) { wrapper.destroy();
wrapper.destroy(); wrapper = null;
wrapper = null;
}
}); });
const findLoader = () => wrapper.find(GlLoadingIcon);
const findEmptyState = () => wrapper.find(GlEmptyState); const findEmptyState = () => wrapper.find(GlEmptyState);
const findSchedule = () => wrapper.find(OnCallSchedule);
describe('Empty state', () => { it('shows a loader while data is requested', () => {
it('shows empty state and passed correct attributes to it', () => { mountComponent({ loading: true });
expect(findEmptyState().exists()).toBe(true); expect(findLoader().exists()).toBe(true);
expect(findEmptyState().attributes('title')).toBe(i18n.emptyState.title); });
expect(findEmptyState().attributes('description')).toBe(i18n.emptyState.description);
expect(findEmptyState().attributes('svgpath')).toBe(emptyOncallSchedulesSvgPath); it('shows empty state and passed correct attributes to it when not loading and no schedule', () => {
mountComponent({ loading: false, schedule: null });
const emptyState = findEmptyState();
expect(emptyState.exists()).toBe(true);
expect(emptyState.attributes()).toEqual({
title: i18n.emptyState.title,
svgpath: emptyOncallSchedulesSvgPath,
description: i18n.emptyState.description,
}); });
}); });
it('renders On-call schedule when data received ', () => {
mountComponent({ loading: false, schedule: { name: 'monitor rotation' } });
const schedule = findSchedule();
expect(findLoader().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
expect(schedule.exists()).toBe(true);
});
}); });
import { shallowMount } from '@vue/test-utils';
import WeeksHeaderItemComponent from 'ee/oncall_schedules/components/schedule/components/preset_weeks/weeks_header_item.vue';
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
describe('WeeksHeaderItemComponent', () => {
let wrapper;
const mockTimeframeIndex = 0;
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
function mountComponent({
timeframeIndex = mockTimeframeIndex,
timeframeItem = mockTimeframeWeeks[mockTimeframeIndex],
timeframe = mockTimeframeWeeks,
}) {
wrapper = shallowMount(WeeksHeaderItemComponent, {
propsData: {
timeframeIndex,
timeframeItem,
timeframe,
},
});
}
beforeEach(() => {
mountComponent({});
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('data', () => {
it('returns default data props', () => {
const currentDate = new Date();
expect(wrapper.vm.currentDate.getDate()).toBe(currentDate.getDate());
});
});
describe('computed', () => {
describe('lastDayOfCurrentWeek', () => {
it('returns date object representing last day of the week as set in `timeframeItem`', () => {
expect(wrapper.vm.lastDayOfCurrentWeek.getDate()).toBe(
mockTimeframeWeeks[mockTimeframeIndex].getDate() + 7,
);
});
});
describe('timelineHeaderLabel', () => {
it('returns string containing Year, Month and Date for the first timeframe item in the entire timeframe', () => {
expect(wrapper.vm.timelineHeaderLabel).toBe('2018 Jan 1');
});
it('returns string containing Year, Month and Date for timeframe item when it is first week of the year', () => {
mountComponent({
timeframeIndex: 3,
timeframeItem: new Date(2019, 0, 6),
});
expect(wrapper.vm.timelineHeaderLabel).toBe('2019 Jan 6');
});
it('returns string containing only Month and Date timeframe item when it is somewhere in the middle of timeframe', () => {
mountComponent({
timeframeIndex: mockTimeframeIndex + 1,
timeframeItem: mockTimeframeWeeks[mockTimeframeIndex + 1],
});
expect(wrapper.vm.timelineHeaderLabel).toBe('Jan 8');
});
});
describe('timelineHeaderClass', () => {
it('returns empty string when timeframeItem week is less than current week', () => {
expect(wrapper.vm.timelineHeaderClass).toBe('');
});
it('returns string containing `label-dark label-bold` when current week is same as timeframeItem week', () => {
wrapper.setData({ currentDate: mockTimeframeWeeks[mockTimeframeIndex] });
expect(wrapper.vm.timelineHeaderClass).toBe('label-dark label-bold');
});
});
});
describe('template', () => {
it('renders component container element with class `timeline-header-item`', () => {
expect(wrapper.classes()).toContain('timeline-header-item');
});
it('renders item label element class `item-label` and value as `timelineHeaderLabel`', () => {
expect(wrapper.find('.item-label').text()).toBe('2018 Jan 1');
});
});
});
import { shallowMount } from '@vue/test-utils';
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 { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants';
describe('MonthsHeaderSubItemComponent', () => {
let wrapper;
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
function mountComponent({
currentDate = mockTimeframeWeeks[0],
timeframeItem = mockTimeframeWeeks[0],
}) {
wrapper = shallowMount(WeeksHeaderSubItemComponent, {
propsData: {
currentDate,
timeframeItem,
},
});
}
beforeEach(() => {
mountComponent({});
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('data', () => {
it('initializes `presetType` and `indicatorStyles` data props', () => {
expect(wrapper.vm.presetType).toBe(PRESET_TYPES.WEEKS);
expect(wrapper.vm.indicatorStyle).toBeDefined();
});
});
describe('computed', () => {
describe('headerSubItems', () => {
it('returns `headerSubItems` array of dates containing days of week from timeframeItem', () => {
expect(wrapper.vm.headerSubItems).toBeInstanceOf(Array);
expect(wrapper.vm.headerSubItems).toHaveLength(7);
wrapper.vm.headerSubItems.forEach(subItem => {
expect(subItem).toBeInstanceOf(Date);
});
});
});
});
describe('methods', () => {
describe('getSubItemValueClass', () => {
it('returns string containing `label-dark` when provided subItem is greater than current week day', () => {
mountComponent({
currentDate: new Date(2018, 0, 1), // Jan 1, 2018
});
const subItem = new Date(2018, 0, 25); // Jan 25, 2018
expect(wrapper.vm.getSubItemValueClass(subItem)).toBe('label-dark');
});
it('returns string containing `label-dark label-bold` when provided subItem is same as current week day', () => {
const currentDate = new Date(2018, 0, 25);
mountComponent({
currentDate,
});
const subItem = currentDate;
expect(wrapper.vm.getSubItemValueClass(subItem)).toBe('label-dark label-bold');
});
});
});
describe('template', () => {
it('renders component container element with class `item-sublabel`', () => {
expect(wrapper.classes()).toContain('item-sublabel');
});
it('renders sub item element with class `sublabel-value`', () => {
expect(wrapper.find('.sublabel-value').exists()).toBe(true);
});
it('renders element with class `current-day-indicator-header` when hasToday is true', () => {
expect(wrapper.find('.current-day-indicator-header.preset-weeks').exists()).toBe(true);
});
});
});
import { shallowMount } from '@vue/test-utils';
import ScheduleTimelineSection from 'ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue';
import WeeksHeaderItem from 'ee/oncall_schedules/components/schedule/components/preset_weeks/weeks_header_item.vue';
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants';
describe('RoadmapTimelineSectionComponent', () => {
let wrapper;
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
function mountComponent({
presetType = PRESET_TYPES.WEEKS,
timeframe = mockTimeframeWeeks,
} = {}) {
wrapper = shallowMount(ScheduleTimelineSection, {
propsData: {
presetType,
timeframe,
},
});
}
beforeEach(() => {
mountComponent({});
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
it('renders component container element with class `timeline-section`', () => {
expect(wrapper.classes()).toContain('timeline-section');
});
it('renders empty header cell element with class `timeline-header-blank`', () => {
expect(wrapper.find('.timeline-header-blank').exists()).toBe(true);
});
it('renders weeks header items based on timeframe data', () => {
expect(wrapper.findAll(WeeksHeaderItem).length).toBe(mockTimeframeWeeks.length);
});
});
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
describe('getTimeframeForWeeksView', () => {
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const timeframe = getTimeframeForWeeksView(mockTimeframeInitialDate);
it('returns timeframe with total of 2 weeks', () => {
expect(timeframe).toHaveLength(2);
});
it('first timeframe item refers to the start date', () => {
const timeframeItem = timeframe[0];
const expectedMonth = {
year: 2018,
month: 0,
date: 1,
};
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
expect(timeframeItem.getMonth()).toBe(expectedMonth.month);
expect(timeframeItem.getDate()).toBe(expectedMonth.date);
});
it('second timeframe item refers to first date of the next week week ', () => {
const timeframeItem = timeframe[timeframe.length - 1];
const expectedMonth = {
year: 2018,
month: 0,
date: 8,
};
expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
expect(timeframeItem.getMonth()).toBe(expectedMonth.month);
expect(timeframeItem.getDate()).toBe(expectedMonth.date);
});
});
import { getFormattedTimezone } from 'ee/oncall_schedules/utils';
import mockTimezones from './mocks/mockTimezones.json';
describe('getFormattedTimezone', () => {
it('formats the timezone', () => {
const tz = mockTimezones[0];
const expectedValue = `(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
expect(getFormattedTimezone(tz)).toBe(expectedValue);
});
});
...@@ -941,6 +941,9 @@ msgstr "" ...@@ -941,6 +941,9 @@ msgstr ""
msgid "(No changes)" msgid "(No changes)"
msgstr "" msgstr ""
msgid "(UTC%{offset}) %{timezone}"
msgstr ""
msgid "(check progress)" msgid "(check progress)"
msgstr "" msgstr ""
...@@ -19112,6 +19115,12 @@ msgstr "" ...@@ -19112,6 +19115,12 @@ msgstr ""
msgid "OnCallSchedules|Failed to add schedule" msgid "OnCallSchedules|Failed to add schedule"
msgstr "" msgstr ""
msgid "OnCallSchedules|On-call schedule"
msgstr ""
msgid "OnCallSchedules|On-call schedule for the %{tzShort}"
msgstr ""
msgid "OnCallSchedules|Rotation length" msgid "OnCallSchedules|Rotation length"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment