Commit eacecc62 authored by David O'Regan's avatar David O'Regan Committed by Kushal Pandya

Enable Rotation creation

Enable rotation creation in
on call schedules via
GraphQL with supporting specs
parent d4ed85ad
......@@ -806,3 +806,20 @@ export const dateAtFirstDayOfMonth = (date) => new Date(newDate(date).setDate(1)
* @return {Boolean} true if the dates match
*/
export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0;
/**
* A utility function which computes a formatted 24 hour
* time string from a positive int in the range 0 - 24.
*
* @param {Int} time a positive Int between 0 and 24
*
* @returns {String} formatted 24 hour time String
*/
export const format24HourTimeStringFromInt = (time) => {
if (!Number.isInteger(time) || time < 0 || time > 24) {
return '';
}
const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`;
return formatted24HourString;
};
......@@ -12,11 +12,10 @@ import { s__, __ } from '~/locale';
import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue';
import DeleteScheduleModal from './delete_schedule_modal.vue';
import EditScheduleModal from './add_edit_schedule_modal.vue';
import AddRotationModal from './rotations/components/add_rotation_modal.vue';
import { getTimeframeForWeeksView } from './schedule/utils';
import { PRESET_TYPES } from '../constants';
import AddEditRotationModal from './rotations/components/add_edit_rotation_modal.vue';
import RotationsListSection from './schedule/components/rotations_list_section.vue';
import { getTimeframeForWeeksView } from './schedule/utils';
import { addRotationModalId, editRotationModalId, PRESET_TYPES } from '../constants';
export const i18n = {
scheduleForTz: s__('OnCallSchedules|On-call schedule for the %{timezone}'),
......@@ -25,14 +24,13 @@ export const i18n = {
rotationTitle: s__('OnCallSchedules|Rotations'),
addARotation: s__('OnCallSchedules|Add a rotation'),
};
export const addRotationModalId = 'addRotationModal';
export const editScheduleModalId = 'editScheduleModal';
export const deleteScheduleModalId = 'deleteScheduleModal';
export default {
i18n,
addRotationModalId,
editRotationModalId,
editScheduleModalId,
deleteScheduleModalId,
presetType: PRESET_TYPES.WEEKS,
......@@ -45,7 +43,7 @@ export default {
GlButton,
DeleteScheduleModal,
EditScheduleModal,
AddRotationModal,
AddEditRotationModal,
RotationsListSection,
},
directives: {
......@@ -151,6 +149,11 @@ export default {
:modal-id="$options.editScheduleModalId"
is-edit-mode
/>
<add-rotation-modal :schedule="schedule" :modal-id="$options.addRotationModalId" />
<add-edit-rotation-modal :schedule="schedule" :modal-id="$options.addRotationModalId" />
<add-edit-rotation-modal
:schedule="schedule"
:modal-id="$options.editRotationModalId"
is-edit-mode
/>
</div>
</template>
<script>
import {
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import {
LENGTH_ENUM,
HOURS_IN_DAY,
CHEVRON_SKIPPING_SHADE_ENUM,
CHEVRON_SKIPPING_PALETTE_ENUM,
} from '../../../constants';
import { format24HourTimeStringFromInt } from '~/lib/utils/datetime_utility';
export const i18n = {
selectParticipant: s__('OnCallSchedules|Select participant'),
errorMsg: s__('OnCallSchedules|Failed to add rotation'),
fields: {
name: { title: __('Name'), error: s__('OnCallSchedules|Rotation name cannot be empty') },
participants: {
title: __('Participants'),
error: s__('OnCallSchedules|Rotation participants cannot be empty'),
},
rotationLength: { title: s__('OnCallSchedules|Rotation length') },
startsAt: {
title: __('Starts on'),
error: s__('OnCallSchedules|Rotation start date cannot be empty'),
},
},
};
export default {
i18n,
HOURS_IN_DAY,
tokenColorPalette: {
shade: CHEVRON_SKIPPING_SHADE_ENUM,
palette: CHEVRON_SKIPPING_PALETTE_ENUM,
},
LENGTH_ENUM,
inject: ['projectPath'],
components: {
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
},
props: {
form: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
rotationNameIsValid: {
type: Boolean,
required: true,
},
rotationParticipantsAreValid: {
type: Boolean,
required: true,
},
rotationStartsAtIsValid: {
type: Boolean,
required: true,
},
participants: {
type: Array,
required: true,
},
schedule: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
participantsArr: [],
};
},
methods: {
format24HourTimeStringFromInt,
},
};
</script>
<template>
<gl-form class="w-75 gl-xs-w-full!" @submit.prevent="createRotation">
<gl-form-group
:label="$options.i18n.fields.name.title"
label-size="sm"
label-for="rotation-name"
:invalid-feedback="$options.i18n.fields.name.error"
:state="rotationNameIsValid"
>
<gl-form-input
id="rotation-name"
@input="$emit('update-rotation-form', { type: 'name', value: $event })"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.participants.title"
label-size="sm"
label-for="rotation-participants"
:invalid-feedback="$options.i18n.fields.participants.error"
:state="rotationParticipantsAreValid"
>
<gl-token-selector
v-model="participantsArr"
:dropdown-items="participants"
:loading="isLoading"
container-class="gl-h-13! gl-overflow-y-auto"
@text-input="$emit('filter-participants', $event)"
@input="$emit('update-rotation-form', { type: 'participants', value: participantsArr })"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatarUrl" :src="token.avatarUrl" :size="16" />
{{ token.name }}
</template>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatarUrl"
:size="32"
:label="dropdownItem.name"
:sub-label="dropdownItem.username"
/>
</template>
</gl-token-selector>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.rotationLength.title"
label-size="sm"
label-for="rotation-length"
>
<div class="gl-display-flex">
<gl-form-input
id="rotation-length"
type="number"
class="gl-w-12 gl-mr-3"
min="1"
:value="1"
@input="$emit('update-rotation-form', { type: 'rotationLength.length', value: $event })"
/>
<gl-dropdown id="rotation-length" :text="form.rotationLength.unit.toLowerCase()">
<gl-dropdown-item
v-for="unit in $options.LENGTH_ENUM"
:key="unit"
:is-checked="form.rotationLength.unit === unit"
is-check-item
@click="$emit('update-rotation-form', { type: 'rotationLength.unit', value: unit })"
>
{{ unit.toLowerCase() }}
</gl-dropdown-item>
</gl-dropdown>
</div>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.startsAt.title"
label-size="sm"
label-for="rotation-time"
:invalid-feedback="$options.i18n.fields.startsAt.error"
:state="rotationStartsAtIsValid"
>
<div class="gl-display-flex gl-align-items-center">
<gl-datepicker
class="gl-mr-3"
@input="$emit('update-rotation-form', { type: 'startsAt.date', value: $event })"
/>
<span> {{ __('at') }} </span>
<gl-dropdown
id="rotation-time"
:text="format24HourTimeStringFromInt(form.startsAt.time)"
class="gl-w-12 gl-pl-3"
>
<gl-dropdown-item
v-for="time in $options.HOURS_IN_DAY"
:key="time"
:is-checked="form.startsAt.time === time"
is-check-item
@click="$emit('update-rotation-form', { type: 'startsAt.time', value: time })"
>
<span class="gl-white-space-nowrap"> {{ format24HourTimeStringFromInt(time) }}</span>
</gl-dropdown-item>
</gl-dropdown>
<span class="gl-pl-5"> {{ schedule.timezone }} </span>
</div>
</gl-form-group>
</gl-form>
</template>
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
import { set } from 'lodash';
import { s__, __ } from '~/locale';
import createFlash, { FLASH_TYPES } from '~/flash';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import getOncallSchedulesQuery from '../../../graphql/queries/get_oncall_schedules.query.graphql';
import createOncallScheduleRotationMutation from '../../../graphql/mutations/create_oncall_schedule_rotation.mutation.graphql';
import updateOncallScheduleRotationMutation from '../../../graphql/mutations/update_oncall_schedule_rotation.mutation.graphql';
import { LENGTH_ENUM } from '../../../constants';
import AddEditRotationForm from './add_edit_rotation_form.vue';
import {
updateStoreAfterRotationAdd,
updateStoreAfterRotationEdit,
} from '../../../utils/cache_updates';
import { format24HourTimeStringFromInt } from '~/lib/utils/datetime_utility';
export const i18n = {
rotationCreated: s__('OnCallSchedules|Successfully created a new rotation'),
editedRotation: s__('OnCallSchedules|Successfully edited your rotation'),
addRotation: s__('OnCallSchedules|Add rotation'),
editRotation: s__('OnCallSchedules|Edit rotation'),
cancel: __('Cancel'),
};
export default {
i18n,
LENGTH_ENUM,
inject: ['projectPath'],
components: {
GlModal,
GlAlert,
AddEditRotationForm,
},
props: {
modalId: {
type: String,
required: true,
},
isEditMode: {
type: Boolean,
required: false,
default: false,
},
schedule: {
type: Object,
required: true,
},
},
apollo: {
participants: {
query: usersSearchQuery,
variables() {
return {
search: this.ptSearchTerm,
};
},
update({ users: { nodes = [] } = {} }) {
return nodes;
},
error(error) {
this.error = error;
},
},
},
data() {
return {
participants: [],
loading: false,
ptSearchTerm: '',
form: {
name: '',
participants: [],
rotationLength: {
length: 1,
unit: this.$options.LENGTH_ENUM.hours,
},
startsAt: {
date: null,
time: 0,
},
},
error: '',
};
},
computed: {
actionsProps() {
return {
primary: {
text: this.title,
attributes: [
{ variant: 'info' },
{ loading: this.loading },
{ disabled: !this.isFormValid },
],
},
cancel: {
text: this.$options.i18n.cancel,
},
};
},
rotationNameIsValid() {
return this.form.name !== '';
},
rotationParticipantsAreValid() {
return this.form.participants.length > 0;
},
rotationStartsAtIsValid() {
return Boolean(this.form.startsAt.date);
},
rotationVariables() {
return {
projectPath: this.projectPath,
scheduleIid: this.schedule.iid,
name: this.form.name,
startsAt: {
...this.form.startsAt,
time: format24HourTimeStringFromInt(this.form.startsAt.time),
},
rotationLength: {
...this.form.rotationLength,
length: parseInt(this.form.rotationLength.length, 10),
},
participants: this.form.participants.map(({ username }) => ({
username,
// eslint-disable-next-line @gitlab/require-i18n-strings
colorWeight: 'WEIGHT_500',
colorPalette: 'BLUE',
})),
};
},
isFormValid() {
return (
this.rotationNameIsValid &&
this.rotationParticipantsAreValid &&
this.rotationStartsAtIsValid
);
},
isLoading() {
return this.loading || this.$apollo.queries.participants.loading;
},
title() {
return this.isEditMode ? this.$options.i18n.editRotation : this.$options.i18n.addRotation;
},
},
methods: {
createRotation() {
this.loading = true;
const { projectPath, schedule } = this;
this.$apollo
.mutate({
mutation: createOncallScheduleRotationMutation,
variables: { OncallRotationCreateInput: this.rotationVariables },
update(store, { data }) {
updateStoreAfterRotationAdd(store, getOncallSchedulesQuery, data, schedule.iid, {
projectPath,
});
},
})
.then(
({
data: {
oncallRotationCreate: {
errors: [error],
},
},
}) => {
if (error) {
throw error;
}
this.$refs.addEditScheduleRotationModal.hide();
return createFlash({
message: this.$options.i18n.rotationCreated,
type: FLASH_TYPES.SUCCESS,
});
},
)
.catch((error) => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
editRotation() {
this.loading = true;
const { projectPath, schedule } = this;
this.$apollo
.mutate({
mutation: updateOncallScheduleRotationMutation,
variables: { OncallRotationUpdateInput: this.rotationVariables },
update(store, { data }) {
updateStoreAfterRotationEdit(store, getOncallSchedulesQuery, data, schedule.iid, {
projectPath,
});
},
})
.then(
({
data: {
oncallRotationUpdate: {
errors: [error],
},
},
}) => {
if (error) {
throw error;
}
this.$refs.addEditScheduleRotationModal.hide();
return createFlash({
message: this.$options.i18n.editedRotation,
type: FLASH_TYPES.SUCCESS,
});
},
)
.catch((error) => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
updateRotationForm({ type, value }) {
set(this.form, type, value);
},
filterParticipants(query) {
this.ptSearchTerm = query;
},
},
};
</script>
<template>
<gl-modal
ref="addEditScheduleRotationModal"
:modal-id="modalId"
size="sm"
:title="title"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
@primary.prevent="isEditMode ? editRotation() : createRotation()"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<add-edit-rotation-form
:rotation-name-is-valid="rotationNameIsValid"
:rotation-participants-are-valid="rotationParticipantsAreValid"
:rotation-starts-at-is-valid="rotationStartsAtIsValid"
:form="form"
:schedule="schedule"
:participants="participants"
:is-loading="isLoading"
@update-rotation-form="updateRotationForm"
@filter-participants="filterParticipants"
/>
</gl-modal>
</template>
<script>
import {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlAlert,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import createOncallScheduleRotationMutation from '../../../graphql/create_oncall_schedule_rotation.mutation.graphql';
import {
LENGTH_ENUM,
CHEVRON_SKIPPING_SHADE_ENUM,
CHEVRON_SKIPPING_PALETTE_ENUM,
} from '../../../constants';
export default {
i18n: {
selectParticipant: s__('OnCallSchedules|Select participant'),
addRotation: s__('OnCallSchedules|Add rotation'),
noResults: __('No matching results'),
cancel: __('Cancel'),
errorMsg: s__('OnCallSchedules|Failed to add rotation'),
fields: {
name: { title: __('Name'), error: s__('OnCallSchedules|Rotation name cannot be empty') },
participants: {
title: __('Participants'),
error: s__('OnCallSchedules|Rotation participants cannot be empty'),
},
length: { title: s__('OnCallSchedules|Rotation length') },
startsOn: {
title: __('Starts on'),
error: s__('OnCallSchedules|Rotation start date cannot be empty'),
},
},
},
tokenColorPalette: {
shade: CHEVRON_SKIPPING_SHADE_ENUM,
palette: CHEVRON_SKIPPING_PALETTE_ENUM,
},
LENGTH_ENUM,
inject: ['projectPath'],
components: {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlAlert,
},
props: {
modalId: {
type: String,
required: true,
},
schedule: {
type: Object,
required: true,
},
},
apollo: {
participants: {
query: usersSearchQuery,
variables() {
return {
search: this.ptSearchTerm,
};
},
update({ users: { nodes = [] } = {} }) {
return nodes;
},
error(error) {
this.error = error;
},
},
},
data() {
return {
participants: [],
loading: false,
ptSearchTerm: '',
form: {
name: '',
participants: [],
length: {
value: 1,
type: this.$options.LENGTH_ENUM.hours,
},
startsOn: {
date: null,
time: 0,
},
},
error: null,
validationState: {
name: true,
participants: true,
startsOn: true,
},
};
},
computed: {
actionsProps() {
return {
primary: {
text: this.$options.i18n.addRotation,
attributes: [{ variant: 'info' }, { loading: this.loading }],
},
cancel: {
text: this.$options.i18n.cancel,
},
};
},
noResults() {
return this.participants.length === 0;
},
},
methods: {
createRotation() {
this.loading = true;
this.$apollo
.mutate({
mutation: createOncallScheduleRotationMutation,
variables: {
oncallScheduleRotationCreate: {
projectPath: this.projectPath,
...this.form,
},
},
})
.then(
({
data: {
oncallScheduleRotationCreate: {
errors: [error],
},
},
}) => {
if (error) {
throw error;
}
this.$refs.createScheduleModal.hide();
},
)
.catch((error) => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
formatTime(time) {
return time > 9 ? `${time}:00` : `0${time}:00`;
},
filterParticipants(query) {
this.ptSearchTerm = query;
},
setRotationLengthType(type) {
this.form.length.type = type;
},
setRotationStartsOnTime(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>
<template>
<gl-modal
ref="createScheduleRotationModal"
:modal-id="modalId"
size="sm"
:title="$options.i18n.addRotation"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
@primary="createRotation"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = null">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<gl-form class="w-75 gl-xs-w-full!" @submit.prevent="createRotation">
<gl-form-group
:label="$options.i18n.fields.name.title"
label-size="sm"
label-for="rotation-name"
:invalid-feedback="$options.i18n.fields.name.error"
:state="validationState.name"
>
<gl-form-input id="rotation-name" v-model="form.name" @blur.native="validateForm('name')" />
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.participants.title"
label-size="sm"
label-for="rotation-participants"
:invalid-feedback="$options.i18n.fields.participants.error"
:state="validationState.participants"
>
<gl-token-selector
v-model="form.participants"
:dropdown-items="participants"
:loading="this.$apollo.queries.participants.loading"
:container-class="'gl-h-13! gl-overflow-y-auto'"
@text-input="filterParticipants"
@blur="validateForm('participants')"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatarUrl" :src="token.avatarUrl" :size="16" />
{{ token.name }}
</template>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatarUrl"
:size="32"
:label="dropdownItem.name"
:sub-label="dropdownItem.username"
/>
</template>
</gl-token-selector>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.length.title"
label-size="sm"
label-for="rotation-length"
>
<div class="gl-display-flex">
<gl-form-input
id="rotation-length"
v-model="form.length.value"
type="number"
class="gl-w-12 gl-mr-3"
min="1"
/>
<gl-dropdown id="rotation-length" :text="form.length.type">
<gl-dropdown-item
v-for="type in $options.LENGTH_ENUM"
:key="type"
:is-checked="form.length.type === type"
is-check-item
@click="setRotationLengthType(type)"
>
{{ type }}
</gl-dropdown-item>
</gl-dropdown>
</div>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.startsOn.title"
label-size="sm"
label-for="rotation-time"
:invalid-feedback="$options.i18n.fields.startsOn.error"
:state="validationState.startsOn"
>
<div class="gl-display-flex gl-align-items-center">
<gl-datepicker
v-model="form.startsOn.date"
class="gl-mr-3"
@close="validateForm('startsOn')"
/>
<span> {{ __('at') }} </span>
<gl-dropdown
id="rotation-time"
:text="formatTime(form.startsOn.time)"
class="gl-w-12 gl-pl-3"
>
<gl-dropdown-item
v-for="n in 24"
:key="n"
:is-checked="form.startsOn.time === n"
is-check-item
@click="setRotationStartsOnTime(n)"
>
<span class="gl-white-space-nowrap"> {{ formatTime(n) }}</span>
</gl-dropdown-item>
</gl-dropdown>
<span class="gl-pl-5"> {{ schedule.timezone }} </span>
</div>
</gl-form-group>
</gl-form>
</gl-modal>
</template>
<script>
import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { GlButtonGroup, GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import CurrentDayIndicator from './current_day_indicator.vue';
import RotationAssignee from '../../rotations/components/rotation_assignee.vue';
import { editRotationModalId } from '../../../constants';
export const i18n = {
editRotationLabel: s__('OnCallSchedules|Edit rotation'),
......@@ -11,6 +12,7 @@ export const i18n = {
export default {
i18n,
editRotationModalId,
components: {
GlButtonGroup,
GlButton,
......@@ -18,6 +20,7 @@ export default {
RotationAssignee,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
props: {
......@@ -50,6 +53,7 @@ export default {
<span class="gl-str-truncated">{{ rotation.name }}</span>
<gl-button-group class="gl-px-2">
<gl-button
v-gl-modal="$options.editRotationModalId"
v-gl-tooltip
category="tertiary"
:title="$options.i18n.editRotationLabel"
......@@ -57,6 +61,7 @@ export default {
:aria-label="$options.i18n.editRotationLabel"
/>
<gl-button
v-gl-modal="$options.editRotationModalId"
v-gl-tooltip
category="tertiary"
:title="$options.i18n.deleteRotationLabel"
......
export const LENGTH_ENUM = {
hours: 'hours',
days: 'days',
weeks: 'weeks',
hours: 'HOURS',
days: 'DAYS',
weeks: 'WEEKS',
};
export const CHEVRON_SKIPPING_SHADE_ENUM = ['500', '600', '700', '800', '900', '950'];
......@@ -9,6 +9,7 @@ export const CHEVRON_SKIPPING_SHADE_ENUM = ['500', '600', '700', '800', '900', '
export const CHEVRON_SKIPPING_PALETTE_ENUM = ['blue', 'orange', 'aqua', 'green', 'magenta'];
export const DAYS_IN_WEEK = 7;
export const HOURS_IN_DAY = 24;
export const PRESET_TYPES = {
WEEKS: 'WEEKS',
......@@ -19,3 +20,6 @@ export const PRESET_DEFAULTS = {
TIMEFRAME_LENGTH: 2,
},
};
export const addRotationModalId = 'addRotationModal';
export const editRotationModalId = 'editRotationModal';
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation oncallScheduleRotationCreate(
$oncallScheduleRotationCreateInput: OncallScheduleRotationCreateInput!
) {
oncallScheduleRotationCreate(input: $oncallScheduleRotationCreateInput) {
errors
oncallScheduleRotation {
iid
name
participants {
nodes {
...User
}
}
length {
value
type
}
startsOn {
date
time
}
}
}
}
fragment OnCallRotation on IncidentManagementOncallRotation {
id
name
startsAt
length
lengthUnit
participants {
nodes {
user {
id
username
}
colorWeight
colorPalette
}
}
}
#import "../fragments/oncall_schedule_rotation.fragment.graphql"
mutation newRotation($OncallRotationCreateInput: OncallRotationCreateInput!) {
oncallRotationCreate(input: $OncallRotationCreateInput) {
errors
oncallRotation {
...OnCallRotation
}
}
}
#import "../fragments/oncall_schedule_rotation.fragment.graphql"
mutation updateRotation($OncallRotationUpdateInput: OncallRotationUpdateInput!) {
oncallRotationUpdate(input: $OncallRotationUpdateInput) {
errors
oncallRotation {
...OnCallRotation
}
}
}
import produce from 'immer';
import createFlash from '~/flash';
import { DELETE_SCHEDULE_ERROR, UPDATE_SCHEDULE_ERROR } from './error_messages';
import {
DELETE_SCHEDULE_ERROR,
UPDATE_SCHEDULE_ERROR,
UPDATE_ROTATION_ERROR,
} from './error_messages';
const addScheduleToStore = (store, query, { oncallSchedule: schedule }, variables) => {
if (!schedule) {
......@@ -75,6 +79,65 @@ const updateScheduleFromStore = (store, query, { oncallScheduleUpdate }, variabl
});
};
const addRotationToStore = (
store,
query,
{ oncallRotationCreate: rotation },
scheduleId,
variables,
) => {
if (!rotation) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
// TODO: This needs the rotation backend to be fully integrated to work, for the moment we will place-hold it.
const data = produce(sourceData, (draftData) => {
const rotations = [rotation];
// eslint-disable-next-line no-param-reassign
draftData.project.incidentManagementOncallSchedules.nodes.find(
({ iid }) => iid === scheduleId,
).rotations = rotations;
});
store.writeQuery({
query,
variables,
data,
});
};
const updateRotationFromStore = (store, query, { oncallRotationUpdate }, scheduleId, variables) => {
const rotation = oncallRotationUpdate?.oncallRotation;
if (!rotation) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, (draftData) => {
// eslint-disable-next-line no-param-reassign
draftData.project.incidentManagementOncallSchedules.nodes = [
...draftData.project.incidentManagementOncallSchedules.nodes,
rotation,
];
});
store.writeQuery({
query,
variables,
data,
});
};
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
......@@ -103,3 +166,17 @@ export const updateStoreAfterScheduleEdit = (store, query, data, variables) => {
updateScheduleFromStore(store, query, data, variables);
}
};
export const updateStoreAfterRotationAdd = (store, query, data, scheduleId, variables) => {
if (!hasErrors(data)) {
addRotationToStore(store, query, data, scheduleId, variables);
}
};
export const updateStoreAfterRotationEdit = (store, query, data, scheduleId, variables) => {
if (hasErrors(data)) {
onError(data, UPDATE_ROTATION_ERROR);
} else {
updateRotationFromStore(store, query, data, scheduleId, variables);
}
};
......@@ -7,3 +7,7 @@ export const DELETE_SCHEDULE_ERROR = s__(
export const UPDATE_SCHEDULE_ERROR = s__(
'OnCallSchedules|The schedule could not be updated. Please try again.',
);
export const UPDATE_ROTATION_ERROR = s__(
'OnCallSchedules|The rotation could not be updated. Please try again.',
);
......@@ -109,3 +109,59 @@ export const newlyCreatedSchedule = {
name: 'S-Monitor rotations',
timezone: 'Kyiv/EST',
};
export const createRotationResponse = {
data: {
oncallRotationCreate: {
errors: [],
oncallRotation: {
id: '37',
name: 'Test',
startsAt: '2020-12-17T12:00:00Z',
length: 5,
lengthUnit: 'WEEKS',
participants: {
nodes: [
{
user: { id: 'gid://gitlab/User/50', username: 'project_1_bot3', __typename: 'User' },
colorWeight: '500',
colorPalette: 'blue',
__typename: 'OncallParticipantType',
},
],
__typename: 'OncallParticipantTypeConnection',
},
__typename: 'IncidentManagementOncallRotation',
},
__typename: 'OncallRotationCreatePayload',
},
},
};
export const createRotationResponseWithErrors = {
data: {
oncallRotationCreate: {
errors: ['Houston, we have a problem'],
oncallRotation: {
id: '37',
name: 'Test',
startsAt: '2020-12-17T12:00:00Z',
length: 5,
lengthUnit: 'WEEKS',
participants: {
nodes: [
{
user: { id: 'gid://gitlab/User/50', username: 'project_1_bot3', __typename: 'User' },
colorWeight: '500',
colorPalette: 'blue',
__typename: 'OncallParticipantType',
},
],
__typename: 'OncallParticipantTypeConnection',
},
__typename: 'IncidentManagementOncallRotation',
},
__typename: 'OncallRotationCreatePayload',
},
},
};
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddEditRotationModal renders rotation modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
modalclass=""
modalid="addRotationModal"
size="sm"
title="Add rotation"
titletag="h4"
>
<!---->
<add-edit-rotation-form-stub
form="[object Object]"
participants=""
schedule="[object Object]"
/>
</gl-modal-stub>
`;
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import { GlDropdownItem, GlTokenSelector } from '@gitlab/ui';
import AddEditRotationForm from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue';
import { LENGTH_ENUM } from 'ee/oncall_schedules/constants';
import { participants, getOncallSchedulesQueryResponse } from '../../mocks/apollo_mock';
const projectPath = 'group/project';
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
describe('AddEditRotationForm', () => {
let wrapper;
const createComponent = ({ data = {}, props = {} } = {}) => {
wrapper = shallowMount(AddEditRotationForm, {
data() {
return {
...data,
};
},
propsData: {
...props,
schedule,
isLoading: false,
rotationNameIsValid: true,
rotationParticipantsAreValid: true,
rotationStartsAtIsValid: true,
participants,
form: {
name: '',
participants: [],
rotationLength: {
length: 1,
unit: LENGTH_ENUM.hours,
},
startsAt: {
date: null,
time: 0,
},
},
},
provide: {
projectPath,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findRotationLength = () => wrapper.find('[id = "rotation-length"]');
const findRotationStartsOn = () => wrapper.find('[id = "rotation-time"]');
const findUserSelector = () => wrapper.find(GlTokenSelector);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
describe('Rotation length and start time', () => {
it('renders the rotation length value', async () => {
const rotationLength = findRotationLength();
expect(rotationLength.exists()).toBe(true);
expect(rotationLength.attributes('value')).toBe('1');
});
it('renders the rotation starts on datepicker', async () => {
const startsOn = findRotationStartsOn();
expect(startsOn.exists()).toBe(true);
expect(startsOn.attributes('text')).toBe('00:00');
expect(startsOn.attributes('headertext')).toBe('');
});
it('should add a check for a rotation length type selected', async () => {
const selectedLengthType1 = findDropdownOptions().at(0);
const selectedLengthType2 = findDropdownOptions().at(1);
selectedLengthType1.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(selectedLengthType1.props('isChecked')).toBe(true);
expect(selectedLengthType2.props('isChecked')).toBe(false);
});
});
describe('filter participants', () => {
beforeEach(() => {
createComponent();
});
it('has user options that are populated via apollo', () => {
expect(findUserSelector().props('dropdownItems')).toHaveLength(participants.length);
});
it('calls the API and sets dropdown items as request result', async () => {
const tokenSelector = findUserSelector();
tokenSelector.vm.$emit('focus');
tokenSelector.vm.$emit('blur');
tokenSelector.vm.$emit('focus');
await waitForPromises();
expect(tokenSelector.props('dropdownItems')).toMatchObject(participants);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
it('emits `input` event with selected users', () => {
findUserSelector().vm.$emit('input', participants);
expect(findUserSelector().emitted().input[0][0]).toEqual(participants);
});
it('when text input is blurred the text input clears', async () => {
const tokenSelector = findUserSelector();
tokenSelector.vm.$emit('blur');
await wrapper.vm.$nextTick();
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
});
......@@ -2,12 +2,23 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import { GlDropdownItem, GlModal, GlAlert, GlTokenSelector } from '@gitlab/ui';
import { addRotationModalId } from 'ee/oncall_schedules/components/oncall_schedule';
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 { GlModal, GlAlert } from '@gitlab/ui';
import { addRotationModalId } from 'ee/oncall_schedules/constants';
import AddEditRotationModal, {
i18n,
} from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue';
import getOncallSchedulesQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mutations/create_oncall_schedule_rotation.mutation.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { getOncallSchedulesQueryResponse, participants } from '../../mocks/apollo_mock';
import {
participants,
getOncallSchedulesQueryResponse,
createRotationResponse,
createRotationResponseWithErrors,
} from '../../mocks/apollo_mock';
jest.mock('~/flash');
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
......@@ -16,12 +27,11 @@ const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
localVue.use(VueApollo);
describe('AddRotationModal', () => {
describe('AddEditRotationModal', () => {
let wrapper;
let fakeApollo;
let userSearchQueryHandler;
let createRotationHandler;
async function awaitApolloDomMock() {
await wrapper.vm.$nextTick(); // kick off the DOM update
......@@ -29,8 +39,12 @@ describe('AddRotationModal', () => {
await wrapper.vm.$nextTick(); // kick off the DOM update for flash
}
async function createRotation(localWrapper) {
localWrapper.find(GlModal).vm.$emit('primary', { preventDefault: jest.fn() });
}
const createComponent = ({ data = {}, props = {}, loading = false } = {}) => {
wrapper = shallowMount(AddRotationModal, {
wrapper = shallowMount(AddEditRotationModal, {
data() {
return {
...data,
......@@ -55,13 +69,31 @@ describe('AddRotationModal', () => {
},
},
});
wrapper.vm.$refs.createScheduleRotationModal.hide = mockHideModal;
wrapper.vm.$refs.addEditScheduleRotationModal.hide = mockHideModal;
};
const createComponentWithApollo = ({ search = '' } = {}) => {
fakeApollo = createMockApollo([[usersSearchQuery, userSearchQueryHandler]]);
const createComponentWithApollo = ({
search = '',
createHandler = jest.fn().mockResolvedValue(createRotationResponse),
} = {}) => {
createRotationHandler = createHandler;
localVue.use(VueApollo);
fakeApollo = createMockApollo([
[getOncallSchedulesQuery, jest.fn().mockResolvedValue(getOncallSchedulesQueryResponse)],
[usersSearchQuery, userSearchQueryHandler],
[createOncallScheduleRotationMutation, createRotationHandler],
]);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getOncallSchedulesQuery,
variables: {
projectPath: 'group/project',
},
data: getOncallSchedulesQueryResponse.data,
});
wrapper = shallowMount(AddRotationModal, {
wrapper = shallowMount(AddEditRotationModal, {
localVue,
propsData: {
modalId: addRotationModalId,
......@@ -81,6 +113,8 @@ describe('AddRotationModal', () => {
projectPath,
},
});
wrapper.vm.$refs.addEditScheduleRotationModal.hide = mockHideModal;
};
beforeEach(() => {
......@@ -93,91 +127,26 @@ describe('AddRotationModal', () => {
});
const findModal = () => wrapper.find(GlModal);
const findRotationLength = () => wrapper.find('[id = "rotation-length"]');
const findRotationStartsOn = () => wrapper.find('[id = "rotation-time"]');
const findUserSelector = () => wrapper.find(GlTokenSelector);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
const findAlert = () => wrapper.find(GlAlert);
it('renders rotation modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('Rotation length and start time', () => {
it('renders the rotation length value', async () => {
const rotationLength = findRotationLength();
expect(rotationLength.exists()).toBe(true);
expect(rotationLength.attributes('value')).toBe('1');
});
it('renders the rotation starts on datepicker', async () => {
const startsOn = findRotationStartsOn();
expect(startsOn.exists()).toBe(true);
expect(startsOn.attributes('text')).toBe('00:00');
expect(startsOn.attributes('headertext')).toBe('');
});
it('should add a check for a rotation length type selected', async () => {
const selectedLengthType1 = findDropdownOptions().at(0);
const selectedLengthType2 = findDropdownOptions().at(1);
selectedLengthType1.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(selectedLengthType1.props('isChecked')).toBe(true);
expect(selectedLengthType2.props('isChecked')).toBe(false);
});
});
describe('filter participants', () => {
beforeEach(() => {
createComponent({ data: { participants } });
});
it('has user options that are populated via apollo', () => {
expect(findUserSelector().props('dropdownItems').length).toBe(participants.length);
});
it('calls the API and sets dropdown items as request result', async () => {
const tokenSelector = findUserSelector();
tokenSelector.vm.$emit('focus');
tokenSelector.vm.$emit('blur');
tokenSelector.vm.$emit('focus');
await waitForPromises();
expect(tokenSelector.props('dropdownItems')).toMatchObject(participants);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
it('emits `input` event with selected users', () => {
findUserSelector().vm.$emit('input', participants);
expect(findUserSelector().emitted().input[0][0]).toEqual(participants);
});
it('when text input is blurred the text input clears', async () => {
const tokenSelector = findUserSelector();
tokenSelector.vm.$emit('blur');
await wrapper.vm.$nextTick();
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
describe('Rotation create', () => {
it('makes a request with `oncallScheduleRotationCreate` to create a schedule rotation', () => {
it('makes a request with `oncallRotationCreate` to create a schedule rotation', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
variables: { oncallScheduleRotationCreate: expect.objectContaining({ projectPath }) },
update: expect.anything(),
variables: { OncallRotationCreateInput: expect.objectContaining({ projectPath }) },
});
});
it('does not hide the rotation modal and shows error alert on fail', async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { oncallScheduleRotationCreate: { errors: [error] } } });
mutate.mockResolvedValueOnce({ data: { oncallRotationCreate: { errors: [error] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).not.toHaveBeenCalled();
......@@ -200,6 +169,31 @@ describe('AddRotationModal', () => {
expect(userSearchQueryHandler).toHaveBeenCalledWith({ search: 'root' });
});
// TODO: Once the BE is complete for the mutation add specs here for that via a creationHandler
it('calls a mutation with correct parameters and creates a rotation', async () => {
createComponentWithApollo();
await createRotation(wrapper);
await awaitApolloDomMock();
expect(mockHideModal).toHaveBeenCalled();
expect(createRotationHandler).toHaveBeenCalled();
expect(createFlash).toHaveBeenCalledWith({
message: i18n.rotationCreated,
type: FLASH_TYPES.SUCCESS,
});
});
it('displays alert if mutation had a recoverable error', async () => {
createComponentWithApollo({
createHandler: jest.fn().mockResolvedValue(createRotationResponseWithErrors),
});
await createRotation(wrapper);
await awaitApolloDomMock();
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('Houston, we have a problem');
});
});
});
......@@ -24,7 +24,9 @@ exports[`RotationsListSectionComponent renders component layout 1`] = `
buttontextclasses=""
category="tertiary"
icon="pencil"
role="button"
size="medium"
tabindex="0"
title="Edit rotation"
variant="default"
/>
......@@ -34,7 +36,9 @@ exports[`RotationsListSectionComponent renders component layout 1`] = `
buttontextclasses=""
category="tertiary"
icon="remove"
role="button"
size="medium"
tabindex="0"
title="Delete rotation"
variant="default"
/>
......
import { shallowMount } from '@vue/test-utils';
import { GlCard } from '@gitlab/ui';
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/constants';
import { getOncallSchedulesQueryResponse } from '../../mocks/apollo_mock';
describe('TimelineSectionComponent', () => {
let wrapper;
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
function mountComponent({
presetType = PRESET_TYPES.WEEKS,
......@@ -18,9 +20,7 @@ describe('TimelineSectionComponent', () => {
propsData: {
presetType,
timeframe,
},
stubs: {
GlCard,
schedule,
},
});
}
......@@ -30,10 +30,8 @@ describe('TimelineSectionComponent', () => {
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
wrapper.destroy();
wrapper = null;
});
it('renders component container element with class `timeline-section`', () => {
......
......@@ -19535,6 +19535,15 @@ msgstr ""
msgid "OnCallSchedules|Sets the default timezone for the schedule, for all participants"
msgstr ""
msgid "OnCallSchedules|Successfully created a new rotation"
msgstr ""
msgid "OnCallSchedules|Successfully edited your rotation"
msgstr ""
msgid "OnCallSchedules|The rotation could not be updated. Please try again."
msgstr ""
msgid "OnCallSchedules|The schedule could not be deleted. Please try again."
msgstr ""
......
......@@ -731,3 +731,28 @@ describe('datesMatch', () => {
expect(datetimeUtility.datesMatch(date1, date2)).toBe(expected);
});
});
describe('format24HourTimeStringFromInt', () => {
const expectedFormattedTimes = [
[0, '00:00'],
[2, '02:00'],
[6, '06:00'],
[9, '09:00'],
[10, '10:00'],
[16, '16:00'],
[22, '22:00'],
[32, ''],
[NaN, ''],
['Invalid Int', ''],
[null, ''],
[undefined, ''],
];
expectedFormattedTimes.forEach(([timeInt, expectedTimeStringIn24HourNotation]) => {
it(`formats ${timeInt} as ${expectedTimeStringIn24HourNotation}`, () => {
expect(datetimeUtility.format24HourTimeStringFromInt(timeInt)).toBe(
expectedTimeStringIn24HourNotation,
);
});
});
});
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