Commit 0e71ebdc authored by David O'Regan's avatar David O'Regan Committed by Olena Horal-Koretska

Feat(oncallschedule): add hour view to grid

Add hour and week view change
for oncall schedules
timeline grid
parent e09c35bb
......@@ -4,7 +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;
const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
const MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR;
window.timeago = timeago;
......@@ -859,17 +860,17 @@ export const format24HourTimeStringFromInt = (time) => {
*
* @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 }
* @returns {Object} { daysOverlap: number of days the overlap is present, hoursOverlap: number of hours 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(
* getOverlapDateInPeriods(
* { 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 }
* ) => { daysOverlap: 2, hoursOverlap: 48, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 }
*
*/
export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => {
export const getOverlapDateInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => {
const leftStartTime = new Date(givenPeriodLeft.start).getTime();
const leftEndTime = new Date(givenPeriodLeft.end).getTime();
const rightStartTime = new Date(givenPeriodRight.start).getTime();
......@@ -890,6 +891,7 @@ export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRig
const differenceInMs = overlapEndDate - overlapStartDate;
return {
hoursOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_HOUR),
daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY),
overlapStartDate,
overlapEndDate,
......
......@@ -85,9 +85,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
}
.timeline-header-item {
// container size minus left panel width divided by 2 week timeframes
width: calc((100% - #{$details-cell-width}) / 2);
&:last-of-type .item-label {
@include gl-border-r-0;
}
......@@ -168,9 +165,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
.timeline-cell {
@include gl-relative;
// width: $timeline-cell-width;
// container size minus left panel width divided by 2 week timeframes
width: calc((100% - #{$details-cell-width}) / 2);
@include gl-bg-transparent;
border-right: $border-style;
......
......@@ -4,9 +4,12 @@ import {
GlCard,
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownItem,
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
import { capitalize } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
import { s__, __ } from '~/locale';
import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue';
......@@ -33,17 +36,19 @@ export default {
editRotationModalId,
editScheduleModalId,
deleteScheduleModalId,
presetType: PRESET_TYPES.WEEKS,
PRESET_TYPES,
components: {
GlSprintf,
GlCard,
ScheduleTimelineSection,
GlButtonGroup,
GlButton,
GlButtonGroup,
GlCard,
GlDropdown,
GlDropdownItem,
GlSprintf,
AddEditRotationModal,
DeleteScheduleModal,
EditScheduleModal,
AddEditRotationModal,
RotationsListSection,
ScheduleTimelineSection,
},
directives: {
GlModal: GlModalDirective,
......@@ -61,6 +66,11 @@ export default {
default: () => [],
},
},
data() {
return {
presetType: this.$options.PRESET_TYPES.WEEKS,
};
},
computed: {
offset() {
const selectedTz = this.timezones.find((tz) => tz.identifier === this.schedule.timezone);
......@@ -70,11 +80,23 @@ export default {
return getTimeframeForWeeksView();
},
scheduleRange() {
const range = { start: this.timeframe[0], end: this.timeframe[this.timeframe.length - 1] };
const end =
this.presetType === this.$options.PRESET_TYPES.DAYS
? this.timeframe[0]
: this.timeframe[this.timeframe.length - 1];
const range = { start: this.timeframe[0], end };
return `${formatDate(range.start, 'mmmm d')} - ${formatDate(range.end, 'mmmm d, yyyy')}`;
},
},
methods: {
setPresetType(type) {
this.presetType = type;
},
formatPresetType(type) {
return capitalize(type);
},
},
};
</script>
......@@ -105,11 +127,24 @@ export default {
</gl-button-group>
</div>
</template>
<p class="gl-text-gray-500 gl-mb-3" data-testid="scheduleBody">
<p
class="gl-text-gray-500 gl-mb-3 gl-display-flex gl-justify-content-space-between gl-align-items-center"
data-testid="scheduleBody"
>
<gl-sprintf :message="$options.i18n.scheduleForTz">
<template #timezone>{{ schedule.timezone }}</template>
</gl-sprintf>
| {{ offset }}
<gl-dropdown right :text="formatPresetType(presetType)">
<gl-dropdown-item
v-for="type in $options.PRESET_TYPES"
:key="type"
:is-check-item="true"
:is-checked="type === presetType"
@click="setPresetType(type)"
>{{ formatPresetType(type) }}</gl-dropdown-item
>
</gl-dropdown>
</p>
<div class="gl-w-full gl-display-flex gl-align-items-center gl-pb-3">
<gl-button-group>
......@@ -133,9 +168,9 @@ export default {
</template>
<div class="schedule-shell" data-testid="rotationsBody">
<schedule-timeline-section :preset-type="$options.presetType" :timeframe="timeframe" />
<schedule-timeline-section :preset-type="presetType" :timeframe="timeframe" />
<rotations-list-section
:preset-type="$options.presetType"
:preset-type="presetType"
:rotations="rotations"
:timeframe="timeframe"
/>
......
......@@ -5,9 +5,9 @@ import { __, sprintf } from '~/locale';
export default {
components: {
GlToken,
GlAvatarLabeled,
GlPopover,
GlToken,
},
props: {
assignee: {
......@@ -33,12 +33,12 @@ export default {
},
startsAt() {
return sprintf(__('Starts: %{startsAt}'), {
startsAt: formatDate(this.rotationAssigneeStartsAt, 'mmmm d, yyyy, hh:mm'),
startsAt: formatDate(this.rotationAssigneeStartsAt, 'mmmm d, yyyy, h:MMtt Z'),
});
},
endsAt() {
return sprintf(__('Ends: %{endsAt}'), {
endsAt: formatDate(this.rotationAssigneeEndsAt, 'mmmm d, yyyy, hh:mm'),
endsAt: formatDate(this.rotationAssigneeEndsAt, 'mmmm d, yyyy, h:MMtt Z'),
});
},
},
......
......@@ -19,7 +19,7 @@ export default {
<template>
<span
v-if="hasToday"
:style="getIndicatorStyles()"
:style="getIndicatorStyles(presetType)"
data-testid="current-day-indicator"
class="current-day-indicator"
></span>
......
<script>
import { PRESET_TYPES, TIMELINE_CELL_WIDTH } from 'ee/oncall_schedules/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import DaysHeaderSubItem from './days_header_sub_item.vue';
export default {
PRESET_TYPES,
components: {
DaysHeaderSubItem,
},
props: {
timeframeItem: {
type: Date,
required: true,
},
},
computed: {
timelineHeaderLabel() {
return formatDate(this.timeframeItem, 'mmmm d, yyyy');
},
timelineHeaderStyles() {
return {
width: `calc(${100}% - ${TIMELINE_CELL_WIDTH}px)`,
};
},
},
};
</script>
<template>
<span class="timeline-header-item" :style="timelineHeaderStyles">
<div class="item-label gl-pl-6 gl-py-4" data-testid="timeline-header-label">
{{ timelineHeaderLabel }}
</div>
<days-header-sub-item />
</span>
</template>
<script>
import { PRESET_TYPES, HOURS_IN_DAY } from 'ee/oncall_schedules/constants';
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 {
PRESET_TYPES,
HOURS_IN_DAY,
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [CommonMixin],
mounted() {
this.updateShiftStyles();
},
methods: {
updateShiftStyles() {
this.$apollo.mutate({
mutation: updateShiftTimeUnitWidthMutation,
variables: {
shiftTimeUnitWidth: this.$refs.dailyHourCell[0].offsetWidth,
},
});
},
},
};
</script>
<template>
<div
v-gl-resize-observer="updateShiftStyles"
class="item-sublabel"
data-testid="day-item-sublabel"
>
<span
v-for="hour in $options.HOURS_IN_DAY"
:key="hour"
ref="dailyHourCell"
class="sublabel-value"
data-testid="sublabel-value"
>{{ hour }}</span
>
<span
:style="getIndicatorStyles($options.PRESET_TYPES.DAYS)"
class="current-day-indicator-header preset-days"
data-testid="day-item-sublabel-current-indicator"
></span>
</div>
</template>
<script>
import { TIMELINE_CELL_WIDTH } from 'ee/oncall_schedules/constants';
import CommonMixin from 'ee/oncall_schedules/mixins/common_mixin';
import { monthInWords } from '~/lib/utils/datetime_utility';
import WeeksHeaderSubItem from './weeks_header_sub_item.vue';
import CommonMixin from '../../../../mixins/common_mixin';
export default {
components: {
......@@ -52,12 +53,17 @@ export default {
return '';
},
timelineHeaderStyles() {
return {
width: `calc((${100}% - ${TIMELINE_CELL_WIDTH}px) / ${2})`,
};
},
},
};
</script>
<template>
<span class="timeline-header-item">
<span class="timeline-header-item" :style="timelineHeaderStyles">
<div
:class="timelineHeaderClass"
class="item-label gl-pl-6 gl-py-4"
......
<script>
import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
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 {
PRESET_TYPES,
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
......@@ -44,6 +46,9 @@ export default {
}
return '';
},
getSubItemValue(subItem) {
return subItem.getDate();
},
updateShiftStyles() {
this.$apollo.mutate({
mutation: updateShiftTimeUnitWidthMutation,
......@@ -69,11 +74,11 @@ export default {
:class="getSubItemValueClass(subItem)"
class="sublabel-value"
data-testid="sublabel-value"
>{{ subItem.getDate() }}</span
>{{ getSubItemValue(subItem) }}</span
>
<span
v-if="hasToday"
:style="getIndicatorStyles()"
:style="getIndicatorStyles($options.PRESET_TYPES.WEEKS)"
class="current-day-indicator-header preset-weeks"
></span>
</div>
......
<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 ScheduleShiftWrapper from 'ee/oncall_schedules/components/schedule/components/shifts/components/schedule_shift_wrapper.vue';
import {
editRotationModalId,
deleteRotationModalId,
PRESET_TYPES,
TIMELINE_CELL_WIDTH,
} from 'ee/oncall_schedules/constants';
import { s__ } from '~/locale';
import CurrentDayIndicator from './current_day_indicator.vue';
import ScheduleShift from './schedule_shift.vue';
export const i18n = {
editRotationLabel: s__('OnCallSchedules|Edit rotation'),
......@@ -16,11 +21,11 @@ export default {
editRotationModalId,
deleteRotationModalId,
components: {
GlButtonGroup,
GlButton,
GlButtonGroup,
CurrentDayIndicator,
DeleteRotationModal,
ScheduleShift,
ScheduleShiftWrapper,
},
directives: {
GlModal: GlModalDirective,
......@@ -43,15 +48,33 @@ export default {
data() {
return {
rotationToUpdate: {},
shiftWidths: 0,
};
},
computed: {
presetIsDay() {
return this.presetType === PRESET_TYPES.DAYS;
},
timeframeToDraw() {
if (this.presetIsDay) {
return [this.timeframe[0]];
}
return this.timeframe;
},
timelineStyles() {
const length = this.presetIsDay ? 1 : 2;
return {
width: `calc((${100}% - ${TIMELINE_CELL_WIDTH}px) / ${length})`,
};
},
},
methods: {
setRotationToUpdate(rotation) {
this.rotationToUpdate = rotation;
},
isLastCell(index) {
return index + 1 === this.timeframe.length;
cellShouldHideOverflow(index) {
return index + 1 === this.timeframe.length || this.presetIsDay;
},
},
};
......@@ -89,21 +112,19 @@ export default {
</gl-button-group>
</span>
<span
v-for="(timeframeItem, index) in timeframe"
v-for="(timeframeItem, index) in timeframeToDraw"
:key="index"
class="timeline-cell gl-border-b-solid gl-border-b-gray-100 gl-border-b-1"
:class="{ 'gl-overflow-hidden': isLastCell(index) }"
:class="{ 'gl-overflow-hidden': cellShouldHideOverflow(index) }"
:style="timelineStyles"
data-testid="timelineCell"
>
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
<schedule-shift
v-for="(shift, shiftIndex) in rotation.shifts.nodes"
:key="shift.startAt"
:shift="shift"
:shift-index="shiftIndex"
<schedule-shift-wrapper
:preset-type="presetType"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
:rotation="rotation"
/>
</span>
</div>
......
<script>
import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
import DaysHeaderItem from './preset_days/days_header_item.vue';
import WeeksHeaderItem from './preset_weeks/weeks_header_item.vue';
export default {
PRESET_TYPES,
components: {
DaysHeaderItem,
WeeksHeaderItem,
},
props: {
......@@ -15,18 +19,28 @@ export default {
required: true,
},
},
computed: {
presetIsDay() {
return this.presetType === this.$options.PRESET_TYPES.DAYS;
},
},
};
</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>
<days-header-item v-if="presetIsDay" :timeframe-item="timeframe[0]" />
<weeks-header-item
v-for="(timeframeItem, index) in timeframe"
v-else
:key="index"
:timeframe-index="index"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
:preset-type="presetType"
/>
</div>
</div>
</template>
<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 } 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,
},
shiftTimeUnitWidth: {
type: Number,
required: true,
},
},
computed: {
currentTimeframeEndsAt() {
return incrementDateByDays(this.timeframeItem, 1);
},
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`,
};
},
shiftStartsAt() {
return new Date(this.shift.startsAt);
},
shiftEndsAt() {
return new Date(this.shift.endsAt);
},
shiftRangeOverlap() {
try {
return getOverlapDateInPeriods(
{ start: this.timeframeItem, end: this.currentTimeframeEndsAt },
{ start: this.shiftStartsAt, end: this.shiftEndsAt },
);
} catch (error) {
return { hoursOverlap: 0 };
}
},
shiftStartHourOutOfRange() {
return this.shiftStartsAt.getTime() < this.timeframeItem.getTime();
},
shiftShouldRender() {
return Boolean(
this.shiftRangeOverlap.hoursOverlap &&
!(this.shiftStartsAt.getDate() > this.timeframeItem.getDate()),
);
},
},
};
</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>
<script>
import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
import getShiftTimeUnitWidthQuery from 'ee/oncall_schedules/graphql/queries/get_shift_time_unit_width.query.graphql';
import DaysScheduleShift from './days_schedule_shift.vue';
import WeeksScheduleShift from './weeks_schedule_shift.vue';
export default {
components: {
DaysScheduleShift,
WeeksScheduleShift,
},
props: {
timeframeItem: {
type: [Date, Object],
required: true,
},
timeframe: {
type: Array,
required: true,
},
presetType: {
type: String,
required: true,
},
rotation: {
type: Object,
required: true,
},
},
data() {
return {
shiftTimeUnitWidth: 0,
componentByPreset: {
[PRESET_TYPES.DAYS]: DaysScheduleShift,
[PRESET_TYPES.WEEKS]: WeeksScheduleShift,
},
};
},
apollo: {
shiftTimeUnitWidth: {
query: getShiftTimeUnitWidthQuery,
},
},
};
</script>
<template>
<div>
<component
:is="componentByPreset[presetType]"
v-for="(shift, shiftIndex) in rotation.shifts.nodes"
:key="shift.startAt"
:shift="shift"
:shift-index="shiftIndex"
:preset-type="presetType"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
:shift-time-unit-width="shiftTimeUnitWidth"
/>
</div>
</template>
<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';
import { DAYS_IN_WEEK, DAYS_IN_DATE_WEEK, ASSIGNEE_SPACER } from 'ee/oncall_schedules/constants';
import { getOverlapDateInPeriods } from '~/lib/utils/datetime_utility';
import { incrementDateByDays } from '../../../utils';
export default {
components: {
......@@ -35,25 +29,14 @@ export default {
type: String,
required: true,
},
},
data() {
return {
shiftTimeUnitWidth: 0,
};
},
apollo: {
shiftTimeUnitWidth: {
query: getShiftTimeUnitWidthQuery,
type: Number,
required: true,
},
},
computed: {
currentTimeframeEndsAt() {
let UnitOfIncrement = 0;
if (this.presetType === PRESET_TYPES.WEEKS) {
UnitOfIncrement = DAYS_IN_DATE_WEEK;
}
return incrementDateByDays(this.timeframeItem, UnitOfIncrement);
return incrementDateByDays(this.timeframeItem, DAYS_IN_DATE_WEEK);
},
daysUntilEndOfTimeFrame() {
return (
......@@ -101,12 +84,11 @@ export default {
},
shiftRangeOverlap() {
try {
return getOverlappingDaysInPeriods(
return getOverlapDateInPeriods(
{ 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 };
}
},
......@@ -126,7 +108,7 @@ export default {
return this.timeframe[this.timeframe.length - 1];
},
totalShiftRangeOverlap() {
return getOverlappingDaysInPeriods(
return getOverlapDateInPeriods(
{
start: this.timeframeItem,
end: incrementDateByDays(this.timeFrameEndsAt, DAYS_IN_DATE_WEEK),
......
......@@ -12,6 +12,7 @@ export const DAYS_IN_WEEK = 7;
export const HOURS_IN_DAY = 24;
export const PRESET_TYPES = {
DAYS: 'DAYS',
WEEKS: 'WEEKS',
};
......@@ -30,3 +31,4 @@ export const deleteRotationModalId = 'deleteRotationModal';
*/
export const DAYS_IN_DATE_WEEK = 6;
export const ASSIGNEE_SPACER = 2;
export const TIMELINE_CELL_WIDTH = 180;
import { DAYS_IN_WEEK } from '../constants';
import { DAYS_IN_WEEK, HOURS_IN_DAY, PRESET_TYPES } from '../constants';
export default {
currentDate: null,
......@@ -29,12 +29,19 @@ export default {
this.$options.currentDate = currentDate;
},
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;
getIndicatorStyles(presetType = PRESET_TYPES.WEEKS) {
if (presetType === PRESET_TYPES.DAYS) {
const currentDate = new Date();
const base = 100 / HOURS_IN_DAY;
const hours = base * currentDate.getHours();
const minutes = base * (currentDate.getMinutes() / 60) - 2.25;
return {
left: `${hours + minutes}%`,
};
}
const left = 100 / DAYS_IN_WEEK / 2;
return {
left: `${left}%`,
};
......
......@@ -16,7 +16,7 @@ describe('RotationAssignee', () => {
const findEndsAt = () => wrapper.findByTestId('rotation-assignee-ends-at');
const formattedDate = (date) => {
return formatDate(date, 'mmmm d, yyyy, hh:mm');
return formatDate(date, 'mmmm d, yyyy, h:MMtt Z');
};
function createComponent() {
......
......@@ -74,146 +74,148 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
style="left: 7.142857142857143%;"
/>
<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"
<div>
<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-token-content"
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"
>
<div
class="gl-avatar-labeled"
shape="circle"
size="16"
title="nora.schaden"
<span
class="gl-token-content"
>
<div
class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1"
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>
<div
class="gl-display-flex gl-flex-wrap gl-align-items-center gl-text-left! gl-mx-n1 gl-my-n1"
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-label"
class="gl-avatar-labeled-sublabel"
>
nora.schaden
</span>
</div>
<span
class="gl-avatar-labeled-sublabel"
>
</span>
</div>
</div>
<!---->
<!---->
</span>
</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"
<div
class="gl-popover"
title="nora.schaden"
>
Ends: January 15, 2021, 10:01
</p>
<p
class="gl-m-0"
data-testid="rotation-assignee-starts-at"
>
Starts: January 12, 2021, 10:04am GMT+0000
</p>
<p
class="gl-m-0"
data-testid="rotation-assignee-ends-at"
>
Ends: January 15, 2021, 10:04am GMT+0000
</p>
</div>
</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"
<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-token-content"
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"
>
<div
class="gl-avatar-labeled"
shape="circle"
size="16"
title="racheal.loving"
<span
class="gl-token-content"
>
<div
class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s16 gl-avatar-identicon-bg1"
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>
<div
class="gl-display-flex gl-flex-wrap gl-align-items-center gl-text-left! gl-mx-n1 gl-my-n1"
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-label"
class="gl-avatar-labeled-sublabel"
>
racheal.loving
</span>
</div>
<span
class="gl-avatar-labeled-sublabel"
>
</span>
</div>
</div>
<!---->
<!---->
</span>
</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"
<div
class="gl-popover"
title="racheal.loving"
>
Ends: January 18, 2021, 10:01
</p>
<p
class="gl-m-0"
data-testid="rotation-assignee-starts-at"
>
Starts: January 16, 2021, 10:04am GMT+0000
</p>
<p
class="gl-m-0"
data-testid="rotation-assignee-ends-at"
>
Ends: January 18, 2021, 10:04am GMT+0000
</p>
</div>
</div>
</div>
</span>
......@@ -223,8 +225,10 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
>
<!---->
<!---->
<!---->
<div>
<!---->
<!---->
</div>
</span>
</div>
......
import { shallowMount } from '@vue/test-utils';
import CurrentDayIndicator from 'ee/oncall_schedules/components/schedule/components/current_day_indicator.vue';
import { useFakeDate } from 'helpers/fake_date';
import { PRESET_TYPES, DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
import { PRESET_TYPES, DAYS_IN_WEEK, HOURS_IN_DAY } from 'ee/oncall_schedules/constants';
describe('CurrentDayIndicator', () => {
let wrapper;
......@@ -12,11 +12,12 @@ describe('CurrentDayIndicator', () => {
// current indicator will be rendered
const mockTimeframeInitialDate = new Date(2018, 0, 1);
function createComponent() {
function createComponent({
props = { presetType: PRESET_TYPES.WEEKS, timeframeItem: mockTimeframeInitialDate },
} = {}) {
wrapper = shallowMount(CurrentDayIndicator, {
propsData: {
presetType: PRESET_TYPES.WEEKS,
timeframeItem: mockTimeframeInitialDate,
...props,
},
});
}
......@@ -35,8 +36,20 @@ describe('CurrentDayIndicator', () => {
expect(wrapper.classes('current-day-indicator')).toBe(true);
});
it('sets correct styles', async () => {
it('sets correct styles for a week', async () => {
const left = 100 / DAYS_IN_WEEK / 2;
expect(wrapper.attributes('style')).toBe(`left: ${left}%;`);
});
it('sets correct styles for a day', async () => {
createComponent({
props: { presetType: PRESET_TYPES.DAYS, timeframeItem: new Date(2018, 0, 3) },
});
const currentDate = new Date();
const base = 100 / HOURS_IN_DAY;
const hours = base * currentDate.getHours();
const minutes = base * (currentDate.getMinutes() / 60) - 2.25;
const left = hours + minutes;
expect(wrapper.attributes('style')).toBe(`left: ${left}%;`);
});
});
import { shallowMount } from '@vue/test-utils';
import DaysHeaderItem from 'ee/oncall_schedules/components/schedule/components/preset_days/days_header_item.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { useFakeDate } from 'helpers/fake_date';
describe('ee/oncall_schedules/components/schedule/components/preset_days/days_header_item.vue', () => {
let wrapper;
// January 3rd, 2018 - current date (faked)
useFakeDate(2018, 0, 3);
const mockTimeframeInitialDate = new Date(2018, 0, 1);
function mountComponent({ timeframeItem = mockTimeframeInitialDate } = {}) {
wrapper = extendedWrapper(
shallowMount(DaysHeaderItem, {
propsData: {
timeframeItem,
},
}),
);
}
beforeEach(() => {
mountComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
});
const findHeaderLabel = () => wrapper.findByTestId('timeline-header-label');
describe('timelineHeaderLabel', () => {
it('returns string containing Year, Month and Date for the current timeframe item', () => {
expect(findHeaderLabel().text()).toBe('January 1, 2018');
});
});
});
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import DaysHeaderSubItem from 'ee/oncall_schedules/components/schedule/components/preset_days/days_header_sub_item.vue';
import updateShiftTimeUnitWidthMutation from 'ee/oncall_schedules/graphql/mutations/update_shift_time_unit_width.mutation.graphql';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('ee/oncall_schedules/components/schedule/components/preset_days/days_header_sub_item.vue', () => {
let wrapper;
function mountComponent() {
wrapper = extendedWrapper(
shallowMount(DaysHeaderSubItem, {
propsData: {},
directives: {
GlResizeObserver: createMockDirective(),
},
mocks: {
$apollo: {
mutate: jest.fn(),
},
},
}),
);
}
beforeEach(() => {
mountComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
});
const findDaysHeaderSubItem = () => wrapper.findByTestId('day-item-sublabel');
const findDaysHeaderCurrentIndicator = () =>
wrapper.findByTestId('day-item-sublabel-current-indicator');
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`', () => {
expect(findDaysHeaderCurrentIndicator().exists()).toBe(true);
});
});
describe('updateShiftStyles', () => {
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.dailyHourCell[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(findDaysHeaderSubItem().element, 'gl-resize-observer');
value();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2);
});
});
});
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 DaysHeaderItem from 'ee/oncall_schedules/components/schedule/components/preset_days/days_header_item.vue';
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
import { getOncallSchedulesQueryResponse } from '../../mocks/apollo_mock';
......@@ -13,14 +14,12 @@ describe('TimelineSectionComponent', () => {
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
function createComponent({
presetType = PRESET_TYPES.WEEKS,
timeframe = mockTimeframeWeeks,
props = { presetType: PRESET_TYPES.WEEKS, timeframe: mockTimeframeWeeks },
} = {}) {
wrapper = shallowMount(ScheduleTimelineSection, {
propsData: {
presetType,
timeframe,
schedule,
...props,
},
});
}
......@@ -42,6 +41,11 @@ describe('TimelineSectionComponent', () => {
});
it('renders weeks header items based on timeframe data', () => {
expect(wrapper.findAll(WeeksHeaderItem).length).toBe(mockTimeframeWeeks.length);
expect(wrapper.findAllComponents(WeeksHeaderItem)).toHaveLength(mockTimeframeWeeks.length);
});
it('renders days header items based on timeframe data', () => {
createComponent({ props: { presetType: PRESET_TYPES.DAYS, timeframe: mockTimeframeWeeks } });
expect(wrapper.findAllComponents(DaysHeaderItem)).toHaveLength(1);
});
});
import { shallowMount } from '@vue/test-utils';
import DaysScheduleShift from 'ee/oncall_schedules/components/schedule/components/shifts/components/days_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-15T00:04:56.333Z',
endsAt: '2021-01-15T04:22:56.333Z',
};
const CELL_WIDTH = 50;
const timeframeItem = new Date(2021, 0, 15);
const timeframe = [timeframeItem, incrementDateByDays(timeframeItem, DAYS_IN_WEEK)];
describe('ee/oncall_schedules/components/schedule/components/shifts/components/days_schedule_shift.vue', () => {
let wrapper;
function createComponent({ props = {} } = {}) {
wrapper = shallowMount(DaysScheduleShift, {
propsData: {
shift,
shiftIndex: 0,
timeframeItem,
timeframe,
presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH,
...props,
},
});
}
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 hours * CELL_WIDTH(5 * 50)
*/
createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '0px',
width: '250px',
});
});
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)
*/
createComponent({
props: {
shift: {
...shift,
startsAt: '2021-01-15T10:04:56.333Z',
endsAt: '2021-01-15T22:04:56.333Z',
},
},
data: { shiftTimeUnitWidth: CELL_WIDTH },
});
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '452px',
width: '650px',
});
});
});
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);
});
});
});
import { shallowMount } from '@vue/test-utils';
import ScheduleShiftWrapper from 'ee/oncall_schedules/components/schedule/components/shifts/components/schedule_shift_wrapper.vue';
import DaysScheduleShift from 'ee/oncall_schedules/components/schedule/components/shifts/components/days_schedule_shift.vue';
import WeeksScheduleShift from 'ee/oncall_schedules/components/schedule/components/shifts/components/weeks_schedule_shift.vue';
import { PRESET_TYPES, DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
import { incrementDateByDays } from 'ee/oncall_schedules/components/schedule/utils';
import mockRotations from '../../../../mocks/mock_rotation.json';
const timeframeItem = new Date(2021, 0, 13);
const timeframe = [timeframeItem, incrementDateByDays(timeframeItem, DAYS_IN_WEEK)];
describe('ee/oncall_schedules/components/schedule/components/shifts/components/schedule_shift_wrapper.vue', () => {
let wrapper;
function createComponent({ props = { presetType: PRESET_TYPES.WEEKS }, data = {} } = {}) {
wrapper = shallowMount(ScheduleShiftWrapper, {
propsData: {
timeframeItem,
timeframe,
rotation: mockRotations[0],
...props,
},
data() {
return {
shiftTimeUnitWidth: 0,
...data,
};
},
mocks: {
$apollo: {
queries: {
shiftTimeUnitWidth: 0,
},
},
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findDaysScheduleShifts = () => wrapper.findAllComponents(DaysScheduleShift);
const findWeeksScheduleShifts = () => wrapper.findAllComponents(WeeksScheduleShift);
describe('when the preset type is WEEKS', () => {
it('should render a selection of week grid shifts inside the rotation', () => {
expect(findWeeksScheduleShifts()).toHaveLength(2);
});
});
describe('when the preset type is DAYS', () => {
it('should render a selection of day grid shifts inside the rotation', () => {
createComponent({ props: { presetType: PRESET_TYPES.DAYS } });
expect(findDaysScheduleShifts()).toHaveLength(2);
});
});
});
import { shallowMount } from '@vue/test-utils';
import ScheduleShift from 'ee/oncall_schedules/components/schedule/components/schedule_shift.vue';
import WeeksScheduleShift from 'ee/oncall_schedules/components/schedule/components/shifts/components/weeks_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';
......@@ -19,32 +19,20 @@ 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', () => {
describe('ee/oncall_schedules/components/schedule/components/shifts/components/weeks_schedule_shift.vue', () => {
let wrapper;
function createComponent({ props = {}, data = {} } = {}) {
wrapper = shallowMount(ScheduleShift, {
function createComponent({ props = {} } = {}) {
wrapper = shallowMount(WeeksScheduleShift, {
propsData: {
shift,
shiftIndex: 0,
timeframeItem,
timeframe,
presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH,
...props,
},
data() {
return {
shiftTimeUnitWidth: 0,
...data,
};
},
mocks: {
$apollo: {
queries: {
shiftTimeUnitWidth: 0,
},
},
},
});
}
......
......@@ -843,7 +843,7 @@ describe('format24HourTimeStringFromInt', () => {
});
});
describe('getOverlappingDaysInPeriods', () => {
describe('getOverlapDateInPeriods', () => {
const start = new Date(2021, 0, 11);
const end = new Date(2021, 0, 13);
......@@ -851,14 +851,15 @@ describe('getOverlappingDaysInPeriods', () => {
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', () => {
it('returns an overlap object that contains the amount of days overlapping, the amount of hours overlapping, start date of overlap and end date of overlap', () => {
expect(
datetimeUtility.getOverlappingDaysInPeriods(
datetimeUtility.getOverlapDateInPeriods(
{ start, end },
{ start: givenPeriodLeft, end: givenPeriodRight },
),
).toEqual({
daysOverlap: 2,
hoursOverlap: 48,
overlapStartDate: givenPeriodLeft.getTime(),
overlapEndDate: end.getTime(),
});
......@@ -871,7 +872,7 @@ describe('getOverlappingDaysInPeriods', () => {
it('returns an overlap object that contains a 0 value for days overlapping', () => {
expect(
datetimeUtility.getOverlappingDaysInPeriods(
datetimeUtility.getOverlapDateInPeriods(
{ start, end },
{ start: givenPeriodLeft, end: givenPeriodRight },
),
......@@ -886,13 +887,13 @@ describe('getOverlappingDaysInPeriods', () => {
it('throws an exception when the left period contains an invalid date', () => {
expect(() =>
datetimeUtility.getOverlappingDaysInPeriods({ start, end }, { start: startInvalid, end }),
datetimeUtility.getOverlapDateInPeriods({ 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 }),
datetimeUtility.getOverlapDateInPeriods({ 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