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.
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `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`
Autogenerated return type of OncallScheduleCreate.
......
......@@ -94,6 +94,7 @@ export default {
presetType: this.$options.PRESET_TYPES.WEEKS,
timeframeStartDate: new Date(),
rotations: this.schedule.rotations.nodes,
rotationToUpdate: {},
};
},
computed: {
......@@ -163,6 +164,9 @@ export default {
fetchRotationShifts() {
this.$apollo.queries.rotations.refetch();
},
setRotationToUpdate(rotation) {
this.rotationToUpdate = rotation;
},
},
};
</script>
......@@ -249,6 +253,7 @@ export default {
:timeframe="timeframe"
:schedule-iid="schedule.iid"
:loading="loading"
@set-rotation-to-update="setRotationToUpdate"
/>
</div>
</gl-card>
......@@ -262,12 +267,14 @@ export default {
<add-edit-rotation-modal
:schedule="schedule"
:modal-id="$options.addRotationModalId"
@fetchRotationShifts="fetchRotationShifts"
@fetch-rotation-shifts="fetchRotationShifts"
/>
<add-edit-rotation-modal
:schedule="schedule"
:modal-id="$options.editRotationModalId"
:rotation="rotationToUpdate"
is-edit-mode
@fetch-rotation-shifts="fetchRotationShifts"
/>
</div>
</template>
......@@ -97,12 +97,6 @@ export default {
default: () => ({}),
},
},
data() {
return {
participantsArr: [],
endDateEnabled: false,
};
},
methods: {
format24HourTimeStringFromInt,
},
......@@ -121,7 +115,8 @@ export default {
>
<gl-form-input
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>
......@@ -133,14 +128,14 @@ export default {
:state="validationState.participants"
>
<gl-token-selector
v-model="participantsArr"
:selected-tokens="form.participants"
:dropdown-items="participants"
:loading="isLoading"
container-class="gl-h-13! gl-overflow-y-auto"
menu-class="gl-overflow-y-auto"
@text-input="$emit('filter-participants', $event)"
@blur="$emit('update-rotation-form', { type: 'participants', value: participantsArr })"
@input="$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: $event })"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatarUrl" :src="token.avatarUrl" :size="16" />
......@@ -169,7 +164,7 @@ export default {
type="number"
class="gl-w-12 gl-mr-3"
min="1"
:value="1"
:value="form.rotationLength.length"
@input="$emit('update-rotation-form', { type: 'rotationLength.length', value: $event })"
/>
<gl-dropdown :text="form.rotationLength.unit.toLowerCase()">
......@@ -195,6 +190,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<gl-datepicker
class="gl-mr-3"
:value="form.startsAt.date"
@input="$emit('update-rotation-form', { type: 'startsAt.date', value: $event })"
>
<template #default="{ formattedDate }">
......@@ -233,14 +229,17 @@ export default {
</div>
<div class="gl-display-inline-block">
<gl-toggle
v-model="endDateEnabled"
:value="form.isEndDateEnabled"
:label="$options.i18n.fields.endsAt.enableToggle"
label-position="left"
class="gl-mb-5"
@change="
$emit('update-rotation-form', { type: 'isEndDateEnabled', value: !form.isEndDateEnabled })
"
/>
<gl-card
v-if="endDateEnabled"
v-if="form.isEndDateEnabled"
data-testid="rotation-ends-on"
class="gl-border-gray-400 gl-bg-gray-10"
>
......@@ -254,6 +253,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<gl-datepicker
class="gl-mr-3"
:value="form.endsAt.date"
@input="$emit('update-rotation-form', { type: 'endsAt.date', value: $event })"
>
<template #default="{ formattedDate }">
......
......@@ -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 getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
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 searchProjectMembersQuery from '~/graphql_shared/queries/project_user_members_search.query.graphql';
import { format24HourTimeStringFromInt, formatDate } from '~/lib/utils/datetime_utility';
......@@ -32,6 +37,7 @@ export const formEmptyState = {
date: null,
time: 0,
},
isEndDateEnabled: false,
endsAt: {
date: null,
time: 0,
......@@ -43,6 +49,13 @@ export const formEmptyState = {
},
};
const validiationInitialState = {
name: true,
participants: true,
startsAt: true,
endsAt: true,
};
export default {
i18n,
components: {
......@@ -61,6 +74,11 @@ export default {
required: false,
default: false,
},
rotation: {
type: Object,
required: false,
default: () => ({}),
},
schedule: {
type: Object,
required: true,
......@@ -90,12 +108,7 @@ export default {
ptSearchTerm: '',
form: cloneDeep(formEmptyState),
error: '',
validationState: {
name: true,
participants: true,
startsAt: true,
endsAt: true,
},
validationState: cloneDeep(validiationInitialState),
};
},
computed: {
......@@ -134,34 +147,35 @@ export default {
participants,
startsAt: { date: startDate, time: startTime },
endsAt: { date: endDate, time: endTime },
isEndDateEnabled,
isRestrictedToTime,
restrictedTo: { startTime: activeStartTime, endTime: activeEndTime },
} = this.form;
const variables = {
projectPath: this.projectPath,
scheduleIid: this.schedule.iid,
name,
participants: getParticipantsForSave(participants),
rotationLength: {
...rotationLength,
length: parseInt(rotationLength.length, 10),
},
startsAt: {
date: formatDate(startDate, 'yyyy-mm-dd'),
time: format24HourTimeStringFromInt(startTime),
},
endsAt: endDate
endsAt: isEndDateEnabled
? {
date: formatDate(endDate, 'yyyy-mm-dd'),
time: format24HourTimeStringFromInt(endTime),
}
: null,
rotationLength: {
...rotationLength,
length: parseInt(rotationLength.length, 10),
},
participants: getParticipantsForSave(participants),
};
if (this.form.isRestrictedToTime) {
variables.activePeriod = {
startTime: format24HourTimeStringFromInt(this.form.restrictedTo.startTime),
endTime: format24HourTimeStringFromInt(this.form.restrictedTo.endTime),
};
activePeriod: isRestrictedToTime
? {
startTime: format24HourTimeStringFromInt(activeStartTime),
endTime: format24HourTimeStringFromInt(activeEndTime),
}
: null,
};
return variables;
},
title() {
......@@ -185,11 +199,15 @@ export default {
methods: {
createRotation() {
this.loading = true;
const input = {
...this.rotationVariables,
projectPath: this.projectPath,
scheduleIid: this.schedule.iid,
};
this.$apollo
.mutate({
mutation: createOncallScheduleRotationMutation,
variables: { input: this.rotationVariables },
variables: { input },
})
.then(
({
......@@ -204,7 +222,7 @@ export default {
}
this.$refs.addEditScheduleRotationModal.hide();
this.$emit('fetchRotationShifts');
this.$emit('fetch-rotation-shifts');
return createFlash({
message: this.$options.i18n.rotationCreated,
type: FLASH_TYPES.SUCCESS,
......@@ -221,11 +239,14 @@ export default {
editRotation() {
this.loading = true;
const { projectPath, schedule } = this;
const input = {
...this.rotationVariables,
id: this.rotation.id,
};
this.$apollo
.mutate({
mutation: updateOncallScheduleRotationMutation,
variables: { input: this.rotationVariables },
variables: { input },
update(store, { data }) {
updateStoreAfterRotationEdit(
store,
......@@ -250,6 +271,7 @@ export default {
}
this.$refs.addEditScheduleRotationModal.hide();
this.$emit('fetch-rotation-shifts');
return createFlash({
message: this.$options.i18n.editedRotation,
type: FLASH_TYPES.SUCCESS,
......@@ -282,8 +304,45 @@ export default {
this.validationState.endsAt = this.isEndDateValid;
}
},
afterCloseModal() {
beforeShowModal() {
if (this.isEditMode) {
this.parseRotation();
}
},
resetModal() {
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 {
:action-cancel="actionsProps.cancel"
modal-class="rotations-modal"
@primary.prevent="isEditMode ? editRotation() : createRotation()"
@hide="afterCloseModal"
@show="beforeShowModal"
@hide="resetModal"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error || $options.i18n.errorMsg }}
......
......@@ -89,6 +89,7 @@ export default {
methods: {
setRotationToUpdate(rotation) {
this.rotationToUpdate = rotation;
this.$emit('set-rotation-to-update', rotation);
},
cellShouldHideOverflow(index) {
return index + 1 === this.timeframe.length || this.presetIsDay;
......@@ -126,11 +127,9 @@ export default {
>
<span class="gl-text-truncated">{{ rotation.name }}</span>
<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
v-gl-modal="$options.editRotationModalId"
v-gl-tooltip
class="gl-display-none"
category="tertiary"
:title="$options.i18n.editRotationLabel"
icon="pencil"
......
fragment OnCallParticipant on OncallParticipantType {
user {
id
name
username
avatarUrl
}
......
import { newDateAsLocaleTime } from '~/lib/utils/datetime_utility';
import { sprintf, __ } from '~/locale';
import { ASSIGNEE_COLORS_COMBO } from '../constants';
......@@ -52,3 +53,36 @@ export const getParticipantsForSave = (participants) =>
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
mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Update
mount_mutation ::Mutations::IncidentManagement::OncallSchedule::Destroy
mount_mutation ::Mutations::IncidentManagement::OncallRotation::Create
mount_mutation ::Mutations::IncidentManagement::OncallRotation::Update
mount_mutation ::Mutations::IncidentManagement::OncallRotation::Destroy
mount_mutation ::Mutations::AppSec::Fuzzing::Api::CiConfiguration::Create
......
......@@ -4,6 +4,9 @@ module Mutations
module IncidentManagement
module OncallRotation
class Base < BaseMutation
MAXIMUM_PARTICIPANTS = 100
TIME_FORMAT = /^(0\d|1\d|2[0-3]):[0-5]\d$/.freeze
field :oncall_rotation,
::Types::IncidentManagement::OncallRotationType,
null: true,
......@@ -33,6 +36,100 @@ module Mutations
::IncidentManagement::OncallRotationsFinder.new(current_user, project, schedule, args).execute.first
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
......
......@@ -40,10 +40,7 @@ module Mutations
argument :participants,
[Types::IncidentManagement::OncallUserInputType],
required: true,
description: 'The usernames of users participating in the on-call rotation.'
MAXIMUM_PARTICIPANTS = 100
TIME_FORMAT = /^(0\d|1\d|2[0-3]):[0-5]\d$/.freeze
description: 'The usernames of users participating in the on-call rotation. A maximum limit of 100 participants applies.'
def resolve(iid:, project_path:, participants:, **args)
project = Project.find_by_full_path(project_path)
......@@ -60,7 +57,7 @@ module Mutations
schedule,
project,
current_user,
create_service_params(schedule, participants, args)
parsed_params(schedule, participants, args)
).execute
response(result)
......@@ -68,95 +65,6 @@ module Mutations
rescue ActiveRecord::RecordInvalid => e
raise Gitlab::Graphql::Errors::ArgumentError, e.message
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
......
# 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
result.payload[:shifts]
end
# See https://gitlab.com/gitlab-org/gitlab/-/issues/324421
def self.complexity_multiplier(args)
0.005
end
end
end
end
......@@ -9,11 +9,11 @@ module Types
argument :start_time, GraphQL::STRING_TYPE,
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,
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
# rubocop: enable Graphql/AuthorizeTypes
end
......
......@@ -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.',
max_page_size: MAX_SHIFTS_FOR_TIMEFRAME,
resolver: ::Resolvers::IncidentManagement::OncallShiftsResolver
def participants
object.participants.not_removed
end
end
end
end
......@@ -2,6 +2,8 @@ import { ASSIGNEE_COLORS_COMBO } from 'ee/oncall_schedules/constants';
import {
getFormattedTimezone,
getParticipantsForSave,
parseHour,
parseRotationDate,
} from 'ee/oncall_schedules/utils/common_utils';
import mockTimezones from './mocks/mock_timezones.json';
......@@ -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 = {
iid: '37',
name: 'Test schedule from query',
description: 'Description 1 lives here',
timezone: {
identifier: 'Pacific/Honolulu',
},
timezone: 'Pacific/Honolulu',
rotations: { nodes: [mockRotations] },
},
],
......@@ -156,6 +154,7 @@ export const createRotationResponse = {
username: 'project_1_bot3',
avatarUrl: invalidUrl,
avatar__typename: 'User',
name: 'Bot 3',
},
colorWeight: '500',
colorPalette: 'blue',
......@@ -194,6 +193,7 @@ export const createRotationResponseWithErrors = {
username: 'project_1_bot3',
avatarUrl: invalidUrl,
__typename: 'User',
name: 'Bot 3',
},
colorWeight: '500',
colorPalette: 'blue',
......
[{
"id": "gid://gitlab/IncidentManagement::OncallRotation/2",
"name": "Rotation 242",
"startsAt": "2021-01-13T10:04:56.333Z",
"endsAt": "2021-03-13T10:04:56.333Z",
"length": 1,
"startsAt": "2021-01-13T11:04:56.333Z",
"endsAt": "2021-03-13T15:04:56.333Z",
"length": 2,
"lengthUnit": "WEEKS",
"activePeriod": {
"startTime": "02:00",
......@@ -15,7 +15,8 @@
"user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/49",
"username": "nora.schaden",
"avatarUrl": "/url"
"avatarUrl": "/url",
"name": "nora"
},
"colorWeight": "500",
"colorPalette": "blue"
......@@ -32,7 +33,8 @@
"user": {
"id": "1",
"username": "nora.schaden",
"avatarUrl": "/url"
"avatarUrl": "/url",
"name": "nora"
}
},
"startsAt": "2021-01-12T10:04:56.333Z",
......@@ -46,7 +48,8 @@
"user": {
"id": "2",
"username": "racheal.loving",
"avatarUrl": "/url"
"avatarUrl": "/url",
"name": "racheal"
}
},
"startsAt": "2021-01-16T10:04:56.333Z",
......@@ -58,8 +61,8 @@
{
"id": "gid://gitlab/IncidentManagement::OncallRotation/55",
"name": "Rotation 242",
"startsAt": "2021-01-13T10:04:56.333Z",
"endsAt": "2021-03-13T10:04:56.333Z",
"startsAt": "2021-01-13T11:04:56.333Z",
"endsAt": "2021-03-13T15:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"activePeriod": {
......@@ -72,7 +75,8 @@
"user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/99",
"username": "david.oregan",
"avatarUrl": "/url"
"avatarUrl": "/url",
"name": "david"
},
"colorWeight": "500",
"colorPalette": "aqua"
......@@ -87,7 +91,8 @@
"colorWeight": "500",
"colorPalette": "aqua",
"user": {
"username": "david.oregan"
"username": "david.oregan",
"name": "david"
}
},
"startsAt": "2021-01-14T10:04:56.333Z",
......@@ -99,7 +104,8 @@
"colorWeight": "500",
"colorPalette": "green",
"user": {
"username": "david.keagan"
"username": "david.keagan",
"name": "david k"
}
},
"startsAt": "2021-01-21T10:04:56.333Z",
......@@ -125,7 +131,8 @@
"user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/48",
"username": "root",
"avatarUrl": "/url"
"avatarUrl": "/url",
"name": "Administrator"
},
"colorWeight": "500",
"colorPalette": "magenta"
......@@ -140,11 +147,12 @@
"colorWeight": "500",
"colorPalette": "magenta",
"user": {
"username": "root"
"username": "root",
"name": "Administrator"
}
},
"startsAt": "2021-01-10T10:04:56.333Z",
"endsAt": "2021-01-13T10:04:56.333Z"
"endsAt": "2021-01-13T11:04:56.333Z"
},
{
"participant": {
......@@ -152,7 +160,8 @@
"colorWeight": "600",
"colorPalette": "blue",
"user": {
"username": "root2"
"username": "root2",
"name": "Administrator 2"
}
},
"startsAt": "2021-01-15T10:04:56.333Z",
......@@ -178,7 +187,8 @@
"user": {
"id": "gid://gitlab/IncidentManagement::OncallParticipant/51",
"username": "oregand",
"avatarUrl": "/url"
"avatarUrl": "/url",
"name": "david"
},
"colorWeight": "600",
"colorPalette": "orange"
......@@ -193,7 +203,8 @@
"colorWeight": "600",
"colorPalette": "orange",
"user": {
"username": "oregand"
"username": "oregand",
"name": "david"
}
},
"startsAt": "2021-01-12T10:04:56.333Z",
......@@ -205,7 +216,8 @@
"colorWeight": "600",
"colorPalette": "aqua",
"user": {
"username": "sarah.w"
"username": "sarah.w",
"name": "sarah"
}
},
"startsAt": "2021-01-16T10:04:56.333Z",
......
......@@ -56,6 +56,7 @@ describe('AddEditRotationForm', () => {
const findStartsOnTimeOptions = () => findRotationStartTime().findAllComponents(GlDropdownItem);
const findEndsOnTimeOptions = () => findRotationEndTime().findAllComponents(GlDropdownItem);
const findRestrictedToToggle = () => wrapper.find('[data-testid="restricted-to-toggle"]');
const findRestrictedToContainer = () => wrapper.find('[data-testid="restricted-to-time"]');
const findRestrictedFromOptions = () =>
wrapper.find('[data-testid="restricted-from"]').findAllComponents(GlDropdownItem);
const findRestrictedToOptions = () =>
......@@ -126,20 +127,35 @@ describe('AddEditRotationForm', () => {
});
describe('Rotation end time', () => {
it('toggles end time visibility', async () => {
it('toggle state depends on isEndDateEnabled', async () => {
createComponent();
const toggle = findEndDateToggle().vm;
toggle.$emit('change', false);
expect(findEndDateToggle().props('value')).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);
});
it('should emit an event with selected value on time selection', async () => {
it('toggles end time visibility on', async () => {
createComponent();
findEndDateToggle().vm.$emit('change', true);
await wrapper.vm.$nextTick();
const toggle = findEndDateToggle().vm;
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;
findEndsOnTimeOptions().at(option).vm.$emit('click');
const emittedEvent = wrapper.emitted('update-rotation-form');
......@@ -152,6 +168,7 @@ describe('AddEditRotationForm', () => {
createComponent({
props: {
form: {
isEndDateEnabled: true,
endsAt: {
time,
},
......@@ -175,9 +192,11 @@ describe('AddEditRotationForm', () => {
it('toggle state depends on isRestrictedToTime', async () => {
createComponent();
expect(findRestrictedToToggle().props('value')).toBe(false);
expect(findRestrictedToContainer().exists()).toBe(false);
createComponent({ props: { form: { ...formEmptyState, isRestrictedToTime: true } } });
expect(findRestrictedToToggle().props('value')).toBe(true);
expect(findRestrictedToContainer().exists()).toBe(true);
});
it('toggles end time visibility on', async () => {
......
......@@ -77,6 +77,7 @@ describe('AddEditRotationModal', () => {
const createComponentWithApollo = ({
search = '',
createHandler = jest.fn().mockResolvedValue(createRotationResponse),
props = {},
} = {}) => {
createRotationHandler = createHandler;
localVue.use(VueApollo);
......@@ -104,6 +105,7 @@ describe('AddEditRotationModal', () => {
modalId: addRotationModalId,
schedule,
rotation: mockRotation[0],
...props,
},
apolloProvider: fakeApollo,
data() {
......@@ -323,7 +325,7 @@ describe('AddEditRotationModal', () => {
it('calls a mutation with correct parameters and creates a rotation', async () => {
createComponentWithApollo();
expect(wrapper.emitted('fetchRotationShifts')).toBeUndefined();
expect(wrapper.emitted('fetch-rotation-shifts')).toBeUndefined();
await createRotation(wrapper);
await awaitApolloDomMock();
......@@ -334,7 +336,7 @@ describe('AddEditRotationModal', () => {
message: i18n.rotationCreated,
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 () => {
......@@ -350,4 +352,59 @@ describe('AddEditRotationModal', () => {
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
>
<button
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"
type="button"
>
......
......@@ -179,7 +179,7 @@ RSpec.describe Mutations::IncidentManagement::OncallRotation::Create do
context 'too many users' do
before do
stub_const('Mutations::IncidentManagement::OncallRotation::Create::MAXIMUM_PARTICIPANTS', 0)
stub_const('Mutations::IncidentManagement::OncallRotation::Base::MAXIMUM_PARTICIPANTS', 0)
end
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