Commit 55eebdf8 authored by Scott Hampton's avatar Scott Hampton

Merge branch '273797-multi-schedules' into 'master'

Create multiple schedules [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!59829
parents d75f02b2 938753a8
---
name: multiple_oncall_schedules
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59829
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328474
milestone: '13.11'
type: development
group: group::monitor
default_enabled: false
...@@ -10563,7 +10563,6 @@ Represents vulnerability finding of a security report on the pipeline. ...@@ -10563,7 +10563,6 @@ Represents vulnerability finding of a security report on the pipeline.
| <a id="projecthttpurltorepo"></a>`httpUrlToRepo` | [`String`](#string) | URL to connect to the project via HTTPS. | | <a id="projecthttpurltorepo"></a>`httpUrlToRepo` | [`String`](#string) | URL to connect to the project via HTTPS. |
| <a id="projectid"></a>`id` | [`ID!`](#id) | ID of the project. | | <a id="projectid"></a>`id` | [`ID!`](#id) | ID of the project. |
| <a id="projectimportstatus"></a>`importStatus` | [`String`](#string) | Status of import background job of the project. | | <a id="projectimportstatus"></a>`importStatus` | [`String`](#string) | Status of import background job of the project. |
| <a id="projectincidentmanagementoncallschedules"></a>`incidentManagementOncallSchedules` | [`IncidentManagementOncallScheduleConnection`](#incidentmanagementoncallscheduleconnection) | Incident Management On-call schedules of the project. |
| <a id="projectissuesenabled"></a>`issuesEnabled` | [`Boolean`](#boolean) | Indicates if Issues are enabled for the current user. | | <a id="projectissuesenabled"></a>`issuesEnabled` | [`Boolean`](#boolean) | Indicates if Issues are enabled for the current user. |
| <a id="projectjiraimportstatus"></a>`jiraImportStatus` | [`String`](#string) | Status of Jira import background job of the project. | | <a id="projectjiraimportstatus"></a>`jiraImportStatus` | [`String`](#string) | Status of Jira import background job of the project. |
| <a id="projectjiraimports"></a>`jiraImports` | [`JiraImportConnection`](#jiraimportconnection) | Jira imports into the project. | | <a id="projectjiraimports"></a>`jiraImports` | [`JiraImportConnection`](#jiraimportconnection) | Jira imports into the project. |
...@@ -10823,6 +10822,22 @@ four standard [pagination arguments](#connection-pagination-arguments): ...@@ -10823,6 +10822,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="projectenvironmentssearch"></a>`search` | [`String`](#string) | Search query for environment name. | | <a id="projectenvironmentssearch"></a>`search` | [`String`](#string) | Search query for environment name. |
| <a id="projectenvironmentsstates"></a>`states` | [`[String!]`](#string) | States of environments that should be included in result. | | <a id="projectenvironmentsstates"></a>`states` | [`[String!]`](#string) | States of environments that should be included in result. |
##### `Project.incidentManagementOncallSchedules`
Incident Management On-call schedules of the project.
Returns [`IncidentManagementOncallScheduleConnection`](#incidentmanagementoncallscheduleconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="projectincidentmanagementoncallschedulesiids"></a>`iids` | [`[ID!]`](#id) | IIDs of on-call schedules. |
##### `Project.issue` ##### `Project.issue`
A single issue of the project. A single issue of the project.
......
<script> <script>
import { GlCard, GlButtonGroup, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import {
GlButton,
GlButtonGroup,
GlCard,
GlCollapse,
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
import { import {
...@@ -11,11 +18,17 @@ import { ...@@ -11,11 +18,17 @@ import {
nDaysAfter, nDaysAfter,
} from '~/lib/utils/datetime_utility'; } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { addRotationModalId, editRotationModalId, PRESET_TYPES } from '../constants'; import {
addRotationModalId,
deleteRotationModalId,
editRotationModalId,
PRESET_TYPES,
} from '../constants';
import getShiftsForRotations from '../graphql/queries/get_oncall_schedules_with_rotations_shifts.query.graphql'; import getShiftsForRotations from '../graphql/queries/get_oncall_schedules_with_rotations_shifts.query.graphql';
import EditScheduleModal from './add_edit_schedule_modal.vue'; import EditScheduleModal from './add_edit_schedule_modal.vue';
import DeleteScheduleModal from './delete_schedule_modal.vue'; import DeleteScheduleModal from './delete_schedule_modal.vue';
import AddEditRotationModal from './rotations/components/add_edit_rotation_modal.vue'; import AddEditRotationModal from './rotations/components/add_edit_rotation_modal.vue';
import DeleteRotationModal from './rotations/components/delete_rotation_modal.vue';
import RotationsListSection from './schedule/components/rotations_list_section.vue'; import RotationsListSection from './schedule/components/rotations_list_section.vue';
import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue'; import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue';
import { getTimeframeForWeeksView, selectedTimezoneFormattedOffset } from './schedule/utils'; import { getTimeframeForWeeksView, selectedTimezoneFormattedOffset } from './schedule/utils';
...@@ -31,6 +44,8 @@ export const i18n = { ...@@ -31,6 +44,8 @@ export const i18n = {
DAYS: s__('OnCallSchedules|1 day'), DAYS: s__('OnCallSchedules|1 day'),
WEEKS: s__('OnCallSchedules|2 weeks'), WEEKS: s__('OnCallSchedules|2 weeks'),
}, },
scheduleOpen: s__('OnCallSchedules|Expand schedule'),
scheduleClose: s__('OnCallSchedules|Collapse schedule'),
}; };
export const editScheduleModalId = 'editScheduleModal'; export const editScheduleModalId = 'editScheduleModal';
export const deleteScheduleModalId = 'deleteScheduleModal'; export const deleteScheduleModalId = 'deleteScheduleModal';
...@@ -40,13 +55,16 @@ export default { ...@@ -40,13 +55,16 @@ export default {
addRotationModalId, addRotationModalId,
editRotationModalId, editRotationModalId,
editScheduleModalId, editScheduleModalId,
deleteRotationModalId,
deleteScheduleModalId, deleteScheduleModalId,
PRESET_TYPES, PRESET_TYPES,
components: { components: {
GlButton, GlButton,
GlButtonGroup, GlButtonGroup,
GlCard, GlCard,
GlCollapse,
AddEditRotationModal, AddEditRotationModal,
DeleteRotationModal,
DeleteScheduleModal, DeleteScheduleModal,
EditScheduleModal, EditScheduleModal,
RotationsListSection, RotationsListSection,
...@@ -78,11 +96,12 @@ export default { ...@@ -78,11 +96,12 @@ export default {
projectPath: this.projectPath, projectPath: this.projectPath,
startsAt, startsAt,
endsAt, endsAt,
iids: [this.schedule.iid],
}; };
}, },
update(data) { update(data) {
const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? []; const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? [];
const schedule = nodes.length ? nodes[nodes.length - 1] : null; const [schedule] = nodes;
return schedule?.rotations.nodes ?? []; return schedule?.rotations.nodes ?? [];
}, },
error(error) { error(error) {
...@@ -96,9 +115,25 @@ export default { ...@@ -96,9 +115,25 @@ export default {
timeframeStartDate: getStartOfWeek(new Date()), timeframeStartDate: getStartOfWeek(new Date()),
rotations: this.schedule.rotations.nodes, rotations: this.schedule.rotations.nodes,
rotationToUpdate: {}, rotationToUpdate: {},
scheduleVisible: true,
}; };
}, },
computed: { computed: {
addRotationModalId() {
return `${this.$options.addRotationModalId}-${this.schedule.iid}`;
},
deleteScheduleModalId() {
return `${this.$options.deleteScheduleModalId}-${this.schedule.iid}`;
},
deleteRotationModalId() {
return `${this.$options.deleteRotationModalId}-${this.schedule.iid}`;
},
editScheduleModalId() {
return `${this.$options.editScheduleModalId}-${this.schedule.iid}`;
},
editRotationModalId() {
return `${this.$options.editRotationModalId}-${this.schedule.iid}`;
},
loading() { loading() {
return this.$apollo.queries.rotations.loading; return this.$apollo.queries.rotations.loading;
}, },
...@@ -129,6 +164,14 @@ export default { ...@@ -129,6 +164,14 @@ export default {
} }
return `${this.schedule.timezone} | ${this.offset}`; return `${this.schedule.timezone} | ${this.offset}`;
}, },
scheduleVisibleAriaLabel() {
return this.scheduleVisible
? this.$options.i18n.scheduleOpen
: this.$options.i18n.scheduleClose;
},
scheduleVisibleAngleIcon() {
return this.scheduleVisible ? 'angle-up' : 'angle-down';
},
selectedTimezone() { selectedTimezone() {
return this.timezones.find((tz) => tz.identifier === this.schedule.timezone); return this.timezones.find((tz) => tz.identifier === this.schedule.timezone);
}, },
...@@ -190,22 +233,29 @@ export default { ...@@ -190,22 +233,29 @@ export default {
<span class="gl-font-weight-bold gl-font-lg">{{ schedule.name }}</span> <span class="gl-font-weight-bold gl-font-lg">{{ schedule.name }}</span>
<gl-button-group> <gl-button-group>
<gl-button <gl-button
v-gl-modal="$options.editScheduleModalId" v-gl-modal="editScheduleModalId"
v-gl-tooltip v-gl-tooltip
:title="$options.i18n.editScheduleLabel" :title="$options.i18n.editScheduleLabel"
icon="pencil" icon="pencil"
:aria-label="$options.i18n.editScheduleLabel" :aria-label="$options.i18n.editScheduleLabel"
/> />
<gl-button <gl-button
v-gl-modal="$options.deleteScheduleModalId" v-gl-modal="deleteScheduleModalId"
v-gl-tooltip v-gl-tooltip
:title="$options.i18n.deleteScheduleLabel" :title="$options.i18n.deleteScheduleLabel"
icon="remove" icon="remove"
:aria-label="$options.i18n.deleteScheduleLabel" :aria-label="$options.i18n.deleteScheduleLabel"
/> />
<gl-button
v-gl-tooltip
:icon="scheduleVisibleAngleIcon"
:aria-label="scheduleVisibleAriaLabel"
@click="scheduleVisible = !scheduleVisible"
/>
</gl-button-group> </gl-button-group>
</div> </div>
</template> </template>
<gl-collapse :visible="scheduleVisible">
<p class="gl-text-gray-500 gl-mb-5" data-testid="scheduleBody"> <p class="gl-text-gray-500 gl-mb-5" data-testid="scheduleBody">
{{ scheduleInfo }} {{ scheduleInfo }}
</p> </p>
...@@ -249,12 +299,11 @@ export default { ...@@ -249,12 +299,11 @@ export default {
data-testid="rotationsHeader" data-testid="rotationsHeader"
> >
<h6 class="gl-m-0">{{ $options.i18n.rotationTitle }}</h6> <h6 class="gl-m-0">{{ $options.i18n.rotationTitle }}</h6>
<gl-button v-gl-modal="$options.addRotationModalId" variant="link" <gl-button v-gl-modal="addRotationModalId" variant="link"
>{{ $options.i18n.addARotation }} >{{ $options.i18n.addARotation }}
</gl-button> </gl-button>
</div> </div>
</template> </template>
<div class="schedule-shell" data-testid="rotationsBody"> <div class="schedule-shell" data-testid="rotationsBody">
<schedule-timeline-section :preset-type="presetType" :timeframe="timeframe" /> <schedule-timeline-section :preset-type="presetType" :timeframe="timeframe" />
<rotations-list-section <rotations-list-section
...@@ -267,24 +316,29 @@ export default { ...@@ -267,24 +316,29 @@ export default {
/> />
</div> </div>
</gl-card> </gl-card>
</gl-collapse>
</gl-card> </gl-card>
<delete-schedule-modal :schedule="schedule" :modal-id="$options.deleteScheduleModalId" /> <div v-if="scheduleVisible">
<edit-schedule-modal <delete-schedule-modal :schedule="schedule" :modal-id="deleteScheduleModalId" />
:schedule="schedule" <edit-schedule-modal :schedule="schedule" :modal-id="editScheduleModalId" is-edit-mode />
:modal-id="$options.editScheduleModalId"
is-edit-mode
/>
<add-edit-rotation-modal <add-edit-rotation-modal
:schedule="schedule" :schedule="schedule"
:modal-id="$options.addRotationModalId" :modal-id="addRotationModalId"
@fetch-rotation-shifts="fetchRotationShifts" @fetch-rotation-shifts="fetchRotationShifts"
/> />
<add-edit-rotation-modal <add-edit-rotation-modal
:schedule="schedule" :schedule="schedule"
:modal-id="$options.editRotationModalId" :modal-id="editRotationModalId"
:rotation="rotationToUpdate" :rotation="rotationToUpdate"
is-edit-mode is-edit-mode
@fetch-rotation-shifts="fetchRotationShifts" @fetch-rotation-shifts="fetchRotationShifts"
/> />
<delete-rotation-modal
:rotation="rotationToUpdate"
:schedule="schedule"
:modal-id="deleteRotationModalId"
@fetch-rotation-shifts="fetchRotationShifts"
/>
</div>
</div> </div>
</template> </template>
<script> <script>
import { GlAlert, GlButton, GlEmptyState, GlLoadingIcon, GlModalDirective } from '@gitlab/ui'; import {
GlAlert,
GlButton,
GlEmptyState,
GlLoadingIcon,
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getOncallSchedulesWithRotationsQuery from '../graphql/queries/get_oncall_schedules.query.graphql'; import getOncallSchedulesWithRotationsQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import AddScheduleModal from './add_edit_schedule_modal.vue'; import AddScheduleModal from './add_edit_schedule_modal.vue';
import OncallSchedule from './oncall_schedule.vue'; import OncallSchedule from './oncall_schedule.vue';
...@@ -10,10 +18,13 @@ export const addScheduleModalId = 'addScheduleModal'; ...@@ -10,10 +18,13 @@ export const addScheduleModalId = 'addScheduleModal';
export const i18n = { export const i18n = {
title: s__('OnCallSchedules|On-call schedules'), title: s__('OnCallSchedules|On-call schedules'),
add: {
button: s__('OnCallSchedules|Add a schedule'),
tooltip: s__('OnCallSchedules|Add an additional schedule to your project'),
},
emptyState: { emptyState: {
title: s__('OnCallSchedules|Create on-call schedules in GitLab'), title: s__('OnCallSchedules|Create on-call schedules in GitLab'),
description: s__('OnCallSchedules|Route alerts directly to specific members of your team'), description: s__('OnCallSchedules|Route alerts directly to specific members of your team'),
button: s__('OnCallSchedules|Add a schedule'),
}, },
successNotification: { successNotification: {
title: s__('OnCallSchedules|Try adding a rotation'), title: s__('OnCallSchedules|Try adding a rotation'),
...@@ -36,16 +47,18 @@ export default { ...@@ -36,16 +47,18 @@ export default {
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
}, },
mixins: [glFeatureFlagMixin()],
inject: ['emptyOncallSchedulesSvgPath', 'projectPath'], inject: ['emptyOncallSchedulesSvgPath', 'projectPath'],
data() { data() {
return { return {
schedule: {}, schedules: [],
showSuccessNotification: false, showSuccessNotification: false,
}; };
}, },
apollo: { apollo: {
schedule: { schedules: {
query: getOncallSchedulesWithRotationsQuery, query: getOncallSchedulesWithRotationsQuery,
variables() { variables() {
return { return {
...@@ -54,7 +67,10 @@ export default { ...@@ -54,7 +67,10 @@ export default {
}, },
update(data) { update(data) {
const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? []; const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? [];
return nodes.length ? nodes[nodes.length - 1] : null; if (this.glFeatures.multipleOncallSchedules) {
return nodes;
}
return nodes.length ? [nodes[nodes.length - 1]] : [];
}, },
error(error) { error(error) {
Sentry.captureException(error); Sentry.captureException(error);
...@@ -63,7 +79,10 @@ export default { ...@@ -63,7 +79,10 @@ export default {
}, },
computed: { computed: {
isLoading() { isLoading() {
return this.$apollo.queries.schedule.loading; return this.$apollo.queries.schedules.loading;
},
hasSchedules() {
return this.schedules.length;
}, },
}, },
}; };
...@@ -73,8 +92,23 @@ export default { ...@@ -73,8 +92,23 @@ export default {
<div> <div>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" /> <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else-if="schedule"> <template v-else-if="hasSchedules">
<div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
<h2>{{ $options.i18n.title }}</h2> <h2>{{ $options.i18n.title }}</h2>
<gl-button
v-if="glFeatures.multipleOncallSchedules"
v-gl-modal="$options.addScheduleModalId"
v-gl-tooltip.left.viewport.hover
:title="$options.i18n.add.tooltip"
:aria-label="$options.i18n.add.tooltip"
category="secondary"
variant="confirm"
class="gl-mt-5"
data-testid="add-additional-schedules-button"
>
{{ $options.i18n.add.button }}
</gl-button>
</div>
<gl-alert <gl-alert
v-if="showSuccessNotification" v-if="showSuccessNotification"
variant="tip" variant="tip"
...@@ -84,7 +118,7 @@ export default { ...@@ -84,7 +118,7 @@ export default {
> >
{{ $options.i18n.successNotification.description }} {{ $options.i18n.successNotification.description }}
</gl-alert> </gl-alert>
<oncall-schedule :schedule="schedule" /> <oncall-schedule v-for="schedule in schedules" :key="schedule.iid" :schedule="schedule" />
</template> </template>
<gl-empty-state <gl-empty-state
...@@ -94,8 +128,8 @@ export default { ...@@ -94,8 +128,8 @@ export default {
:svg-path="emptyOncallSchedulesSvgPath" :svg-path="emptyOncallSchedulesSvgPath"
> >
<template #actions> <template #actions>
<gl-button v-gl-modal="$options.addScheduleModalId" variant="info"> <gl-button v-gl-modal="$options.addScheduleModalId" variant="confirm">
{{ $options.i18n.emptyState.button }} {{ $options.i18n.add.button }}
</gl-button> </gl-button>
</template> </template>
</gl-empty-state> </gl-empty-state>
......
...@@ -29,8 +29,8 @@ export default { ...@@ -29,8 +29,8 @@ export default {
validator: (rotation) => validator: (rotation) =>
isEmpty(rotation) || [rotation.id, rotation.name, rotation.startsAt].every(Boolean), isEmpty(rotation) || [rotation.id, rotation.name, rotation.startsAt].every(Boolean),
}, },
scheduleIid: { schedule: {
type: String, type: Object,
required: true, required: true,
}, },
modalId: { modalId: {
...@@ -65,7 +65,7 @@ export default { ...@@ -65,7 +65,7 @@ export default {
const { const {
projectPath, projectPath,
rotation: { id }, rotation: { id },
scheduleIid, schedule: { iid },
} = this; } = this;
this.loading = true; this.loading = true;
...@@ -74,14 +74,14 @@ export default { ...@@ -74,14 +74,14 @@ export default {
mutation: destroyOncallRotationMutation, mutation: destroyOncallRotationMutation,
variables: { variables: {
id, id,
scheduleIid, scheduleIid: iid,
projectPath, projectPath,
}, },
update(store, { data }) { update(store, { data }) {
updateStoreAfterRotationDelete( updateStoreAfterRotationDelete(
store, store,
getOncallSchedulesQuery, getOncallSchedulesQuery,
{ ...data, scheduleIid }, { ...data, scheduleIid: iid },
{ {
projectPath, projectPath,
}, },
...@@ -93,6 +93,7 @@ export default { ...@@ -93,6 +93,7 @@ export default {
if (error) { if (error) {
throw error; throw error;
} }
this.$emit('fetch-rotation-shifts');
this.$refs.deleteRotationModal.hide(); this.$refs.deleteRotationModal.hide();
}) })
.catch((error) => { .catch((error) => {
......
...@@ -6,7 +6,6 @@ import { ...@@ -6,7 +6,6 @@ import {
GlTooltipDirective, GlTooltipDirective,
GlModalDirective, GlModalDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import DeleteRotationModal from 'ee/oncall_schedules/components/rotations/components/delete_rotation_modal.vue';
import ScheduleShiftWrapper from 'ee/oncall_schedules/components/schedule/components/shifts/components/schedule_shift_wrapper.vue'; import ScheduleShiftWrapper from 'ee/oncall_schedules/components/schedule/components/shifts/components/schedule_shift_wrapper.vue';
import { import {
editRotationModalId, editRotationModalId,
...@@ -31,7 +30,6 @@ export default { ...@@ -31,7 +30,6 @@ export default {
GlButtonGroup, GlButtonGroup,
GlLoadingIcon, GlLoadingIcon,
CurrentDayIndicator, CurrentDayIndicator,
DeleteRotationModal,
ScheduleShiftWrapper, ScheduleShiftWrapper,
}, },
directives: { directives: {
...@@ -67,6 +65,12 @@ export default { ...@@ -67,6 +65,12 @@ export default {
}; };
}, },
computed: { computed: {
editRotationModalId() {
return `${this.$options.editRotationModalId}-${this.scheduleIid}`;
},
deleteRotationModalId() {
return `${this.$options.deleteRotationModalId}-${this.scheduleIid}`;
},
timelineStyles() { timelineStyles() {
return { return {
width: `calc(${100}% - ${TIMELINE_CELL_WIDTH}px)`, width: `calc(${100}% - ${TIMELINE_CELL_WIDTH}px)`,
...@@ -117,7 +121,7 @@ export default { ...@@ -117,7 +121,7 @@ export default {
> >
<gl-button-group class="gl-px-2"> <gl-button-group class="gl-px-2">
<gl-button <gl-button
v-gl-modal="$options.editRotationModalId" v-gl-modal="editRotationModalId"
v-gl-tooltip v-gl-tooltip
category="tertiary" category="tertiary"
:title="$options.i18n.editRotationLabel" :title="$options.i18n.editRotationLabel"
...@@ -126,7 +130,7 @@ export default { ...@@ -126,7 +130,7 @@ export default {
@click="setRotationToUpdate(rotation)" @click="setRotationToUpdate(rotation)"
/> />
<gl-button <gl-button
v-gl-modal="$options.deleteRotationModalId" v-gl-modal="deleteRotationModalId"
v-gl-tooltip v-gl-tooltip
category="tertiary" category="tertiary"
:title="$options.i18n.deleteRotationLabel" :title="$options.i18n.deleteRotationLabel"
...@@ -155,11 +159,5 @@ export default { ...@@ -155,11 +159,5 @@ export default {
</span> </span>
</div> </div>
</div> </div>
<delete-rotation-modal
:rotation="rotationToUpdate"
:schedule-iid="scheduleIid"
:modal-id="$options.deleteRotationModalId"
@set-rotation-to-update="setRotationToUpdate"
/>
</div> </div>
</template> </template>
...@@ -34,11 +34,16 @@ export default { ...@@ -34,11 +34,16 @@ export default {
}, },
methods: { methods: {
updateShiftStyles() { updateShiftStyles() {
const timelineWidth = this.$refs.timelineHeaderWrapper.getBoundingClientRect().width;
// Don't re-size the schedule grid if we collapse another schedule
if (timelineWidth === 0) {
return;
}
this.$apollo.mutate({ this.$apollo.mutate({
mutation: updateTimelineWidthMutation, mutation: updateTimelineWidthMutation,
variables: { variables: {
timelineWidth: timelineWidth: timelineWidth - TIMELINE_CELL_WIDTH,
this.$refs.timelineHeaderWrapper.getBoundingClientRect().width - TIMELINE_CELL_WIDTH,
}, },
}); });
}, },
......
#import "../fragments/oncall_schedule_rotation_with_shifts.fragment.graphql" #import "../fragments/oncall_schedule_rotation_with_shifts.fragment.graphql"
query getShiftsForRotations($projectPath: ID!, $startsAt: Time!, $endsAt: Time!) { query getShiftsForRotations($projectPath: ID!, $startsAt: Time!, $endsAt: Time!, $iids: [ID!]) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
incidentManagementOncallSchedules { incidentManagementOncallSchedules(iids: $iids) {
nodes { nodes {
rotations { rotations {
nodes { nodes {
......
...@@ -48,7 +48,7 @@ const deleteScheduleFromStore = (store, query, { oncallScheduleDestroy }, variab ...@@ -48,7 +48,7 @@ const deleteScheduleFromStore = (store, query, { oncallScheduleDestroy }, variab
const data = produce(sourceData, (draftData) => { const data = produce(sourceData, (draftData) => {
draftData.project.incidentManagementOncallSchedules.nodes = draftData.project.incidentManagementOncallSchedules.nodes.filter( draftData.project.incidentManagementOncallSchedules.nodes = draftData.project.incidentManagementOncallSchedules.nodes.filter(
({ id }) => id !== schedule.id, ({ iid }) => iid !== schedule.iid,
); );
}); });
......
...@@ -4,6 +4,9 @@ module Projects ...@@ -4,6 +4,9 @@ module Projects
module IncidentManagement module IncidentManagement
class OncallSchedulesController < Projects::ApplicationController class OncallSchedulesController < Projects::ApplicationController
before_action :authorize_read_incident_management_oncall_schedule! before_action :authorize_read_incident_management_oncall_schedule!
before_action do
push_frontend_feature_flag(:multiple_oncall_schedules, @project)
end
feature_category :incident_management feature_category :incident_management
......
...@@ -10,8 +10,12 @@ module Resolvers ...@@ -10,8 +10,12 @@ module Resolvers
type Types::IncidentManagement::OncallScheduleType.connection_type, null: true type Types::IncidentManagement::OncallScheduleType.connection_type, null: true
argument :iids, [GraphQL::ID_TYPE],
required: false,
description: 'IIDs of on-call schedules.'
def resolve_with_lookahead(**args) def resolve_with_lookahead(**args)
apply_lookahead(::IncidentManagement::OncallSchedulesFinder.new(context[:current_user], project).execute) apply_lookahead(::IncidentManagement::OncallSchedulesFinder.new(context[:current_user], project, iid: args[:iids]).execute)
end end
private private
......
...@@ -8,6 +8,8 @@ import OnCallScheduleWrapper, { ...@@ -8,6 +8,8 @@ import OnCallScheduleWrapper, {
} from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue'; } from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue';
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 createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { preExistingSchedule, newlyCreatedSchedule } from './mocks/apollo_mock'; import { preExistingSchedule, newlyCreatedSchedule } from './mocks/apollo_mock';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -17,27 +19,33 @@ describe('On-call schedule wrapper', () => { ...@@ -17,27 +19,33 @@ describe('On-call schedule wrapper', () => {
const emptyOncallSchedulesSvgPath = 'illustration/path.svg'; const emptyOncallSchedulesSvgPath = 'illustration/path.svg';
const projectPath = 'group/project'; const projectPath = 'group/project';
function mountComponent({ loading, schedule } = {}) { function mountComponent({ loading, schedules, multipleOncallSchedules = false } = {}) {
const $apollo = { const $apollo = {
queries: { queries: {
schedule: { schedules: {
loading, loading,
}, },
}, },
}; };
wrapper = shallowMount(OnCallScheduleWrapper, { wrapper = extendedWrapper(
shallowMount(OnCallScheduleWrapper, {
data() { data() {
return { return {
schedule, schedules,
}; };
}, },
provide: { provide: {
emptyOncallSchedulesSvgPath, emptyOncallSchedulesSvgPath,
projectPath, projectPath,
glFeatures: { multipleOncallSchedules },
},
directives: {
GlTooltip: createMockDirective(),
}, },
mocks: { $apollo }, mocks: { $apollo },
}); }),
);
} }
let getOncallSchedulesQuerySpy; let getOncallSchedulesQuerySpy;
...@@ -53,7 +61,7 @@ describe('On-call schedule wrapper', () => { ...@@ -53,7 +61,7 @@ describe('On-call schedule wrapper', () => {
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
data() { data() {
return { return {
schedule: {}, schedules: [],
}; };
}, },
provide: { provide: {
...@@ -71,9 +79,10 @@ describe('On-call schedule wrapper', () => { ...@@ -71,9 +79,10 @@ describe('On-call schedule wrapper', () => {
const findLoader = () => wrapper.findComponent(GlLoadingIcon); const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findSchedule = () => wrapper.findComponent(OnCallSchedule); const findSchedules = () => wrapper.findAllComponents(OnCallSchedule);
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(AddScheduleModal); const findModal = () => wrapper.findComponent(AddScheduleModal);
const findAddAdditionalButton = () => wrapper.findByTestId('add-additional-schedules-button');
it('shows a loader while data is requested', () => { it('shows a loader while data is requested', () => {
mountComponent({ loading: true }); mountComponent({ loading: true });
...@@ -81,7 +90,7 @@ describe('On-call schedule wrapper', () => { ...@@ -81,7 +90,7 @@ describe('On-call schedule wrapper', () => {
}); });
it('shows empty state and passed correct attributes to it when not loading and no schedule', () => { it('shows empty state and passed correct attributes to it when not loading and no schedule', () => {
mountComponent({ loading: false, schedule: null }); mountComponent({ loading: false, schedules: [] });
const emptyState = findEmptyState(); const emptyState = findEmptyState();
expect(emptyState.exists()).toBe(true); expect(emptyState.exists()).toBe(true);
...@@ -94,13 +103,14 @@ describe('On-call schedule wrapper', () => { ...@@ -94,13 +103,14 @@ describe('On-call schedule wrapper', () => {
describe('Schedule created', () => { describe('Schedule created', () => {
beforeEach(() => { beforeEach(() => {
mountComponent({ loading: false, schedule: { name: 'monitor rotation' } }); mountComponent({ loading: false, schedules: [{ name: 'monitor rotation' }] });
}); });
it('renders the schedule when data received ', () => { it('renders the schedule when data received ', () => {
const schedule = findSchedules().at(0);
expect(findLoader().exists()).toBe(false); expect(findLoader().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false); expect(findEmptyState().exists()).toBe(false);
expect(findSchedule().exists()).toBe(true); expect(schedule.exists()).toBe(true);
}); });
it('shows success alert', async () => { it('shows success alert', async () => {
...@@ -112,8 +122,9 @@ describe('On-call schedule wrapper', () => { ...@@ -112,8 +122,9 @@ describe('On-call schedule wrapper', () => {
}); });
it('renders a newly created schedule', async () => { it('renders a newly created schedule', async () => {
const schedule = findSchedules().at(0);
await findModal().vm.$emit('scheduleCreated'); await findModal().vm.$emit('scheduleCreated');
expect(findSchedule().exists()).toBe(true); expect(schedule.exists()).toBe(true);
}); });
}); });
...@@ -134,7 +145,31 @@ describe('On-call schedule wrapper', () => { ...@@ -134,7 +145,31 @@ describe('On-call schedule wrapper', () => {
mountComponentWithApollo(); mountComponentWithApollo();
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(findSchedule().props('schedule')).toEqual(newlyCreatedSchedule); const schedule = findSchedules().at(0);
expect(schedule.props('schedule')).toEqual(newlyCreatedSchedule);
});
});
describe('when multiple schedules are allowed to be shown', () => {
beforeEach(() => {
mountComponent({
loading: false,
schedules: [{ name: 'monitor rotation' }, { name: 'monitor rotation 2' }],
multipleOncallSchedules: true,
});
});
it('renders the schedules when data received ', () => {
expect(findLoader().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
expect(findSchedules()).toHaveLength(2);
});
it('renders an add button with a tooltip for additional schedules ', () => {
const button = findAddAdditionalButton();
expect(button.exists()).toBe(true);
const tooltip = getBinding(button.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
}); });
}); });
}); });
...@@ -13,7 +13,6 @@ import { ...@@ -13,7 +13,6 @@ import {
getOncallSchedulesQueryResponse, getOncallSchedulesQueryResponse,
destroyRotationResponse, destroyRotationResponse,
destroyRotationResponseWithErrors, destroyRotationResponseWithErrors,
scheduleIid,
} from '../../mocks/apollo_mock'; } from '../../mocks/apollo_mock';
import mockRotations from '../../mocks/mock_rotation.json'; import mockRotations from '../../mocks/mock_rotation.json';
...@@ -22,6 +21,8 @@ const projectPath = 'group/project'; ...@@ -22,6 +21,8 @@ const projectPath = 'group/project';
const mutate = jest.fn(); const mutate = jest.fn();
const mockHideModal = jest.fn(); const mockHideModal = jest.fn();
const rotation = mockRotations[0]; const rotation = mockRotations[0];
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
describe('DeleteRotationModal', () => { describe('DeleteRotationModal', () => {
let wrapper; let wrapper;
...@@ -51,7 +52,7 @@ describe('DeleteRotationModal', () => { ...@@ -51,7 +52,7 @@ describe('DeleteRotationModal', () => {
}, },
propsData: { propsData: {
modalId: deleteRotationModalId, modalId: deleteRotationModalId,
scheduleIid, schedule,
rotation, rotation,
...props, ...props,
}, },
...@@ -95,7 +96,7 @@ describe('DeleteRotationModal', () => { ...@@ -95,7 +96,7 @@ describe('DeleteRotationModal', () => {
propsData: { propsData: {
rotation, rotation,
modalId: deleteRotationModalId, modalId: deleteRotationModalId,
scheduleIid, schedule,
}, },
provide: { provide: {
projectPath, projectPath,
...@@ -128,7 +129,7 @@ describe('DeleteRotationModal', () => { ...@@ -128,7 +129,7 @@ describe('DeleteRotationModal', () => {
expect(mutate).toHaveBeenCalledWith({ expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object), mutation: expect.any(Object),
update: expect.anything(), update: expect.anything(),
variables: { id: rotation.id, projectPath, scheduleIid }, variables: { id: rotation.id, projectPath, scheduleIid: schedule.iid },
}); });
}); });
......
...@@ -165,7 +165,5 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -165,7 +165,5 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
</span> </span>
</div> </div>
</div> </div>
<!---->
</div> </div>
`; `;
...@@ -8,6 +8,7 @@ RSpec.describe Resolvers::IncidentManagement::OncallScheduleResolver do ...@@ -8,6 +8,7 @@ RSpec.describe Resolvers::IncidentManagement::OncallScheduleResolver do
let_it_be(:current_user) { create(:user) } let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:oncall_schedule) { create(:incident_management_oncall_schedule, project: project) } let_it_be(:oncall_schedule) { create(:incident_management_oncall_schedule, project: project) }
let_it_be(:oncall_schedule_2) { create(:incident_management_oncall_schedule, project: project) }
subject { sync(resolve_oncall_schedules) } subject { sync(resolve_oncall_schedules) }
...@@ -21,7 +22,21 @@ RSpec.describe Resolvers::IncidentManagement::OncallScheduleResolver do ...@@ -21,7 +22,21 @@ RSpec.describe Resolvers::IncidentManagement::OncallScheduleResolver do
end end
it 'returns on-call schedules' do it 'returns on-call schedules' do
is_expected.to contain_exactly(oncall_schedule) is_expected.to contain_exactly(oncall_schedule, oncall_schedule_2)
end
context 'finding by iid' do
it 'by single iid' do
expect(resolve_oncall_schedules(iids: [oncall_schedule.iid])).to contain_exactly(oncall_schedule)
end
it 'by multiple iids' do
expect(resolve_oncall_schedules(iids: [oncall_schedule.iid, oncall_schedule_2.iid])).to contain_exactly(oncall_schedule, oncall_schedule_2)
end
it 'by no iids' do
expect(resolve_oncall_schedules(iids: [])).to match_array([])
end
end end
private private
......
...@@ -72,6 +72,7 @@ RSpec.describe 'getting Incident Management on-call schedules' do ...@@ -72,6 +72,7 @@ RSpec.describe 'getting Incident Management on-call schedules' do
context 'with on-call schedules' do context 'with on-call schedules' do
let!(:oncall_schedule) { create(:incident_management_oncall_schedule, project: project) } let!(:oncall_schedule) { create(:incident_management_oncall_schedule, project: project) }
let(:first_oncall_schedule) { oncall_schedules.first }
let(:last_oncall_schedule) { oncall_schedules.last } let(:last_oncall_schedule) { oncall_schedules.last }
before do before do
...@@ -88,6 +89,15 @@ RSpec.describe 'getting Incident Management on-call schedules' do ...@@ -88,6 +89,15 @@ RSpec.describe 'getting Incident Management on-call schedules' do
'timezone' => oncall_schedule.timezone 'timezone' => oncall_schedule.timezone
) )
end end
context 'with an array of iids given' do
let(:params) { { iids: [oncall_schedule.iid.to_s] } }
it_behaves_like 'a working graphql query'
it { expect(oncall_schedules.size).to eq(1) }
it { expect(first_oncall_schedule['iid']).to eq(oncall_schedule.iid.to_s) }
end
end end
end end
end end
...@@ -22409,6 +22409,9 @@ msgstr "" ...@@ -22409,6 +22409,9 @@ msgstr ""
msgid "OnCallSchedules|Add a schedule" msgid "OnCallSchedules|Add a schedule"
msgstr "" msgstr ""
msgid "OnCallSchedules|Add an additional schedule to your project"
msgstr ""
msgid "OnCallSchedules|Add rotation" msgid "OnCallSchedules|Add rotation"
msgstr "" msgstr ""
...@@ -22421,6 +22424,9 @@ msgstr "" ...@@ -22421,6 +22424,9 @@ msgstr ""
msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteSchedule}\" schedule? This action cannot be undone." msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteSchedule}\" schedule? This action cannot be undone."
msgstr "" msgstr ""
msgid "OnCallSchedules|Collapse schedule"
msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab" msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr "" msgstr ""
...@@ -22442,6 +22448,9 @@ msgstr "" ...@@ -22442,6 +22448,9 @@ msgstr ""
msgid "OnCallSchedules|Enable end date" msgid "OnCallSchedules|Enable end date"
msgstr "" msgstr ""
msgid "OnCallSchedules|Expand schedule"
msgstr ""
msgid "OnCallSchedules|Failed to add rotation" msgid "OnCallSchedules|Failed to add rotation"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment