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

Add rotation assignee for schedule

Allow for GitLab Ui tokens
to be used inside the schedule
grid to draw assignees
parent 86a35180
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
//// Copied from roadmaps.scss - adapted for on-call schedules //// Copied from roadmaps.scss - adapted for on-call schedules
$header-item-height: 72px; $header-item-height: 72px;
$item-height: 40px; $item-height: 40px;
$details-cell-width: 150px; $details-cell-width: 180px;
$timeline-cell-height: 32px; $timeline-cell-height: 32px;
$timeline-cell-width: 180px; $timeline-cell-width: 180px;
$border-style: 1px solid var(--gray-100, $gray-100); $border-style: 1px solid var(--gray-100, $gray-100);
...@@ -98,7 +98,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi ...@@ -98,7 +98,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
.item-label { .item-label {
@include gl-py-4; @include gl-py-4;
@include gl-pl-7; @include gl-pl-4;
border-right: $border-style; border-right: $border-style;
border-bottom: $border-style; border-bottom: $border-style;
} }
...@@ -147,7 +147,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi ...@@ -147,7 +147,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
.timeline-cell { .timeline-cell {
@include float-left; @include float-left;
height: $item-height; height: $item-height;
border-bottom: $border-style;
} }
.details-cell { .details-cell {
...@@ -181,3 +180,10 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi ...@@ -181,3 +180,10 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
.gl-token {
.gl-avatar-labeled-label {
@include gl-text-white;
@include gl-font-weight-normal;
}
}
...@@ -7,19 +7,19 @@ import { ...@@ -7,19 +7,19 @@ import {
GlModalDirective, GlModalDirective,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__ } from '~/locale'; import { formatDate } from '~/lib/utils/datetime_utility';
import { s__, __ } from '~/locale';
import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue'; import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue';
import DeleteScheduleModal from './delete_schedule_modal.vue'; import DeleteScheduleModal from './delete_schedule_modal.vue';
import EditScheduleModal from './edit_schedule_modal.vue'; import EditScheduleModal from './edit_schedule_modal.vue';
import AddRotationModal from './rotations/add_rotation_modal.vue'; import AddRotationModal from './rotations/components/add_rotation_modal.vue';
import { getTimeframeForWeeksView } from './schedule/utils'; import { getTimeframeForWeeksView } from './schedule/utils';
import { PRESET_TYPES } from './schedule/constants'; import { PRESET_TYPES } from '../constants';
import { getFormattedTimezone } from '../utils/common_utils';
import RotationsListSection from './schedule/components/rotations_list_section.vue'; import RotationsListSection from './schedule/components/rotations_list_section.vue';
export const i18n = { export const i18n = {
scheduleForTz: s__('OnCallSchedules|On-call schedule for the %{tzShort}'), scheduleForTz: s__('OnCallSchedules|On-call schedule for the %{timezone}'),
editScheduleLabel: s__('OnCallSchedules|Edit schedule'), editScheduleLabel: s__('OnCallSchedules|Edit schedule'),
deleteScheduleLabel: s__('OnCallSchedules|Delete schedule'), deleteScheduleLabel: s__('OnCallSchedules|Delete schedule'),
rotationTitle: s__('OnCallSchedules|Rotations'), rotationTitle: s__('OnCallSchedules|Rotations'),
...@@ -64,23 +64,28 @@ export default { ...@@ -64,23 +64,28 @@ export default {
}, },
}, },
computed: { computed: {
tzLong() { offset() {
const selectedTz = this.timezones.find(tz => tz.identifier === this.schedule.timezone); const selectedTz = this.timezones.find(tz => tz.identifier === this.schedule.timezone);
return getFormattedTimezone(selectedTz); return __(`(UTC ${selectedTz.formatted_offset})`);
}, },
timeframe() { timeframe() {
return getTimeframeForWeeksView(); return getTimeframeForWeeksView();
}, },
scheduleRange() {
const range = { start: this.timeframe[0], end: this.timeframe[this.timeframe.length - 1] };
return `${formatDate(range.start, 'mmmm d')} - ${formatDate(range.end, 'mmmm d, yyyy')}`;
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-card> <gl-card class="gl-mt-5" header-class="gl-py-3">
<template #header> <template #header>
<div <div
class="gl-display-flex gl-justify-content-space-between gl-m-0" class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-m-0"
data-testid="scheduleHeader" data-testid="scheduleHeader"
> >
<span class="gl-font-weight-bold gl-font-lg">{{ schedule.name }}</span> <span class="gl-font-weight-bold gl-font-lg">{{ schedule.name }}</span>
...@@ -102,12 +107,19 @@ export default { ...@@ -102,12 +107,19 @@ export default {
</gl-button-group> </gl-button-group>
</div> </div>
</template> </template>
<p class="gl-text-gray-500 gl-mb-5" data-testid="scheduleBody"> <p class="gl-text-gray-500 gl-mb-3" data-testid="scheduleBody">
<gl-sprintf :message="$options.i18n.scheduleForTz"> <gl-sprintf :message="$options.i18n.scheduleForTz">
<template #tzShort>{{ schedule.timezone }}</template> <template #timezone>{{ schedule.timezone }}</template>
</gl-sprintf> </gl-sprintf>
| {{ tzLong }} | {{ offset }}
</p> </p>
<div class="gl-w-full gl-display-flex gl-align-items-center gl-pb-3">
<gl-button-group>
<gl-button icon="chevron-left" />
<gl-button icon="chevron-right" />
</gl-button-group>
<p class="gl-ml-3 gl-mb-0">{{ scheduleRange }}</p>
</div>
<gl-card header-class="gl-bg-transparent"> <gl-card header-class="gl-bg-transparent">
<template #header> <template #header>
...@@ -134,6 +146,6 @@ export default { ...@@ -134,6 +146,6 @@ export default {
</gl-card> </gl-card>
<delete-schedule-modal :schedule="schedule" :modal-id="$options.deleteScheduleModalId" /> <delete-schedule-modal :schedule="schedule" :modal-id="$options.deleteScheduleModalId" />
<edit-schedule-modal :schedule="schedule" :modal-id="$options.editScheduleModalId" /> <edit-schedule-modal :schedule="schedule" :modal-id="$options.editScheduleModalId" />
<add-rotation-modal :modal-id="$options.addRotationModalId" /> <add-rotation-modal :schedule="schedule" :modal-id="$options.addRotationModalId" />
</div> </div>
</template> </template>
<script> <script>
import { GlAlert, GlButton, GlEmptyState, GlLoadingIcon, GlModalDirective } from '@gitlab/ui'; import { GlAlert, GlButton, GlEmptyState, GlLoadingIcon, GlModalDirective } from '@gitlab/ui';
import mockRotations from '../../../../../spec/frontend/oncall_schedule/mocks/mock_rotation.json';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import AddScheduleModal from './add_schedule_modal.vue'; import AddScheduleModal from './add_schedule_modal.vue';
import OncallSchedule from './oncall_schedule.vue'; import OncallSchedule from './oncall_schedule.vue';
...@@ -25,6 +26,7 @@ export const i18n = { ...@@ -25,6 +26,7 @@ export const i18n = {
}; };
export default { export default {
mockRotations,
i18n, i18n,
addScheduleModalId, addScheduleModalId,
inject: ['emptyOncallSchedulesSvgPath', 'projectPath'], inject: ['emptyOncallSchedulesSvgPath', 'projectPath'],
...@@ -86,7 +88,7 @@ export default { ...@@ -86,7 +88,7 @@ export default {
> >
{{ $options.i18n.successNotification.description }} {{ $options.i18n.successNotification.description }}
</gl-alert> </gl-alert>
<oncall-schedule :schedule="schedule" /> <oncall-schedule :schedule="schedule" :rotations="$options.mockRotations" />
</template> </template>
<gl-empty-state <gl-empty-state
......
...@@ -14,12 +14,12 @@ import { ...@@ -14,12 +14,12 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import createOncallScheduleRotationMutation from '../../graphql/create_oncall_schedule_rotation.mutation.graphql'; import createOncallScheduleRotationMutation from '../../../graphql/create_oncall_schedule_rotation.mutation.graphql';
import { import {
LENGTH_ENUM, LENGTH_ENUM,
CHEVRON_SKIPPING_SHADE_ENUM, CHEVRON_SKIPPING_SHADE_ENUM,
CHEVRON_SKIPPING_PALETTE_ENUM, CHEVRON_SKIPPING_PALETTE_ENUM,
} from '../../constants'; } from '../../../constants';
export default { export default {
i18n: { i18n: {
...@@ -65,6 +65,10 @@ export default { ...@@ -65,6 +65,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
schedule: {
type: Object,
required: true,
},
}, },
apollo: { apollo: {
participants: { participants: {
...@@ -78,7 +82,6 @@ export default { ...@@ -78,7 +82,6 @@ export default {
return nodes; return nodes;
}, },
error(error) { error(error) {
this.showErrorAlert = true;
this.error = error; this.error = error;
}, },
}, },
...@@ -100,8 +103,12 @@ export default { ...@@ -100,8 +103,12 @@ export default {
time: 0, time: 0,
}, },
}, },
showErrorAlert: false, error: null,
error: '', validationState: {
name: true,
participants: true,
startsOn: true,
},
}; };
}, },
computed: { computed: {
...@@ -116,15 +123,6 @@ export default { ...@@ -116,15 +123,6 @@ export default {
}, },
}; };
}, },
rotationNameIsValid() {
return this.form.name !== '';
},
rotationParticipantsAreValid() {
return this.form.participants.length > 0;
},
rotationStartsOnIsValid() {
return this.form.startsOn.date !== null || this.form.startsOn.date !== undefined;
},
noResults() { noResults() {
return this.participants.length === 0; return this.participants.length === 0;
}, },
...@@ -150,7 +148,6 @@ export default { ...@@ -150,7 +148,6 @@ export default {
}) })
.catch(error => { .catch(error => {
this.error = error; this.error = error;
this.showErrorAlert = true;
}) })
.finally(() => { .finally(() => {
this.loading = false; this.loading = false;
...@@ -168,6 +165,15 @@ export default { ...@@ -168,6 +165,15 @@ export default {
setRotationStartsOnTime(time) { setRotationStartsOnTime(time) {
this.form.startsOn.time = time; this.form.startsOn.time = time;
}, },
validateForm(key) {
if (key === 'name') {
this.validationState.name = this.form.name !== '';
} else if (key === 'participants') {
this.validationState.participants = this.form.participants.length > 0;
} else if (key === 'startsOn') {
this.validationState.startsOn = this.form.startsOn.date !== null;
}
},
}, },
}; };
</script> </script>
...@@ -182,7 +188,7 @@ export default { ...@@ -182,7 +188,7 @@ export default {
:action-cancel="actionsProps.cancel" :action-cancel="actionsProps.cancel"
@primary="createRotation" @primary="createRotation"
> >
<gl-alert v-if="showErrorAlert" variant="danger" @dismiss="showErrorAlert = false"> <gl-alert v-if="error" variant="danger" @dismiss="error = null">
{{ error || $options.i18n.errorMsg }} {{ error || $options.i18n.errorMsg }}
</gl-alert> </gl-alert>
<gl-form class="w-75 gl-xs-w-full!" @submit.prevent="createRotation"> <gl-form class="w-75 gl-xs-w-full!" @submit.prevent="createRotation">
...@@ -191,9 +197,9 @@ export default { ...@@ -191,9 +197,9 @@ export default {
label-size="sm" label-size="sm"
label-for="rotation-name" label-for="rotation-name"
:invalid-feedback="$options.i18n.fields.name.error" :invalid-feedback="$options.i18n.fields.name.error"
:state="rotationNameIsValid" :state="validationState.name"
> >
<gl-form-input id="rotation-name" v-model="form.name" /> <gl-form-input id="rotation-name" v-model="form.name" @blur.native="validateForm('name')" />
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
...@@ -201,7 +207,7 @@ export default { ...@@ -201,7 +207,7 @@ export default {
label-size="sm" label-size="sm"
label-for="rotation-participants" label-for="rotation-participants"
:invalid-feedback="$options.i18n.fields.participants.error" :invalid-feedback="$options.i18n.fields.participants.error"
:state="rotationParticipantsAreValid" :state="validationState.participants"
> >
<gl-token-selector <gl-token-selector
v-model="form.participants" v-model="form.participants"
...@@ -209,6 +215,7 @@ export default { ...@@ -209,6 +215,7 @@ export default {
:loading="this.$apollo.queries.participants.loading" :loading="this.$apollo.queries.participants.loading"
:container-class="'gl-h-13! gl-overflow-y-auto'" :container-class="'gl-h-13! gl-overflow-y-auto'"
@text-input="filterParticipants" @text-input="filterParticipants"
@blur="validateForm('participants')"
> >
<template #token-content="{ token }"> <template #token-content="{ token }">
<gl-avatar v-if="token.avatarUrl" :src="token.avatarUrl" :size="16" /> <gl-avatar v-if="token.avatarUrl" :src="token.avatarUrl" :size="16" />
...@@ -257,10 +264,14 @@ export default { ...@@ -257,10 +264,14 @@ export default {
label-size="sm" label-size="sm"
label-for="rotation-time" label-for="rotation-time"
:invalid-feedback="$options.i18n.fields.startsOn.error" :invalid-feedback="$options.i18n.fields.startsOn.error"
:state="rotationStartsOnIsValid" :state="validationState.startsOn"
> >
<div class="gl-display-flex gl-align-items-center"> <div class="gl-display-flex gl-align-items-center">
<gl-datepicker v-model="form.startsOn.date" class="gl-mr-3" /> <gl-datepicker
v-model="form.startsOn.date"
class="gl-mr-3"
@close="validateForm('startsOn')"
/>
<span> {{ __('at') }} </span> <span> {{ __('at') }} </span>
<gl-dropdown <gl-dropdown
id="rotation-time" id="rotation-time"
...@@ -277,8 +288,7 @@ export default { ...@@ -277,8 +288,7 @@ export default {
<span class="gl-white-space-nowrap"> {{ formatTime(n) }}</span> <span class="gl-white-space-nowrap"> {{ formatTime(n) }}</span>
</gl-dropdown-item> </gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
<!-- TODO: // Replace with actual timezone following coming work --> <span class="gl-pl-5"> {{ schedule.timezone }} </span>
<span class="gl-pl-5"> {{ __('PST') }} </span>
</div> </div>
</gl-form-group> </gl-form-group>
</gl-form> </gl-form>
......
<script>
import { GlToken, GlAvatarLabeled, GlPopover } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { assigneeScheduleDateStart } from '../../../utils/common_utils';
export default {
components: {
GlToken,
GlAvatarLabeled,
GlPopover,
},
props: {
assigneeIndex: {
type: Number,
required: true,
},
assignee: {
type: Object,
required: true,
},
rotationLength: {
type: Number,
required: true,
},
rotationStartsAt: {
type: String,
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 });
},
endsAt() {
const endsAt = assigneeScheduleDateStart(
new Date(this.rotationStartsAt),
this.rotationLength * 7 * this.assigneeIndex + this.rotationLength * 7,
).toLocaleString();
return sprintf(__('Ends at %{endsAt}'), { endsAt });
},
},
};
</script>
<template>
<div class="gl-w-full gl-mt-3 gl-px-3">
<gl-token
:id="assignee.user.id"
class="gl-w-full gl-align-items-center"
:class="chevronClass"
:view-only="true"
>
<gl-avatar-labeled
shape="circle"
:size="16"
:src="assignee.user.avatarUrl"
:label="assignee.user.username"
:title="assignee.user.username"
/>
</gl-token>
<gl-popover
:target="assignee.user.id"
:title="assignee.user.username"
triggers="hover"
placement="left"
>
<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>
</gl-popover>
</div>
</template>
<script> <script>
import CommonMixin from '../mixins/common_mixin'; import CommonMixin from '../../../mixins/common_mixin';
export default { export default {
mixins: [CommonMixin], mixins: [CommonMixin],
......
<script> <script>
import { monthInWords } from '~/lib/utils/datetime_utility'; import { monthInWords } from '~/lib/utils/datetime_utility';
import WeeksHeaderSubItem from './weeks_header_sub_item.vue'; import WeeksHeaderSubItem from './weeks_header_sub_item.vue';
import CommonMixin from '../../mixins/common_mixin'; import CommonMixin from '../../../../mixins/common_mixin';
export default { export default {
components: { components: {
...@@ -34,10 +34,7 @@ export default { ...@@ -34,10 +34,7 @@ export default {
const timeframeItemDate = this.timeframeItem.getDate(); const timeframeItemDate = this.timeframeItem.getDate();
if (this.timeframeIndex === 0 || (timeframeItemMonth === 0 && timeframeItemDate <= 7)) { if (this.timeframeIndex === 0 || (timeframeItemMonth === 0 && timeframeItemDate <= 7)) {
return `${this.timeframeItem.getFullYear()} ${monthInWords( return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`;
this.timeframeItem,
true,
)} ${timeframeItemDate}`;
} }
return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`; return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`;
......
<script> <script>
import CommonMixin from '../../mixins/common_mixin'; import CommonMixin from '../../../../mixins/common_mixin';
export default { export default {
mixins: [CommonMixin], mixins: [CommonMixin],
......
<script> <script>
import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import CurrentDayIndicator from './current_day_indicator.vue'; import CurrentDayIndicator from './current_day_indicator.vue';
import RotationAssignee from '../../rotations/components/rotation_assignee.vue';
export const i18n = {
editRotationLabel: s__('OnCallSchedules|Edit rotation'),
deleteRotationLabel: s__('OnCallSchedules|Delete rotation'),
};
export default { export default {
i18n,
components: { components: {
GlButtonGroup,
GlButton,
CurrentDayIndicator, CurrentDayIndicator,
RotationAssignee,
},
directives: {
GlTooltip: GlTooltipDirective,
}, },
props: { props: {
presetType: { presetType: {
...@@ -24,8 +39,32 @@ export default { ...@@ -24,8 +39,32 @@ export default {
<template> <template>
<div class="list-section"> <div class="list-section">
<div class="list-item list-item-empty clearfix"> <div
<span class="details-cell"></span> v-for="rotation in rotations"
:key="rotation.id"
class="list-item list-item-empty clearfix"
>
<span
class="details-cell gl-display-flex gl-justify-content-space-between gl-align-items-center gl-pl-3"
>
<span class="gl-str-truncated">{{ rotation.name }}</span>
<gl-button-group class="gl-px-2">
<gl-button
v-gl-tooltip
category="tertiary"
:title="$options.i18n.editRotationLabel"
icon="pencil"
:aria-label="$options.i18n.editRotationLabel"
/>
<gl-button
v-gl-tooltip
category="tertiary"
:title="$options.i18n.deleteRotationLabel"
icon="remove"
:aria-label="$options.i18n.deleteRotationLabel"
/>
</gl-button-group>
</span>
<span <span
v-for="(timeframeItem, index) in timeframe" v-for="(timeframeItem, index) in timeframe"
:key="index" :key="index"
...@@ -33,6 +72,12 @@ export default { ...@@ -33,6 +72,12 @@ export default {
data-testid="timelineCell" data-testid="timelineCell"
> >
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" /> <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"
/>
</span> </span>
</div> </div>
</div> </div>
......
export const DAYS_IN_WEEK = 7;
export const PRESET_TYPES = {
WEEKS: 'WEEKS',
};
export const PRESET_DEFAULTS = {
WEEKS: {
TIMEFRAME_LENGTH: 2,
},
};
import { newDate } from '~/lib/utils/datetime_utility'; import { newDate } from '~/lib/utils/datetime_utility';
import { PRESET_DEFAULTS, DAYS_IN_WEEK } from './constants'; import { PRESET_DEFAULTS, DAYS_IN_WEEK } from '../../constants';
/** /**
* This method returns array of Dates representing 2-weeks timeframe based on provided initialDate * This method returns array of Dates representing 2-weeks timeframe based on provided initialDate
......
...@@ -7,3 +7,15 @@ export const LENGTH_ENUM = { ...@@ -7,3 +7,15 @@ export const LENGTH_ENUM = {
export const CHEVRON_SKIPPING_SHADE_ENUM = ['500', '600', '700', '800', '900', '950']; export const CHEVRON_SKIPPING_SHADE_ENUM = ['500', '600', '700', '800', '900', '950'];
export const CHEVRON_SKIPPING_PALETTE_ENUM = ['blue', 'orange', 'aqua', 'green', 'magenta']; export const CHEVRON_SKIPPING_PALETTE_ENUM = ['blue', 'orange', 'aqua', 'green', 'magenta'];
export const DAYS_IN_WEEK = 7;
export const PRESET_TYPES = {
WEEKS: 'WEEKS',
};
export const PRESET_DEFAULTS = {
WEEKS: {
TIMEFRAME_LENGTH: 2,
},
};
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import { getDateInFuture } from '~/lib/utils/datetime_utility';
/** /**
* Returns formatted timezone string, e.g. (UTC-09:00) AKST Alaska * Returns formatted timezone string, e.g. (UTC-09:00) AKST Alaska
...@@ -16,3 +17,16 @@ export const getFormattedTimezone = tz => { ...@@ -16,3 +17,16 @@ export const getFormattedTimezone = tz => {
timezone: `${tz.abbr} ${tz.name}`, timezone: `${tz.abbr} ${tz.name}`,
}); });
}; };
/**
* 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);
};
[{
"id": "gid://gitlab/IncidentManagement::OncallRotation/2",
"name": "Rotation 242",
"startsAt": "2020-12-09T09:00:53Z",
"length": 1,
"lengthUnit": "WEEKS",
"participants": {
"nodes": [
{
"user": {
"id": "gid://gitlab/User/1",
"username": "root",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "blue"
},
{
"user": {
"id": "gid://gitlab/User/2",
"username": "david",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "magenta"
}
]
}
},
{
"id": "gid://gitlab/IncidentManagement::OncallRotation/3",
"name": "Rotation 244",
"startsAt": "2020-12-16T09:00:53Z",
"length": 1,
"lengthUnit": "WEEKS",
"participants": {
"nodes": [
{
"user": {
"id": "gid://gitlab/User/3",
"username": "root 2",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "orange"
},
{
"user": {
"id": "gid://gitlab/User/4",
"username": "david 2",
"avatarUrl": "/url"
},
"colorWeight": "500",
"colorPalette": "aqua"
}
]
}
}]
...@@ -5,7 +5,7 @@ import ScheduleTimelineSection from 'ee/oncall_schedules/components/schedule/com ...@@ -5,7 +5,7 @@ import ScheduleTimelineSection from 'ee/oncall_schedules/components/schedule/com
import RotationsListSection from 'ee/oncall_schedules/components/schedule/components/rotations_list_section.vue'; import RotationsListSection from 'ee/oncall_schedules/components/schedule/components/rotations_list_section.vue';
import * as utils from 'ee/oncall_schedules/components/schedule/utils'; import * as utils from 'ee/oncall_schedules/components/schedule/utils';
import * as commonUtils from 'ee/oncall_schedules/utils/common_utils'; import * as commonUtils from 'ee/oncall_schedules/utils/common_utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants'; import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import mockTimezones from './mocks/mockTimezones.json'; import mockTimezones from './mocks/mockTimezones.json';
...@@ -63,11 +63,11 @@ describe('On-call schedule', () => { ...@@ -63,11 +63,11 @@ describe('On-call schedule', () => {
}); });
it('shows timezone info', () => { it('shows timezone info', () => {
const shortTz = i18n.scheduleForTz.replace('%{tzShort}', lastTz.identifier); const timezone = i18n.scheduleForTz.replace('%{timezone}', lastTz.identifier);
const longTz = formattedTimezone; const offset = `(UTC ${lastTz.formatted_offset})`;
const description = findSchedule().text(); const description = findSchedule().text();
expect(description).toContain(shortTz); expect(description).toContain(timezone);
expect(description).toContain(longTz); expect(description).toContain(offset);
}); });
it('renders rotations header', () => { it('renders rotations header', () => {
......
...@@ -20,6 +20,7 @@ exports[`AddRotationModal renders rotation modal layout 1`] = ` ...@@ -20,6 +20,7 @@ exports[`AddRotationModal renders rotation modal layout 1`] = `
label="Name" label="Name"
label-for="rotation-name" label-for="rotation-name"
label-size="sm" label-size="sm"
state="true"
> >
<gl-form-input-stub <gl-form-input-stub
id="rotation-name" id="rotation-name"
...@@ -32,6 +33,7 @@ exports[`AddRotationModal renders rotation modal layout 1`] = ` ...@@ -32,6 +33,7 @@ exports[`AddRotationModal renders rotation modal layout 1`] = `
label="Participants" label="Participants"
label-for="rotation-participants" label-for="rotation-participants"
label-size="sm" label-size="sm"
state="true"
> >
<gl-token-selector-stub <gl-token-selector-stub
autocomplete="off" autocomplete="off"
...@@ -509,7 +511,9 @@ exports[`AddRotationModal renders rotation modal layout 1`] = ` ...@@ -509,7 +511,9 @@ exports[`AddRotationModal renders rotation modal layout 1`] = `
<span <span
class="gl-pl-5" class="gl-pl-5"
> >
PST {
"identifier": "Pacific/Honolulu"
}
</span> </span>
</div> </div>
</gl-form-group-stub> </gl-form-group-stub>
......
...@@ -4,11 +4,13 @@ import VueApollo from 'vue-apollo'; ...@@ -4,11 +4,13 @@ import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { GlDropdownItem, GlModal, GlAlert, GlTokenSelector } from '@gitlab/ui'; import { GlDropdownItem, GlModal, GlAlert, GlTokenSelector } from '@gitlab/ui';
import { addRotationModalId } from 'ee/oncall_schedules/components/oncall_schedule'; import { addRotationModalId } from 'ee/oncall_schedules/components/oncall_schedule';
import AddRotationModal from 'ee/oncall_schedules/components/rotations/add_rotation_modal.vue'; import AddRotationModal from 'ee/oncall_schedules/components/rotations/components/add_rotation_modal.vue';
// import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/create_oncall_schedule_rotation.mutation.graphql'; // import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/create_oncall_schedule_rotation.mutation.graphql';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { participants } from '../mocks/apollo_mock'; import { getOncallSchedulesQueryResponse, participants } from '../../mocks/apollo_mock';
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
const localVue = createLocalVue(); const localVue = createLocalVue();
const projectPath = 'group/project'; const projectPath = 'group/project';
const mutate = jest.fn(); const mutate = jest.fn();
...@@ -36,6 +38,7 @@ describe('AddRotationModal', () => { ...@@ -36,6 +38,7 @@ describe('AddRotationModal', () => {
}, },
propsData: { propsData: {
modalId: addRotationModalId, modalId: addRotationModalId,
schedule,
...props, ...props,
}, },
provide: { provide: {
...@@ -62,6 +65,7 @@ describe('AddRotationModal', () => { ...@@ -62,6 +65,7 @@ describe('AddRotationModal', () => {
localVue, localVue,
propsData: { propsData: {
modalId: addRotationModalId, modalId: addRotationModalId,
schedule,
}, },
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
data() { data() {
......
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 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 findStartsAt = () => wrapper.findByTestId('rotation-assignee-starts-at');
const findEndsAt = () => wrapper.findByTestId('rotation-assignee-ends-at');
function mountComponent() {
wrapper = extendedWrapper(
shallowMount(RotationAssignee, {
propsData: {
assignee,
assigneeIndex: 1,
rotationLength: mockRotations[0].length,
rotationStartsAt: mockRotations[0].startsAt,
},
}),
);
}
beforeEach(() => {
mountComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
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);
});
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}`,
);
});
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');
});
});
});
...@@ -8,8 +8,38 @@ exports[`RotationsListSectionComponent renders component layout 1`] = ` ...@@ -8,8 +8,38 @@ exports[`RotationsListSectionComponent renders component layout 1`] = `
class="list-item list-item-empty clearfix" class="list-item list-item-empty clearfix"
> >
<span <span
class="details-cell" class="details-cell gl-display-flex gl-justify-content-space-between gl-align-items-center gl-pl-3"
/> >
<span
class="gl-str-truncated"
>
Rotation 242
</span>
<gl-button-group-stub
class="gl-px-2"
>
<gl-button-stub
aria-label="Edit rotation"
buttontextclasses=""
category="tertiary"
icon="pencil"
size="medium"
title="Edit rotation"
variant="default"
/>
<gl-button-stub
aria-label="Delete rotation"
buttontextclasses=""
category="tertiary"
icon="remove"
size="medium"
title="Delete rotation"
variant="default"
/>
</gl-button-group-stub>
</span>
<span <span
class="timeline-cell" class="timeline-cell"
...@@ -19,6 +49,13 @@ exports[`RotationsListSectionComponent renders component layout 1`] = ` ...@@ -19,6 +49,13 @@ exports[`RotationsListSectionComponent renders component layout 1`] = `
presettype="WEEKS" presettype="WEEKS"
timeframeitem="Mon Jan 01 2018 00:00:00 GMT+0000 (Greenwich Mean Time)" timeframeitem="Mon Jan 01 2018 00:00:00 GMT+0000 (Greenwich Mean Time)"
/> />
<rotation-assignee-stub
assignee="[object Object]"
assigneeindex="0"
rotationlength="1"
rotationstartsat="2020-12-09T09:00:53Z"
/>
</span> </span>
<span <span
class="timeline-cell" class="timeline-cell"
...@@ -28,6 +65,13 @@ exports[`RotationsListSectionComponent renders component layout 1`] = ` ...@@ -28,6 +65,13 @@ exports[`RotationsListSectionComponent renders component layout 1`] = `
presettype="WEEKS" presettype="WEEKS"
timeframeitem="Mon Jan 08 2018 00:00:00 GMT+0000 (Greenwich Mean Time)" 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> </span>
</div> </div>
</div> </div>
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import CurrentDayIndicator from 'ee/oncall_schedules/components/schedule/components/current_day_indicator.vue'; import CurrentDayIndicator from 'ee/oncall_schedules/components/schedule/components/current_day_indicator.vue';
import { PRESET_TYPES, DAYS_IN_WEEK } from 'ee/oncall_schedules/components/schedule/constants';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import { PRESET_TYPES, DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
describe('CurrentDayIndicator', () => { describe('CurrentDayIndicator', () => {
let wrapper; let wrapper;
......
...@@ -48,7 +48,7 @@ describe('WeeksHeaderItemComponent', () => { ...@@ -48,7 +48,7 @@ describe('WeeksHeaderItemComponent', () => {
describe('timelineHeaderLabel', () => { describe('timelineHeaderLabel', () => {
it('returns string containing Year, Month and Date for the first timeframe item in the entire timeframe', () => { it('returns string containing Year, Month and Date for the first timeframe item in the entire timeframe', () => {
expect(findHeaderLabel().text()).toBe('2018 Jan 1'); expect(findHeaderLabel().text()).toBe('Jan 1');
}); });
it('returns string containing Year, Month and Date for timeframe item that is the first week of the year', () => { it('returns string containing Year, Month and Date for timeframe item that is the first week of the year', () => {
...@@ -57,7 +57,7 @@ describe('WeeksHeaderItemComponent', () => { ...@@ -57,7 +57,7 @@ describe('WeeksHeaderItemComponent', () => {
timeframeItem: new Date(2019, 0, 6), timeframeItem: new Date(2019, 0, 6),
}); });
expect(findHeaderLabel().text()).toBe('2019 Jan 6'); expect(findHeaderLabel().text()).toBe('Jan 6');
}); });
it('returns string containing only Month and Date when timeframe item is somewhere in the middle of the timeframe', () => { it('returns string containing only Month and Date when timeframe item is somewhere in the middle of the timeframe', () => {
......
...@@ -2,8 +2,10 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,8 +2,10 @@ import { shallowMount } from '@vue/test-utils';
import { GlCard } from '@gitlab/ui'; import { GlCard } from '@gitlab/ui';
import RotationsListSection from 'ee/oncall_schedules/components/schedule/components/rotations_list_section.vue'; 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'; import CurrentDayIndicator from 'ee/oncall_schedules/components/schedule/components/current_day_indicator.vue';
import RotationsAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils'; import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants'; import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
import mockRotations from '../../mocks/mock_rotation.json';
describe('RotationsListSectionComponent', () => { describe('RotationsListSectionComponent', () => {
let wrapper; let wrapper;
...@@ -18,7 +20,7 @@ describe('RotationsListSectionComponent', () => { ...@@ -18,7 +20,7 @@ describe('RotationsListSectionComponent', () => {
propsData: { propsData: {
presetType, presetType,
timeframe, timeframe,
rotations: [], rotations: [mockRotations[0]],
}, },
stubs: { stubs: {
GlCard, GlCard,
...@@ -38,6 +40,7 @@ describe('RotationsListSectionComponent', () => { ...@@ -38,6 +40,7 @@ describe('RotationsListSectionComponent', () => {
}); });
const findTimelineCells = () => wrapper.findAll('[data-testid="timelineCell"]'); const findTimelineCells = () => wrapper.findAll('[data-testid="timelineCell"]');
const findRotationAssignees = () => wrapper.findAll(RotationsAssignee);
it('renders component layout', () => { it('renders component layout', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
...@@ -55,4 +58,13 @@ describe('RotationsListSectionComponent', () => { ...@@ -55,4 +58,13 @@ describe('RotationsListSectionComponent', () => {
.exists(), .exists(),
).toBe(true); ).toBe(true);
}); });
it('render the correct amount of rotation assignees with their name, avatar and color', () => {
expect(findRotationAssignees()).toHaveLength(2);
expect(
findRotationAssignees()
.at(0)
.props().assignee.user,
).toEqual(mockRotations[0].participants.nodes[0].user);
});
}); });
...@@ -3,7 +3,7 @@ import { GlCard } from '@gitlab/ui'; ...@@ -3,7 +3,7 @@ import { GlCard } from '@gitlab/ui';
import ScheduleTimelineSection from 'ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue'; 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 WeeksHeaderItem from 'ee/oncall_schedules/components/schedule/components/preset_weeks/weeks_header_item.vue';
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils'; import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants'; import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
describe('TimelineSectionComponent', () => { describe('TimelineSectionComponent', () => {
let wrapper; let wrapper;
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import CommonMixin from 'ee/oncall_schedules/components/schedule/mixins/common_mixin'; import CommonMixin from 'ee/oncall_schedules/mixins/common_mixin';
import { DAYS_IN_WEEK } from 'ee/oncall_schedules/components/schedule/constants';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import { DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
describe('Schedule Common Mixins', () => { describe('Schedule Common Mixins', () => {
// January 3rd, 2018 // January 3rd, 2018
......
...@@ -10581,6 +10581,9 @@ msgstr "" ...@@ -10581,6 +10581,9 @@ msgstr ""
msgid "End Time" msgid "End Time"
msgstr "" msgstr ""
msgid "Ends at %{endsAt}"
msgstr ""
msgid "Ends at (UTC)" msgid "Ends at (UTC)"
msgstr "" msgstr ""
...@@ -19352,9 +19355,15 @@ msgstr "" ...@@ -19352,9 +19355,15 @@ msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab" msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr "" msgstr ""
msgid "OnCallSchedules|Delete rotation"
msgstr ""
msgid "OnCallSchedules|Delete schedule" msgid "OnCallSchedules|Delete schedule"
msgstr "" msgstr ""
msgid "OnCallSchedules|Edit rotation"
msgstr ""
msgid "OnCallSchedules|Edit schedule" msgid "OnCallSchedules|Edit schedule"
msgstr "" msgstr ""
...@@ -19370,7 +19379,7 @@ msgstr "" ...@@ -19370,7 +19379,7 @@ msgstr ""
msgid "OnCallSchedules|On-call schedule" msgid "OnCallSchedules|On-call schedule"
msgstr "" msgstr ""
msgid "OnCallSchedules|On-call schedule for the %{tzShort}" msgid "OnCallSchedules|On-call schedule for the %{timezone}"
msgstr "" msgstr ""
msgid "OnCallSchedules|Rotation length" msgid "OnCallSchedules|Rotation length"
...@@ -19723,9 +19732,6 @@ msgstr "" ...@@ -19723,9 +19732,6 @@ msgstr ""
msgid "Owner" msgid "Owner"
msgstr "" msgstr ""
msgid "PST"
msgstr ""
msgid "Package Registry" msgid "Package Registry"
msgstr "" msgstr ""
...@@ -26402,6 +26408,9 @@ msgstr "" ...@@ -26402,6 +26408,9 @@ msgstr ""
msgid "Starts %{startsIn}" msgid "Starts %{startsIn}"
msgstr "" msgstr ""
msgid "Starts at %{startsAt}"
msgstr ""
msgid "Starts at (UTC)" msgid "Starts at (UTC)"
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