Commit 5f003f9d authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '262857-add-a-rotation-modal-dor-graphql-specs' into 'master'

Add rotation modal to create a new rotation and handle feedback

See merge request gitlab-org/gitlab!50268
parents ceaefe24 eacecc62
......@@ -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