Commit 80cfd7e6 authored by Robert Speicher's avatar Robert Speicher

Merge branch '262862-edit-rotation-graphql-mutation' into 'master'

Add OncallRotationUpdate mutation

See merge request gitlab-org/gitlab!55955
parents ef623125 6183a9df
---
title: Add update GraphQL mutation for Oncall Rotations
merge_request: 55955
author:
type: added
...@@ -4316,6 +4316,16 @@ Autogenerated return type of OncallRotationDestroy. ...@@ -4316,6 +4316,16 @@ Autogenerated return type of OncallRotationDestroy.
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `oncallRotation` | [`IncidentManagementOncallRotation`](#incidentmanagementoncallrotation) | The on-call rotation. | | `oncallRotation` | [`IncidentManagementOncallRotation`](#incidentmanagementoncallrotation) | The on-call rotation. |
### `OncallRotationUpdatePayload`
Autogenerated return type of OncallRotationUpdate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `oncallRotation` | [`IncidentManagementOncallRotation`](#incidentmanagementoncallrotation) | The on-call rotation. |
### `OncallScheduleCreatePayload` ### `OncallScheduleCreatePayload`
Autogenerated return type of OncallScheduleCreate. Autogenerated return type of OncallScheduleCreate.
......
...@@ -94,6 +94,7 @@ export default { ...@@ -94,6 +94,7 @@ export default {
presetType: this.$options.PRESET_TYPES.WEEKS, presetType: this.$options.PRESET_TYPES.WEEKS,
timeframeStartDate: new Date(), timeframeStartDate: new Date(),
rotations: this.schedule.rotations.nodes, rotations: this.schedule.rotations.nodes,
rotationToUpdate: {},
}; };
}, },
computed: { computed: {
...@@ -163,6 +164,9 @@ export default { ...@@ -163,6 +164,9 @@ export default {
fetchRotationShifts() { fetchRotationShifts() {
this.$apollo.queries.rotations.refetch(); this.$apollo.queries.rotations.refetch();
}, },
setRotationToUpdate(rotation) {
this.rotationToUpdate = rotation;
},
}, },
}; };
</script> </script>
...@@ -249,6 +253,7 @@ export default { ...@@ -249,6 +253,7 @@ export default {
:timeframe="timeframe" :timeframe="timeframe"
:schedule-iid="schedule.iid" :schedule-iid="schedule.iid"
:loading="loading" :loading="loading"
@set-rotation-to-update="setRotationToUpdate"
/> />
</div> </div>
</gl-card> </gl-card>
...@@ -262,12 +267,14 @@ export default { ...@@ -262,12 +267,14 @@ export default {
<add-edit-rotation-modal <add-edit-rotation-modal
:schedule="schedule" :schedule="schedule"
:modal-id="$options.addRotationModalId" :modal-id="$options.addRotationModalId"
@fetchRotationShifts="fetchRotationShifts" @fetch-rotation-shifts="fetchRotationShifts"
/> />
<add-edit-rotation-modal <add-edit-rotation-modal
:schedule="schedule" :schedule="schedule"
:modal-id="$options.editRotationModalId" :modal-id="$options.editRotationModalId"
:rotation="rotationToUpdate"
is-edit-mode is-edit-mode
@fetch-rotation-shifts="fetchRotationShifts"
/> />
</div> </div>
</template> </template>
...@@ -97,12 +97,6 @@ export default { ...@@ -97,12 +97,6 @@ export default {
default: () => ({}), default: () => ({}),
}, },
}, },
data() {
return {
participantsArr: [],
endDateEnabled: false,
};
},
methods: { methods: {
format24HourTimeStringFromInt, format24HourTimeStringFromInt,
}, },
...@@ -121,7 +115,8 @@ export default { ...@@ -121,7 +115,8 @@ export default {
> >
<gl-form-input <gl-form-input
id="rotation-name" id="rotation-name"
@blur="$emit('update-rotation-form', { type: 'name', value: $event.target.value })" :value="form.name"
@change="$emit('update-rotation-form', { type: 'name', value: $event })"
/> />
</gl-form-group> </gl-form-group>
...@@ -133,14 +128,14 @@ export default { ...@@ -133,14 +128,14 @@ export default {
:state="validationState.participants" :state="validationState.participants"
> >
<gl-token-selector <gl-token-selector
v-model="participantsArr" :selected-tokens="form.participants"
:dropdown-items="participants" :dropdown-items="participants"
:loading="isLoading" :loading="isLoading"
container-class="gl-h-13! gl-overflow-y-auto" container-class="gl-h-13! gl-overflow-y-auto"
menu-class="gl-overflow-y-auto" menu-class="gl-overflow-y-auto"
@text-input="$emit('filter-participants', $event)" @text-input="$emit('filter-participants', $event)"
@blur="$emit('update-rotation-form', { type: 'participants', value: participantsArr })" @blur="$emit('update-rotation-form', { type: 'participants', value: form.participants })"
@input="$emit('update-rotation-form', { type: 'participants', value: participantsArr })" @input="$emit('update-rotation-form', { type: 'participants', value: $event })"
> >
<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" />
...@@ -169,7 +164,7 @@ export default { ...@@ -169,7 +164,7 @@ export default {
type="number" type="number"
class="gl-w-12 gl-mr-3" class="gl-w-12 gl-mr-3"
min="1" min="1"
:value="1" :value="form.rotationLength.length"
@input="$emit('update-rotation-form', { type: 'rotationLength.length', value: $event })" @input="$emit('update-rotation-form', { type: 'rotationLength.length', value: $event })"
/> />
<gl-dropdown :text="form.rotationLength.unit.toLowerCase()"> <gl-dropdown :text="form.rotationLength.unit.toLowerCase()">
...@@ -195,6 +190,7 @@ export default { ...@@ -195,6 +190,7 @@ export default {
<div class="gl-display-flex gl-align-items-center"> <div class="gl-display-flex gl-align-items-center">
<gl-datepicker <gl-datepicker
class="gl-mr-3" class="gl-mr-3"
:value="form.startsAt.date"
@input="$emit('update-rotation-form', { type: 'startsAt.date', value: $event })" @input="$emit('update-rotation-form', { type: 'startsAt.date', value: $event })"
> >
<template #default="{ formattedDate }"> <template #default="{ formattedDate }">
...@@ -233,14 +229,17 @@ export default { ...@@ -233,14 +229,17 @@ export default {
</div> </div>
<div class="gl-display-inline-block"> <div class="gl-display-inline-block">
<gl-toggle <gl-toggle
v-model="endDateEnabled" :value="form.isEndDateEnabled"
:label="$options.i18n.fields.endsAt.enableToggle" :label="$options.i18n.fields.endsAt.enableToggle"
label-position="left" label-position="left"
class="gl-mb-5" class="gl-mb-5"
@change="
$emit('update-rotation-form', { type: 'isEndDateEnabled', value: !form.isEndDateEnabled })
"
/> />
<gl-card <gl-card
v-if="endDateEnabled" v-if="form.isEndDateEnabled"
data-testid="rotation-ends-on" data-testid="rotation-ends-on"
class="gl-border-gray-400 gl-bg-gray-10" class="gl-border-gray-400 gl-bg-gray-10"
> >
...@@ -254,6 +253,7 @@ export default { ...@@ -254,6 +253,7 @@ export default {
<div class="gl-display-flex gl-align-items-center"> <div class="gl-display-flex gl-align-items-center">
<gl-datepicker <gl-datepicker
class="gl-mr-3" class="gl-mr-3"
:value="form.endsAt.date"
@input="$emit('update-rotation-form', { type: 'endsAt.date', value: $event })" @input="$emit('update-rotation-form', { type: 'endsAt.date', value: $event })"
> >
<template #default="{ formattedDate }"> <template #default="{ formattedDate }">
......
...@@ -6,7 +6,12 @@ import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mu ...@@ -6,7 +6,12 @@ import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mu
import updateOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql'; import updateOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql';
import getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql'; import getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
import { updateStoreAfterRotationEdit } from 'ee/oncall_schedules/utils/cache_updates'; import { updateStoreAfterRotationEdit } from 'ee/oncall_schedules/utils/cache_updates';
import { isNameFieldValid, getParticipantsForSave } from 'ee/oncall_schedules/utils/common_utils'; import {
isNameFieldValid,
getParticipantsForSave,
parseHour,
parseRotationDate,
} from 'ee/oncall_schedules/utils/common_utils';
import createFlash, { FLASH_TYPES } from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql'; import searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql';
import { format24HourTimeStringFromInt, formatDate } from '~/lib/utils/datetime_utility'; import { format24HourTimeStringFromInt, formatDate } from '~/lib/utils/datetime_utility';
...@@ -32,6 +37,7 @@ export const formEmptyState = { ...@@ -32,6 +37,7 @@ export const formEmptyState = {
date: null, date: null,
time: 0, time: 0,
}, },
isEndDateEnabled: false,
endsAt: { endsAt: {
date: null, date: null,
time: 0, time: 0,
...@@ -43,6 +49,13 @@ export const formEmptyState = { ...@@ -43,6 +49,13 @@ export const formEmptyState = {
}, },
}; };
const validiationInitialState = {
name: true,
participants: true,
startsAt: true,
endsAt: true,
};
export default { export default {
i18n, i18n,
components: { components: {
...@@ -61,6 +74,11 @@ export default { ...@@ -61,6 +74,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
rotation: {
type: Object,
required: false,
default: () => ({}),
},
schedule: { schedule: {
type: Object, type: Object,
required: true, required: true,
...@@ -90,12 +108,7 @@ export default { ...@@ -90,12 +108,7 @@ export default {
ptSearchTerm: '', ptSearchTerm: '',
form: cloneDeep(formEmptyState), form: cloneDeep(formEmptyState),
error: '', error: '',
validationState: { validationState: cloneDeep(validiationInitialState),
name: true,
participants: true,
startsAt: true,
endsAt: true,
},
}; };
}, },
computed: { computed: {
...@@ -134,34 +147,35 @@ export default { ...@@ -134,34 +147,35 @@ export default {
participants, participants,
startsAt: { date: startDate, time: startTime }, startsAt: { date: startDate, time: startTime },
endsAt: { date: endDate, time: endTime }, endsAt: { date: endDate, time: endTime },
isEndDateEnabled,
isRestrictedToTime,
restrictedTo: { startTime: activeStartTime, endTime: activeEndTime },
} = this.form; } = this.form;
const variables = { const variables = {
projectPath: this.projectPath,
scheduleIid: this.schedule.iid,
name, name,
participants: getParticipantsForSave(participants),
rotationLength: {
...rotationLength,
length: parseInt(rotationLength.length, 10),
},
startsAt: { startsAt: {
date: formatDate(startDate, 'yyyy-mm-dd'), date: formatDate(startDate, 'yyyy-mm-dd'),
time: format24HourTimeStringFromInt(startTime), time: format24HourTimeStringFromInt(startTime),
}, },
endsAt: endDate endsAt: isEndDateEnabled
? { ? {
date: formatDate(endDate, 'yyyy-mm-dd'), date: formatDate(endDate, 'yyyy-mm-dd'),
time: format24HourTimeStringFromInt(endTime), time: format24HourTimeStringFromInt(endTime),
} }
: null, : null,
rotationLength: { activePeriod: isRestrictedToTime
...rotationLength, ? {
length: parseInt(rotationLength.length, 10), startTime: format24HourTimeStringFromInt(activeStartTime),
}, endTime: format24HourTimeStringFromInt(activeEndTime),
participants: getParticipantsForSave(participants),
};
if (this.form.isRestrictedToTime) {
variables.activePeriod = {
startTime: format24HourTimeStringFromInt(this.form.restrictedTo.startTime),
endTime: format24HourTimeStringFromInt(this.form.restrictedTo.endTime),
};
} }
: null,
};
return variables; return variables;
}, },
title() { title() {
...@@ -185,11 +199,15 @@ export default { ...@@ -185,11 +199,15 @@ export default {
methods: { methods: {
createRotation() { createRotation() {
this.loading = true; this.loading = true;
const input = {
...this.rotationVariables,
projectPath: this.projectPath,
scheduleIid: this.schedule.iid,
};
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: createOncallScheduleRotationMutation, mutation: createOncallScheduleRotationMutation,
variables: { input: this.rotationVariables }, variables: { input },
}) })
.then( .then(
({ ({
...@@ -204,7 +222,7 @@ export default { ...@@ -204,7 +222,7 @@ export default {
} }
this.$refs.addEditScheduleRotationModal.hide(); this.$refs.addEditScheduleRotationModal.hide();
this.$emit('fetchRotationShifts'); this.$emit('fetch-rotation-shifts');
return createFlash({ return createFlash({
message: this.$options.i18n.rotationCreated, message: this.$options.i18n.rotationCreated,
type: FLASH_TYPES.SUCCESS, type: FLASH_TYPES.SUCCESS,
...@@ -221,11 +239,14 @@ export default { ...@@ -221,11 +239,14 @@ export default {
editRotation() { editRotation() {
this.loading = true; this.loading = true;
const { projectPath, schedule } = this; const { projectPath, schedule } = this;
const input = {
...this.rotationVariables,
id: this.rotation.id,
};
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: updateOncallScheduleRotationMutation, mutation: updateOncallScheduleRotationMutation,
variables: { input: this.rotationVariables }, variables: { input },
update(store, { data }) { update(store, { data }) {
updateStoreAfterRotationEdit( updateStoreAfterRotationEdit(
store, store,
...@@ -250,6 +271,7 @@ export default { ...@@ -250,6 +271,7 @@ export default {
} }
this.$refs.addEditScheduleRotationModal.hide(); this.$refs.addEditScheduleRotationModal.hide();
this.$emit('fetch-rotation-shifts');
return createFlash({ return createFlash({
message: this.$options.i18n.editedRotation, message: this.$options.i18n.editedRotation,
type: FLASH_TYPES.SUCCESS, type: FLASH_TYPES.SUCCESS,
...@@ -282,8 +304,45 @@ export default { ...@@ -282,8 +304,45 @@ export default {
this.validationState.endsAt = this.isEndDateValid; this.validationState.endsAt = this.isEndDateValid;
} }
}, },
afterCloseModal() { beforeShowModal() {
if (this.isEditMode) {
this.parseRotation();
}
},
resetModal() {
this.form = cloneDeep(formEmptyState); this.form = cloneDeep(formEmptyState);
this.validationState = cloneDeep(validiationInitialState);
this.error = '';
},
parseRotation() {
const scheduleTimezone = this.schedule.timezone;
this.form.name = this.rotation.name;
const participants =
this.rotation?.participants?.nodes?.map(({ user }) => ({ ...user })) ?? [];
this.form.participants = participants;
this.form.rotationLength = {
length: this.rotation.length,
unit: this.rotation.lengthUnit,
};
if (this.rotation.startsAt) {
this.form.startsAt = parseRotationDate(this.rotation.startsAt, scheduleTimezone);
}
if (this.rotation.endsAt) {
this.form.isEndDateEnabled = true;
this.form.endsAt = parseRotationDate(this.rotation.endsAt, scheduleTimezone);
}
if (this.rotation?.activePeriod?.startTime) {
const { activePeriod } = this.rotation;
this.form.isRestrictedToTime = true;
this.form.restrictedTo.startTime = parseHour(activePeriod.startTime);
this.form.restrictedTo.endTime = parseHour(activePeriod.endTime);
}
}, },
}, },
}; };
...@@ -298,7 +357,8 @@ export default { ...@@ -298,7 +357,8 @@ export default {
:action-cancel="actionsProps.cancel" :action-cancel="actionsProps.cancel"
modal-class="rotations-modal" modal-class="rotations-modal"
@primary.prevent="isEditMode ? editRotation() : createRotation()" @primary.prevent="isEditMode ? editRotation() : createRotation()"
@hide="afterCloseModal" @show="beforeShowModal"
@hide="resetModal"
> >
<gl-alert v-if="error" variant="danger" @dismiss="error = ''"> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error || $options.i18n.errorMsg }} {{ error || $options.i18n.errorMsg }}
......
...@@ -89,6 +89,7 @@ export default { ...@@ -89,6 +89,7 @@ export default {
methods: { methods: {
setRotationToUpdate(rotation) { setRotationToUpdate(rotation) {
this.rotationToUpdate = rotation; this.rotationToUpdate = rotation;
this.$emit('set-rotation-to-update', rotation);
}, },
cellShouldHideOverflow(index) { cellShouldHideOverflow(index) {
return index + 1 === this.timeframe.length || this.presetIsDay; return index + 1 === this.timeframe.length || this.presetIsDay;
...@@ -126,11 +127,9 @@ export default { ...@@ -126,11 +127,9 @@ export default {
> >
<span class="gl-text-truncated">{{ rotation.name }}</span> <span class="gl-text-truncated">{{ rotation.name }}</span>
<gl-button-group class="gl-px-2"> <gl-button-group class="gl-px-2">
<!-- TODO: Un-hide this button when: https://gitlab.com/gitlab-org/gitlab/-/issues/262862 is completed -->
<gl-button <gl-button
v-gl-modal="$options.editRotationModalId" v-gl-modal="$options.editRotationModalId"
v-gl-tooltip v-gl-tooltip
class="gl-display-none"
category="tertiary" category="tertiary"
:title="$options.i18n.editRotationLabel" :title="$options.i18n.editRotationLabel"
icon="pencil" icon="pencil"
......
fragment OnCallParticipant on OncallParticipantType { fragment OnCallParticipant on OncallParticipantType {
user { user {
id id
name
username username
avatarUrl avatarUrl
} }
......
import { newDateAsLocaleTime } from '~/lib/utils/datetime_utility';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import { ASSIGNEE_COLORS_COMBO } from '../constants'; import { ASSIGNEE_COLORS_COMBO } from '../constants';
...@@ -52,3 +53,36 @@ export const getParticipantsForSave = (participants) => ...@@ -52,3 +53,36 @@ export const getParticipantsForSave = (participants) =>
colorPalette, colorPalette,
}; };
}); });
/**
* Parses a activePeriod string into an integer value
*
* @param {String} hourString
*/
export const parseHour = (hourString) => parseInt(hourString.slice(0, 2), 10);
/**
* Parses a rotation date for use in the add/edit rotation form
*
* @param {ISOString} dateTimeString
* @param {Timezone string - long} scheduleTimezone
*/
export const parseRotationDate = (dateTimeString, scheduleTimezone) => {
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hour12: false, // The time picker uses 24 hour time
timeZone: scheduleTimezone,
timeZoneName: 'long',
};
const formatter = new Intl.DateTimeFormat('en-US', options);
const parts = formatter.formatToParts(Date.parse(dateTimeString));
const [month, , day, , year, , hour] = parts.map((part) => part.value);
// The datepicker uses local time
const date = newDateAsLocaleTime(`${year}-${month}-${day}`);
const time = parseInt(hour, 10);
return { date, time };
};
...@@ -67,6 +67,7 @@ module EE ...@@ -67,6 +67,7 @@ module EE
mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Update mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Update
mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Destroy mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Destroy
mount_mutation ::Mutations::IncidentManagement::OncallRotation::Create mount_mutation ::Mutations::IncidentManagement::OncallRotation::Create
mount_mutation ::Mutations::IncidentManagement::OncallRotation::Update
mount_mutation ::Mutations::IncidentManagement::OncallRotation::Destroy mount_mutation ::Mutations::IncidentManagement::OncallRotation::Destroy
mount_mutation ::Mutations::AppSec::Fuzzing::Api::CiConfiguration::Create mount_mutation ::Mutations::AppSec::Fuzzing::Api::CiConfiguration::Create
......
...@@ -4,6 +4,9 @@ module Mutations ...@@ -4,6 +4,9 @@ module Mutations
module IncidentManagement module IncidentManagement
module OncallRotation module OncallRotation
class Base < BaseMutation class Base < BaseMutation
MAXIMUM_PARTICIPANTS = 100
TIME_FORMAT = /^(0\d|1\d|2[0-3]):[0-5]\d$/.freeze
field :oncall_rotation, field :oncall_rotation,
::Types::IncidentManagement::OncallRotationType, ::Types::IncidentManagement::OncallRotationType,
null: true, null: true,
...@@ -33,6 +36,100 @@ module Mutations ...@@ -33,6 +36,100 @@ module Mutations
::IncidentManagement::OncallRotationsFinder.new(current_user, project, schedule, args).execute.first ::IncidentManagement::OncallRotationsFinder.new(current_user, project, schedule, args).execute.first
end end
def parsed_params(schedule, participants, args)
params = args.slice(:name)
params[:participants] = find_participants(participants)
params[:starts_at] = parse_datetime(schedule, args[:starts_at]) if args[:starts_at]
params[:ends_at] = parse_datetime(schedule, args[:ends_at]) if args.key?(:ends_at)
if args[:rotation_length]
params.merge!(
length: args.dig(:rotation_length, :length),
length_unit: args.dig(:rotation_length, :unit)
)
end
if args.key?(:active_period)
active_period_start, active_period_end = active_period_times(args)
params.merge!(
active_period_start: active_period_start,
active_period_end: active_period_end
)
end
params
end
def parse_datetime(schedule, timestamp)
timestamp&.asctime&.in_time_zone(schedule.timezone)
end
def find_participants(user_array)
return if user_array.nil?
return [] if user_array == []
raise_too_many_users_error if user_array.size > MAXIMUM_PARTICIPANTS
usernames = user_array.map {|h| h[:username] }
raise_duplicate_users_error if usernames.size != usernames.uniq.size
matched_users = UsersFinder.new(current_user, username: usernames).execute.index_by(&:username)
raise_user_not_found if matched_users.size != user_array.size
user_array.map { |param| param.to_h.merge(user: matched_users[param[:username]]) }
end
def active_period_times(args)
active_period_args = args.dig(:active_period)
return [nil, nil] if active_period_args.blank?
start_time = active_period_args[:start_time]
end_time = active_period_args[:end_time]
raise invalid_time_error unless TIME_FORMAT.match?(start_time)
raise invalid_time_error unless TIME_FORMAT.match?(end_time)
# We parse the times into dates to compare.
# Time.parse parses a timestamp into a Time with todays date
# Time.parse("22:11") => 2021-02-23 22:11:00 +0000
parsed_from = Time.parse(start_time)
parsed_to = Time.parse(end_time)
# Overnight shift times will be supported via
# https://gitlab.com/gitlab-org/gitlab/-/issues/322079
if parsed_to < parsed_from
raise ::Gitlab::Graphql::Errors::ArgumentError, "'start_time' time must be before 'end_time' time"
end
[start_time, end_time]
end
def raise_project_not_found
raise Gitlab::Graphql::Errors::ArgumentError, 'The project could not be found'
end
def raise_schedule_not_found
raise Gitlab::Graphql::Errors::ArgumentError, 'The schedule could not be found'
end
def raise_too_many_users_error
raise Gitlab::Graphql::Errors::ArgumentError, "A maximum of #{MAXIMUM_PARTICIPANTS} participants can be added"
end
def raise_duplicate_users_error
raise Gitlab::Graphql::Errors::ArgumentError, "A duplicate username is included in the participant list"
end
def raise_user_not_found
raise Gitlab::Graphql::Errors::ArgumentError, "A provided username couldn't be matched to a user"
end
def invalid_time_error
::Gitlab::Graphql::Errors::ArgumentError.new 'Time given is invalid'
end
end end
end end
end end
......
...@@ -40,10 +40,7 @@ module Mutations ...@@ -40,10 +40,7 @@ module Mutations
argument :participants, argument :participants,
[Types::IncidentManagement::OncallUserInputType], [Types::IncidentManagement::OncallUserInputType],
required: true, required: true,
description: 'The usernames of users participating in the on-call rotation.' description: 'The usernames of users participating in the on-call rotation. A maximum limit of 100 participants applies.'
MAXIMUM_PARTICIPANTS = 100
TIME_FORMAT = /^(0\d|1\d|2[0-3]):[0-5]\d$/.freeze
def resolve(iid:, project_path:, participants:, **args) def resolve(iid:, project_path:, participants:, **args)
project = Project.find_by_full_path(project_path) project = Project.find_by_full_path(project_path)
...@@ -60,7 +57,7 @@ module Mutations ...@@ -60,7 +57,7 @@ module Mutations
schedule, schedule,
project, project,
current_user, current_user,
create_service_params(schedule, participants, args) parsed_params(schedule, participants, args)
).execute ).execute
response(result) response(result)
...@@ -68,95 +65,6 @@ module Mutations ...@@ -68,95 +65,6 @@ module Mutations
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
raise Gitlab::Graphql::Errors::ArgumentError, e.message raise Gitlab::Graphql::Errors::ArgumentError, e.message
end end
private
def create_service_params(schedule, participants, args)
rotation_length = args[:rotation_length][:length]
rotation_length_unit = args[:rotation_length][:unit]
starts_at = parse_datetime(schedule, args[:starts_at])
ends_at = parse_datetime(schedule, args[:ends_at]) if args[:ends_at]
active_period_start, active_period_end = active_period_times(args)
args.slice(:name).merge(
length: rotation_length,
length_unit: rotation_length_unit,
starts_at: starts_at,
ends_at: ends_at,
participants: find_participants(participants),
active_period_start: active_period_start,
active_period_end: active_period_end
)
end
def parse_datetime(schedule, timestamp)
timestamp.asctime.in_time_zone(schedule.timezone)
end
def find_participants(user_array)
raise_too_many_users_error if user_array.size > MAXIMUM_PARTICIPANTS
usernames = user_array.map {|h| h[:username] }
raise_duplicate_users_error if usernames.size != usernames.uniq.size
matched_users = UsersFinder.new(current_user, username: usernames).execute.order_by(:username)
raise_user_not_found if matched_users.size != user_array.size
user_array = user_array.sort_by! { |h| h[:username] }
user_array.map.with_index { |param, i| param.to_h.merge(user: matched_users[i]) }
end
def active_period_times(args)
active_period_args = args.dig(:active_period)
return [nil, nil] if active_period_args.blank?
start_time = active_period_args[:start_time]
end_time = active_period_args[:end_time]
raise invalid_time_error unless TIME_FORMAT.match?(start_time)
raise invalid_time_error unless TIME_FORMAT.match?(end_time)
# We parse the times into dates to compare.
# Time.parse parses a timestamp into a Time with todays date
# Time.parse("22:11") => 2021-02-23 22:11:00 +0000
parsed_from = Time.parse(start_time)
parsed_to = Time.parse(end_time)
# Overnight shift times will be supported via
# https://gitlab.com/gitlab-org/gitlab/-/issues/322079
if parsed_to < parsed_from
raise ::Gitlab::Graphql::Errors::ArgumentError, "'start_time' time must be before 'end_time' time"
end
[start_time, end_time]
end
def raise_project_not_found
raise Gitlab::Graphql::Errors::ArgumentError, 'The project could not be found'
end
def raise_schedule_not_found
raise Gitlab::Graphql::Errors::ArgumentError, 'The schedule could not be found'
end
def raise_too_many_users_error
raise Gitlab::Graphql::Errors::ArgumentError, "A maximum of #{MAXIMUM_PARTICIPANTS} participants can be added"
end
def raise_duplicate_users_error
raise Gitlab::Graphql::Errors::ArgumentError, "A duplicate username is included in the participant list"
end
def raise_user_not_found
raise Gitlab::Graphql::Errors::ArgumentError, "A provided username couldn't be matched to a user"
end
def invalid_time_error
::Gitlab::Graphql::Errors::ArgumentError.new 'Time given is invalid'
end
end end
end end
end end
......
# frozen_string_literal: true
module Mutations
module IncidentManagement
module OncallRotation
class Update < Base
include ResolvesProject
graphql_name 'OncallRotationUpdate'
argument :id, ::Types::GlobalIDType[::IncidentManagement::OncallRotation],
required: true,
description: 'The ID of the on-call schedule to create the on-call rotation in.'
argument :name, GraphQL::STRING_TYPE,
required: false,
description: 'The name of the on-call rotation.'
argument :starts_at, Types::IncidentManagement::OncallRotationDateInputType,
required: false,
description: 'The start date and time of the on-call rotation, in the timezone of the on-call schedule.'
argument :ends_at, Types::IncidentManagement::OncallRotationDateInputType,
required: false,
description: 'The end date and time of the on-call rotation, in the timezone of the on-call schedule.'
argument :rotation_length, Types::IncidentManagement::OncallRotationLengthInputType,
required: false,
description: 'The rotation length of the on-call rotation.'
argument :active_period, Types::IncidentManagement::OncallRotationActivePeriodInputType,
required: false,
description: 'The active period of time that the on-call rotation should take place.'
argument :participants,
[Types::IncidentManagement::OncallUserInputType],
required: false,
description: 'The usernames of users participating in the on-call rotation. A maximum limit of 100 participants applies.'
def resolve(id:, **args)
rotation = authorized_find!(id: id)
result = ::IncidentManagement::OncallRotations::EditService.new(
rotation,
current_user,
parsed_params(rotation.schedule, args[:participants], args)
).execute
response(result)
end
private
def find_object(id:)
GitlabSchema.object_from_id(id, expected_type: ::IncidentManagement::OncallRotation)
end
def raise_rotation_not_found
raise Gitlab::Graphql::Errors::ArgumentError, 'The rotation could not be found'
end
end
end
end
end
...@@ -29,6 +29,11 @@ module Resolvers ...@@ -29,6 +29,11 @@ module Resolvers
result.payload[:shifts] result.payload[:shifts]
end end
# See https://gitlab.com/gitlab-org/gitlab/-/issues/324421
def self.complexity_multiplier(args)
0.005
end
end end
end end
end end
...@@ -9,11 +9,11 @@ module Types ...@@ -9,11 +9,11 @@ module Types
argument :start_time, GraphQL::STRING_TYPE, argument :start_time, GraphQL::STRING_TYPE,
required: true, required: true,
description: 'The start of the rotation active period.' description: 'The start of the rotation active period in 24 hour format, for example "18:30".'
argument :end_time, GraphQL::STRING_TYPE, argument :end_time, GraphQL::STRING_TYPE,
required: true, required: true,
description: 'The end of the rotation active period.' description: 'The end of the rotation active period in 24 hour format, for example "18:30".'
end end
# rubocop: enable Graphql/AuthorizeTypes # rubocop: enable Graphql/AuthorizeTypes
end end
......
...@@ -56,6 +56,10 @@ module Types ...@@ -56,6 +56,10 @@ module Types
description: 'Blocks of time for which a participant is on-call within a given time frame. Time frame cannot exceed one month.', description: 'Blocks of time for which a participant is on-call within a given time frame. Time frame cannot exceed one month.',
max_page_size: MAX_SHIFTS_FOR_TIMEFRAME, max_page_size: MAX_SHIFTS_FOR_TIMEFRAME,
resolver: ::Resolvers::IncidentManagement::OncallShiftsResolver resolver: ::Resolvers::IncidentManagement::OncallShiftsResolver
def participants
object.participants.not_removed
end
end end
end end
end end
...@@ -2,6 +2,8 @@ import { ASSIGNEE_COLORS_COMBO } from 'ee/oncall_schedules/constants'; ...@@ -2,6 +2,8 @@ import { ASSIGNEE_COLORS_COMBO } from 'ee/oncall_schedules/constants';
import { import {
getFormattedTimezone, getFormattedTimezone,
getParticipantsForSave, getParticipantsForSave,
parseHour,
parseRotationDate,
} from 'ee/oncall_schedules/utils/common_utils'; } from 'ee/oncall_schedules/utils/common_utils';
import mockTimezones from './mocks/mock_timezones.json'; import mockTimezones from './mocks/mock_timezones.json';
...@@ -27,3 +29,24 @@ describe('getParticipantsForSave', () => { ...@@ -27,3 +29,24 @@ describe('getParticipantsForSave', () => {
}); });
}); });
}); });
describe('parseRotationDate', () => {
it('parses a rotation date according to the supplied timezone', () => {
const dateTimeString = '2021-01-12T05:04:56.333Z';
const scheduleTimezone = 'Pacific/Honolulu';
const rotationDate = parseRotationDate(dateTimeString, scheduleTimezone);
expect(rotationDate).toStrictEqual({ date: new Date('2021-01-11T00:00:00.000Z'), time: 19 });
});
});
describe('parseHour', () => {
it('parses a rotation active period hour string', () => {
const hourString = '14:00';
const hourInt = parseHour(hourString);
expect(hourInt).toBe(14);
});
});
...@@ -34,9 +34,7 @@ export const getOncallSchedulesQueryResponse = { ...@@ -34,9 +34,7 @@ export const getOncallSchedulesQueryResponse = {
iid: '37', iid: '37',
name: 'Test schedule from query', name: 'Test schedule from query',
description: 'Description 1 lives here', description: 'Description 1 lives here',
timezone: { timezone: 'Pacific/Honolulu',
identifier: 'Pacific/Honolulu',
},
rotations: { nodes: [mockRotations] }, rotations: { nodes: [mockRotations] },
}, },
], ],
...@@ -156,6 +154,7 @@ export const createRotationResponse = { ...@@ -156,6 +154,7 @@ export const createRotationResponse = {
username: 'project_1_bot3', username: 'project_1_bot3',
avatarUrl: invalidUrl, avatarUrl: invalidUrl,
avatar__typename: 'User', avatar__typename: 'User',
name: 'Bot 3',
}, },
colorWeight: '500', colorWeight: '500',
colorPalette: 'blue', colorPalette: 'blue',
...@@ -194,6 +193,7 @@ export const createRotationResponseWithErrors = { ...@@ -194,6 +193,7 @@ export const createRotationResponseWithErrors = {
username: 'project_1_bot3', username: 'project_1_bot3',
avatarUrl: invalidUrl, avatarUrl: invalidUrl,
__typename: 'User', __typename: 'User',
name: 'Bot 3',
}, },
colorWeight: '500', colorWeight: '500',
colorPalette: 'blue', colorPalette: 'blue',
......
[{ [{
"id": "gid://gitlab/IncidentManagement::OncallRotation/2", "id": "gid://gitlab/IncidentManagement::OncallRotation/2",
"name": "Rotation 242", "name": "Rotation 242",
"startsAt": "2021-01-13T10:04:56.333Z", "startsAt": "2021-01-13T11:04:56.333Z",
"endsAt": "2021-03-13T10:04:56.333Z", "endsAt": "2021-03-13T15:04:56.333Z",
"length": 1, "length": 2,
"lengthUnit": "WEEKS", "lengthUnit": "WEEKS",
"activePeriod": { "activePeriod": {
"startTime": "02:00", "startTime": "02:00",
...@@ -15,7 +15,8 @@ ...@@ -15,7 +15,8 @@
"user": { "user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/49", "id": "gid://gitlab/IncidentManagement::OncallParticipant/49",
"username": "nora.schaden", "username": "nora.schaden",
"avatarUrl": "/url" "avatarUrl": "/url",
"name": "nora"
}, },
"colorWeight": "500", "colorWeight": "500",
"colorPalette": "blue" "colorPalette": "blue"
...@@ -32,7 +33,8 @@ ...@@ -32,7 +33,8 @@
"user": { "user": {
"id": "1", "id": "1",
"username": "nora.schaden", "username": "nora.schaden",
"avatarUrl": "/url" "avatarUrl": "/url",
"name": "nora"
} }
}, },
"startsAt": "2021-01-12T10:04:56.333Z", "startsAt": "2021-01-12T10:04:56.333Z",
...@@ -46,7 +48,8 @@ ...@@ -46,7 +48,8 @@
"user": { "user": {
"id": "2", "id": "2",
"username": "racheal.loving", "username": "racheal.loving",
"avatarUrl": "/url" "avatarUrl": "/url",
"name": "racheal"
} }
}, },
"startsAt": "2021-01-16T10:04:56.333Z", "startsAt": "2021-01-16T10:04:56.333Z",
...@@ -58,8 +61,8 @@ ...@@ -58,8 +61,8 @@
{ {
"id": "gid://gitlab/IncidentManagement::OncallRotation/55", "id": "gid://gitlab/IncidentManagement::OncallRotation/55",
"name": "Rotation 242", "name": "Rotation 242",
"startsAt": "2021-01-13T10:04:56.333Z", "startsAt": "2021-01-13T11:04:56.333Z",
"endsAt": "2021-03-13T10:04:56.333Z", "endsAt": "2021-03-13T15:04:56.333Z",
"length": 1, "length": 1,
"lengthUnit": "WEEKS", "lengthUnit": "WEEKS",
"activePeriod": { "activePeriod": {
...@@ -72,7 +75,8 @@ ...@@ -72,7 +75,8 @@
"user": { "user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/99", "id": "gid://gitlab/IncidentManagement::OncallParticipant/99",
"username": "david.oregan", "username": "david.oregan",
"avatarUrl": "/url" "avatarUrl": "/url",
"name": "david"
}, },
"colorWeight": "500", "colorWeight": "500",
"colorPalette": "aqua" "colorPalette": "aqua"
...@@ -87,7 +91,8 @@ ...@@ -87,7 +91,8 @@
"colorWeight": "500", "colorWeight": "500",
"colorPalette": "aqua", "colorPalette": "aqua",
"user": { "user": {
"username": "david.oregan" "username": "david.oregan",
"name": "david"
} }
}, },
"startsAt": "2021-01-14T10:04:56.333Z", "startsAt": "2021-01-14T10:04:56.333Z",
...@@ -99,7 +104,8 @@ ...@@ -99,7 +104,8 @@
"colorWeight": "500", "colorWeight": "500",
"colorPalette": "green", "colorPalette": "green",
"user": { "user": {
"username": "david.keagan" "username": "david.keagan",
"name": "david k"
} }
}, },
"startsAt": "2021-01-21T10:04:56.333Z", "startsAt": "2021-01-21T10:04:56.333Z",
...@@ -125,7 +131,8 @@ ...@@ -125,7 +131,8 @@
"user": { "user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/48", "id": "gid://gitlab/IncidentManagement::OncallParticipant/48",
"username": "root", "username": "root",
"avatarUrl": "/url" "avatarUrl": "/url",
"name": "Administrator"
}, },
"colorWeight": "500", "colorWeight": "500",
"colorPalette": "magenta" "colorPalette": "magenta"
...@@ -140,11 +147,12 @@ ...@@ -140,11 +147,12 @@
"colorWeight": "500", "colorWeight": "500",
"colorPalette": "magenta", "colorPalette": "magenta",
"user": { "user": {
"username": "root" "username": "root",
"name": "Administrator"
} }
}, },
"startsAt": "2021-01-10T10:04:56.333Z", "startsAt": "2021-01-10T10:04:56.333Z",
"endsAt": "2021-01-13T10:04:56.333Z" "endsAt": "2021-01-13T11:04:56.333Z"
}, },
{ {
"participant": { "participant": {
...@@ -152,7 +160,8 @@ ...@@ -152,7 +160,8 @@
"colorWeight": "600", "colorWeight": "600",
"colorPalette": "blue", "colorPalette": "blue",
"user": { "user": {
"username": "root2" "username": "root2",
"name": "Administrator 2"
} }
}, },
"startsAt": "2021-01-15T10:04:56.333Z", "startsAt": "2021-01-15T10:04:56.333Z",
...@@ -178,7 +187,8 @@ ...@@ -178,7 +187,8 @@
"user": { "user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/51", "id": "gid://gitlab/IncidentManagement::OncallParticipant/51",
"username": "oregand", "username": "oregand",
"avatarUrl": "/url" "avatarUrl": "/url",
"name": "david"
}, },
"colorWeight": "600", "colorWeight": "600",
"colorPalette": "orange" "colorPalette": "orange"
...@@ -193,7 +203,8 @@ ...@@ -193,7 +203,8 @@
"colorWeight": "600", "colorWeight": "600",
"colorPalette": "orange", "colorPalette": "orange",
"user": { "user": {
"username": "oregand" "username": "oregand",
"name": "david"
} }
}, },
"startsAt": "2021-01-12T10:04:56.333Z", "startsAt": "2021-01-12T10:04:56.333Z",
...@@ -205,7 +216,8 @@ ...@@ -205,7 +216,8 @@
"colorWeight": "600", "colorWeight": "600",
"colorPalette": "aqua", "colorPalette": "aqua",
"user": { "user": {
"username": "sarah.w" "username": "sarah.w",
"name": "sarah"
} }
}, },
"startsAt": "2021-01-16T10:04:56.333Z", "startsAt": "2021-01-16T10:04:56.333Z",
......
...@@ -56,6 +56,7 @@ describe('AddEditRotationForm', () => { ...@@ -56,6 +56,7 @@ describe('AddEditRotationForm', () => {
const findStartsOnTimeOptions = () => findRotationStartTime().findAllComponents(GlDropdownItem); const findStartsOnTimeOptions = () => findRotationStartTime().findAllComponents(GlDropdownItem);
const findEndsOnTimeOptions = () => findRotationEndTime().findAllComponents(GlDropdownItem); const findEndsOnTimeOptions = () => findRotationEndTime().findAllComponents(GlDropdownItem);
const findRestrictedToToggle = () => wrapper.find('[data-testid="restricted-to-toggle"]'); const findRestrictedToToggle = () => wrapper.find('[data-testid="restricted-to-toggle"]');
const findRestrictedToContainer = () => wrapper.find('[data-testid="restricted-to-time"]');
const findRestrictedFromOptions = () => const findRestrictedFromOptions = () =>
wrapper.find('[data-testid="restricted-from"]').findAllComponents(GlDropdownItem); wrapper.find('[data-testid="restricted-from"]').findAllComponents(GlDropdownItem);
const findRestrictedToOptions = () => const findRestrictedToOptions = () =>
...@@ -126,20 +127,35 @@ describe('AddEditRotationForm', () => { ...@@ -126,20 +127,35 @@ describe('AddEditRotationForm', () => {
}); });
describe('Rotation end time', () => { describe('Rotation end time', () => {
it('toggles end time visibility', async () => { it('toggle state depends on isEndDateEnabled', async () => {
createComponent(); createComponent();
const toggle = findEndDateToggle().vm; expect(findEndDateToggle().props('value')).toBe(false);
toggle.$emit('change', false);
expect(findRotationEndsContainer().exists()).toBe(false); expect(findRotationEndsContainer().exists()).toBe(false);
toggle.$emit('change', true);
await wrapper.vm.$nextTick(); createComponent({ props: { form: { isEndDateEnabled: true } } });
expect(findRotationEndsContainer().exists()).toBe(true); expect(findRotationEndsContainer().exists()).toBe(true);
}); });
it('should emit an event with selected value on time selection', async () => { it('toggles end time visibility on', async () => {
createComponent(); createComponent();
findEndDateToggle().vm.$emit('change', true); const toggle = findEndDateToggle().vm;
await wrapper.vm.$nextTick(); toggle.$emit('change', true);
const emittedEvent = wrapper.emitted('update-rotation-form');
expect(emittedEvent).toHaveLength(1);
expect(emittedEvent[0][0]).toEqual({ type: 'isEndDateEnabled', value: true });
});
it('toggles end time visibility off', async () => {
createComponent({ props: { form: { isEndDateEnabled: true } } });
const toggle = findEndDateToggle().vm;
toggle.$emit('change', false);
const emittedEvent = wrapper.emitted('update-rotation-form');
expect(emittedEvent).toHaveLength(1);
expect(emittedEvent[0][0]).toEqual({ type: 'isEndDateEnabled', value: false });
});
it('should emit an event with selected value on time selection', async () => {
createComponent({ props: { form: { isEndDateEnabled: true } } });
const option = 3; const option = 3;
findEndsOnTimeOptions().at(option).vm.$emit('click'); findEndsOnTimeOptions().at(option).vm.$emit('click');
const emittedEvent = wrapper.emitted('update-rotation-form'); const emittedEvent = wrapper.emitted('update-rotation-form');
...@@ -152,6 +168,7 @@ describe('AddEditRotationForm', () => { ...@@ -152,6 +168,7 @@ describe('AddEditRotationForm', () => {
createComponent({ createComponent({
props: { props: {
form: { form: {
isEndDateEnabled: true,
endsAt: { endsAt: {
time, time,
}, },
...@@ -175,9 +192,11 @@ describe('AddEditRotationForm', () => { ...@@ -175,9 +192,11 @@ describe('AddEditRotationForm', () => {
it('toggle state depends on isRestrictedToTime', async () => { it('toggle state depends on isRestrictedToTime', async () => {
createComponent(); createComponent();
expect(findRestrictedToToggle().props('value')).toBe(false); expect(findRestrictedToToggle().props('value')).toBe(false);
expect(findRestrictedToContainer().exists()).toBe(false);
createComponent({ props: { form: { ...formEmptyState, isRestrictedToTime: true } } }); createComponent({ props: { form: { ...formEmptyState, isRestrictedToTime: true } } });
expect(findRestrictedToToggle().props('value')).toBe(true); expect(findRestrictedToToggle().props('value')).toBe(true);
expect(findRestrictedToContainer().exists()).toBe(true);
}); });
it('toggles end time visibility on', async () => { it('toggles end time visibility on', async () => {
......
...@@ -77,6 +77,7 @@ describe('AddEditRotationModal', () => { ...@@ -77,6 +77,7 @@ describe('AddEditRotationModal', () => {
const createComponentWithApollo = ({ const createComponentWithApollo = ({
search = '', search = '',
createHandler = jest.fn().mockResolvedValue(createRotationResponse), createHandler = jest.fn().mockResolvedValue(createRotationResponse),
props = {},
} = {}) => { } = {}) => {
createRotationHandler = createHandler; createRotationHandler = createHandler;
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -104,6 +105,7 @@ describe('AddEditRotationModal', () => { ...@@ -104,6 +105,7 @@ describe('AddEditRotationModal', () => {
modalId: addRotationModalId, modalId: addRotationModalId,
schedule, schedule,
rotation: mockRotation[0], rotation: mockRotation[0],
...props,
}, },
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
data() { data() {
...@@ -323,7 +325,7 @@ describe('AddEditRotationModal', () => { ...@@ -323,7 +325,7 @@ describe('AddEditRotationModal', () => {
it('calls a mutation with correct parameters and creates a rotation', async () => { it('calls a mutation with correct parameters and creates a rotation', async () => {
createComponentWithApollo(); createComponentWithApollo();
expect(wrapper.emitted('fetchRotationShifts')).toBeUndefined(); expect(wrapper.emitted('fetch-rotation-shifts')).toBeUndefined();
await createRotation(wrapper); await createRotation(wrapper);
await awaitApolloDomMock(); await awaitApolloDomMock();
...@@ -334,7 +336,7 @@ describe('AddEditRotationModal', () => { ...@@ -334,7 +336,7 @@ describe('AddEditRotationModal', () => {
message: i18n.rotationCreated, message: i18n.rotationCreated,
type: FLASH_TYPES.SUCCESS, type: FLASH_TYPES.SUCCESS,
}); });
expect(wrapper.emitted('fetchRotationShifts')).toHaveLength(1); expect(wrapper.emitted('fetch-rotation-shifts')).toHaveLength(1);
}); });
it('displays alert if mutation had a recoverable error', async () => { it('displays alert if mutation had a recoverable error', async () => {
...@@ -350,4 +352,59 @@ describe('AddEditRotationModal', () => { ...@@ -350,4 +352,59 @@ describe('AddEditRotationModal', () => {
expect(alert.text()).toContain('Houston, we have a problem'); expect(alert.text()).toContain('Houston, we have a problem');
}); });
}); });
describe('edit mode', () => {
beforeEach(async () => {
await createComponentWithApollo({ props: { isEditMode: true } });
await awaitApolloDomMock();
findModal().vm.$emit('show');
});
it('should load name correctly', () => {
expect(findForm().props('form')).toMatchObject({
name: 'Rotation 242',
});
});
it('should load rotation length correctly', () => {
expect(findForm().props('form')).toMatchObject({
rotationLength: {
length: 2,
unit: 'WEEKS',
},
});
});
it('should load participants correctly', () => {
expect(findForm().props('form')).toMatchObject({
participants: [{ name: 'nora' }],
});
});
it('should load startTime correctly', () => {
expect(findForm().props('form')).toMatchObject({
startsAt: {
date: new Date('2021-01-13T00:00:00.000Z'),
time: 1,
},
});
});
it('should load endTime correctly', () => {
expect(findForm().props('form')).toMatchObject({
endsAt: {
date: new Date('2021-03-13T00:00:00.000Z'),
time: 5,
},
});
});
it('should load rotation restriction data successfully', async () => {
expect(findForm().props('form')).toMatchObject({
isRestrictedToTime: true,
restrictedTo: { startTime: 2, endTime: 10 },
});
});
});
}); });
...@@ -23,7 +23,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -23,7 +23,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
> >
<button <button
aria-label="Edit rotation" aria-label="Edit rotation"
class="btn gl-display-none btn-default btn-md gl-button btn-default-tertiary btn-icon" class="btn btn-default btn-md gl-button btn-default-tertiary btn-icon"
title="Edit rotation" title="Edit rotation"
type="button" type="button"
> >
......
...@@ -179,7 +179,7 @@ RSpec.describe Mutations::IncidentManagement::OncallRotation::Create do ...@@ -179,7 +179,7 @@ RSpec.describe Mutations::IncidentManagement::OncallRotation::Create do
context 'too many users' do context 'too many users' do
before do before do
stub_const('Mutations::IncidentManagement::OncallRotation::Create::MAXIMUM_PARTICIPANTS', 0) stub_const('Mutations::IncidentManagement::OncallRotation::Base::MAXIMUM_PARTICIPANTS', 0)
end end
it 'raises an error' do it 'raises an error' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::IncidentManagement::OncallRotation::Update do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:schedule) { create(:incident_management_oncall_schedule, project: project) }
let_it_be_with_reload(:rotation) { create(:incident_management_oncall_rotation, :with_participants, schedule: schedule) }
let(:args) do
{
name: 'On-call rotation',
starts_at: "2020-01-10 09:00".in_time_zone(schedule.timezone),
rotation_length: {
length: 1,
unit: ::IncidentManagement::OncallRotation.length_units[:days]
},
participants: [
{
username: current_user.username,
color_weight: ::IncidentManagement::OncallParticipant.color_weights['50'],
color_palette: ::IncidentManagement::OncallParticipant.color_palettes[:blue]
}
]
}
end
describe '#resolve' do
subject(:resolve) { mutation_for(current_user).resolve(id: rotation.to_global_id, participants: args[:participants], **args) }
context 'user has access to project' do
before do
stub_licensed_features(oncall_schedules: true)
project.add_maintainer(current_user)
end
context 'when OncallRotation::UpdateService responds with success' do
it 'returns the on-call rotation with no errors' do
expect(resolve).to match(
oncall_rotation: rotation.reload,
errors: be_empty
)
expect(rotation).to have_attributes(args.except(:participants, :rotation_length))
expect(rotation.length).to eq(args[:rotation_length][:length])
expect(rotation.length_unit).to eq(IncidentManagement::OncallRotation.length_units.key(args[:rotation_length][:unit]))
end
it 'adds the participant to the rotation' do
rotation = resolve[:oncall_rotation]
expect(rotation.participants.not_removed.size).to eq(1)
expect(rotation.participants.removed.size).to eq(1)
first_participant = rotation.participants.not_removed.first
expect(first_participant.user).to eq(current_user)
expect(first_participant.color_weight).to eq('50')
expect(first_participant.color_palette).to eq('blue')
end
context 'removing participants' do
before do
args[:participants] = []
end
it 'returns the on-call rotation with no errors' do
expect(resolve[:oncall_rotation].participants.not_removed).to be_empty
end
end
context 'with endsAt arg' do
let(:ends_at) { "2020-02-10 09:00".in_time_zone(schedule.timezone) }
before do
args.merge!(ends_at: ends_at)
end
it 'returns the on-call rotation with no errors' do
expect(resolve[:oncall_rotation].ends_at).to eq(ends_at)
expect(resolve[:errors]).to be_empty
end
context 'when endsAt is nil' do
let(:ends_at) { nil }
before do
rotation.update!(ends_at: Time.current)
end
it 'returns the on-call rotation with no errors' do
expect(resolve[:oncall_rotation].ends_at).to be_nil
expect(resolve[:errors]).to be_empty
end
end
end
end
context 'when OncallRotations::UpdateService responds with an error' do
before do
allow_next_instance_of(::IncidentManagement::OncallRotations::EditService) do |service|
allow(service).to receive(:execute)
.and_return(ServiceResponse.error(payload: { oncall_rotation: nil }, message: 'An error has occurred'))
end
end
it 'returns errors' do
expect(resolve).to eq(
oncall_rotation: nil,
errors: ['An error has occurred']
)
end
end
context 'with active period times given' do
let(:start_time) { '08:00' }
let(:end_time) { '17:00' }
before do
args[:active_period] = {
start_time: start_time,
end_time: end_time
}
end
it 'returns the on-call rotation with no errors' do
expect(resolve).to match(
oncall_rotation: ::IncidentManagement::OncallRotation.last!,
errors: be_empty
)
end
it 'saves the on-call rotation with active period times' do
rotation = resolve[:oncall_rotation]
expect(rotation.active_period_start.strftime('%H:%M')).to eql('08:00')
expect(rotation.active_period_end.strftime('%H:%M')).to eql('17:00')
end
context 'hours rotation length unit' do
before do
args[:rotation_length][:unit] = ::IncidentManagement::OncallRotation.length_units[:hours]
end
it 'returns errors' do
expect(resolve).to match(
oncall_rotation: nil,
errors: [/Restricted shift times are not available for hourly shifts/]
)
end
end
context 'end time is before start time' do
let(:start_time) { '17:00' }
let(:end_time) { '08:00' }
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "'start_time' time must be before 'end_time' time")
end
end
context 'invalid time given' do
let(:start_time) { 'an invalid time' }
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'Time given is invalid')
end
end
end
context 'removing active period' do
before do
rotation.update!(active_period_start: "08:00", active_period_end: "17:00")
args.merge!(active_period: nil)
end
it 'removes the active period' do
expect(resolve[:errors]).to be_empty
expect(rotation.reload.active_period_start).to eq(nil)
expect(rotation.active_period_end).to eq(nil)
end
end
describe 'error cases' do
context 'user cannot be found' do
before do
args.merge!(participants: [username: 'unknown'])
end
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "A provided username couldn't be matched to a user")
end
end
context 'duplicate participants' do
before do
args[:participants] << args[:participants].first
end
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'A duplicate username is included in the participant list')
end
end
context 'too many users' do
before do
stub_const('Mutations::IncidentManagement::OncallRotation::Base::MAXIMUM_PARTICIPANTS', 0)
end
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "A maximum of #{described_class::MAXIMUM_PARTICIPANTS} participants can be added")
end
end
end
end
context 'when resource is not accessible to the user' do
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action")
end
end
end
private
def mutation_for(user)
described_class.new(object: nil, context: { current_user: user }, field: nil)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting Incident Management on-call shifts' do
include GraphqlHelpers
let_it_be(:participant) { create(:incident_management_oncall_participant, :with_developer_access) }
let_it_be(:rotation) { participant.rotation }
let_it_be(:schedule) { rotation.schedule }
let_it_be(:project) { rotation.project }
let_it_be(:current_user) { participant.user }
let(:mutation) do
graphql_mutation(:oncall_rotation_update, update_params) do
<<-QL.strip_heredoc
clientMutationId
errors
oncallRotation {
id
name
startsAt
length
lengthUnit
activePeriod {
startTime
endTime
}
participants {
nodes {
user {
username
}
colorWeight
colorPalette
}
}
}
QL
end
end
before do
stub_licensed_features(oncall_schedules: true)
project.add_maintainer(current_user)
end
subject(:resolve) { post_graphql_mutation(mutation, current_user: current_user) }
def mutation_response
graphql_mutation_response(:oncall_rotation_update)
end
context 'updating name only' do
let(:params) { { name: 'Test rotation mutation' } }
it 'updates the rotation' do
resolve
expect(mutation_response['errors']).to be_empty
oncall_rotation_response = mutation_response['oncallRotation']
expect(oncall_rotation_response['name']).to eq(params[:name])
end
end
context 'removing participants' do
let(:params) { { participants: [] } }
it 'updates the rotation and removes participants' do
resolve
expect(mutation_response['errors']).to be_empty
oncall_rotation_response = mutation_response['oncallRotation']
expect(oncall_rotation_response['participants']['nodes']).to eq([])
expect(rotation.participants.removed.reload).not_to be_empty
expect(rotation.participants.not_removed.reload).to be_empty
end
end
context 'adding participants' do
let(:new_user) { create(:user) }
before do
project.add_reporter(new_user)
end
let(:params) { { participants: [*existing_participant_params, participant_params(new_user)] } }
it 'updates the rotation and adds participants' do
resolve
expect(mutation_response['errors']).to be_empty
oncall_rotation_response = mutation_response['oncallRotation']
response_participants = oncall_rotation_response['participants']['nodes']
expect(response_participants.size).to eq(2)
new_user_participant = response_participants.detect {|h| h.dig('user', 'username') == new_user.username }
expect(new_user_participant['colorPalette']).to eq('blue')
expect(new_user_participant['colorWeight']).to eq('500')
expect(rotation.participants.removed.reload).to be_empty
expect(rotation.participants.not_removed.reload.size).to eq(2)
end
end
context 'errors' do
context 'user cannot be found' do
let(:params) { { participants: [{ username: 'unknown' }] } }
it 'raises an error' do
resolve
expect(json_response['errors'][0]['message']).to eq("A provided username couldn't be matched to a user")
end
end
end
def update_params
{ id: rotation.to_global_id.uri }.merge(params)
end
def participant_params(user)
participant = build(:incident_management_oncall_participant, user: user)
{
username: participant.user.username,
colorWeight: 'WEIGHT_500',
colorPalette: 'BLUE'
}
end
def existing_participant_params
rotation.participants.map do |participant|
{
username: participant.user.username,
colorWeight: "WEIGHT_#{participant.color_weight}",
colorPalette: participant.color_palette.upcase
}
end
end
end
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