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.
| <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="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="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. |
......@@ -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="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`
A single issue of the project.
......
<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 { capitalize } from 'lodash';
import {
......@@ -11,11 +18,17 @@ import {
nDaysAfter,
} from '~/lib/utils/datetime_utility';
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 EditScheduleModal from './add_edit_schedule_modal.vue';
import DeleteScheduleModal from './delete_schedule_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 ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue';
import { getTimeframeForWeeksView, selectedTimezoneFormattedOffset } from './schedule/utils';
......@@ -31,6 +44,8 @@ export const i18n = {
DAYS: s__('OnCallSchedules|1 day'),
WEEKS: s__('OnCallSchedules|2 weeks'),
},
scheduleOpen: s__('OnCallSchedules|Expand schedule'),
scheduleClose: s__('OnCallSchedules|Collapse schedule'),
};
export const editScheduleModalId = 'editScheduleModal';
export const deleteScheduleModalId = 'deleteScheduleModal';
......@@ -40,13 +55,16 @@ export default {
addRotationModalId,
editRotationModalId,
editScheduleModalId,
deleteRotationModalId,
deleteScheduleModalId,
PRESET_TYPES,
components: {
GlButton,
GlButtonGroup,
GlCard,
GlCollapse,
AddEditRotationModal,
DeleteRotationModal,
DeleteScheduleModal,
EditScheduleModal,
RotationsListSection,
......@@ -78,11 +96,12 @@ export default {
projectPath: this.projectPath,
startsAt,
endsAt,
iids: [this.schedule.iid],
};
},
update(data) {
const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? [];
const schedule = nodes.length ? nodes[nodes.length - 1] : null;
const [schedule] = nodes;
return schedule?.rotations.nodes ?? [];
},
error(error) {
......@@ -96,9 +115,25 @@ export default {
timeframeStartDate: getStartOfWeek(new Date()),
rotations: this.schedule.rotations.nodes,
rotationToUpdate: {},
scheduleVisible: true,
};
},
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() {
return this.$apollo.queries.rotations.loading;
},
......@@ -129,6 +164,14 @@ export default {
}
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() {
return this.timezones.find((tz) => tz.identifier === this.schedule.timezone);
},
......@@ -190,101 +233,112 @@ export default {
<span class="gl-font-weight-bold gl-font-lg">{{ schedule.name }}</span>
<gl-button-group>
<gl-button
v-gl-modal="$options.editScheduleModalId"
v-gl-modal="editScheduleModalId"
v-gl-tooltip
:title="$options.i18n.editScheduleLabel"
icon="pencil"
:aria-label="$options.i18n.editScheduleLabel"
/>
<gl-button
v-gl-modal="$options.deleteScheduleModalId"
v-gl-modal="deleteScheduleModalId"
v-gl-tooltip
:title="$options.i18n.deleteScheduleLabel"
icon="remove"
:aria-label="$options.i18n.deleteScheduleLabel"
/>
<gl-button
v-gl-tooltip
:icon="scheduleVisibleAngleIcon"
:aria-label="scheduleVisibleAriaLabel"
@click="scheduleVisible = !scheduleVisible"
/>
</gl-button-group>
</div>
</template>
<p class="gl-text-gray-500 gl-mb-5" data-testid="scheduleBody">
{{ scheduleInfo }}
</p>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
<div class="gl-display-flex gl-align-items-center">
<gl-button-group>
<gl-button
data-testid="previous-timeframe-btn"
icon="chevron-left"
:disabled="loading"
:aria-label="$options.i18n.viewPreviousTimeframe"
@click="updateToViewPreviousTimeframe"
/>
<gl-collapse :visible="scheduleVisible">
<p class="gl-text-gray-500 gl-mb-5" data-testid="scheduleBody">
{{ scheduleInfo }}
</p>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
<div class="gl-display-flex gl-align-items-center">
<gl-button-group>
<gl-button
data-testid="previous-timeframe-btn"
icon="chevron-left"
:disabled="loading"
:aria-label="$options.i18n.viewPreviousTimeframe"
@click="updateToViewPreviousTimeframe"
/>
<gl-button
data-testid="next-timeframe-btn"
icon="chevron-right"
:disabled="loading"
:aria-label="$options.i18n.viewNextTimeframe"
@click="updateToViewNextTimeframe"
/>
</gl-button-group>
<div class="gl-ml-3">{{ scheduleRange }}</div>
</div>
<gl-button-group data-testid="shift-preset-change">
<gl-button
data-testid="next-timeframe-btn"
icon="chevron-right"
:disabled="loading"
:aria-label="$options.i18n.viewNextTimeframe"
@click="updateToViewNextTimeframe"
/>
v-for="type in $options.PRESET_TYPES"
:key="type"
:selected="type === presetType"
:title="formatPresetType(type)"
@click="switchPresetType(type)"
>
{{ $options.i18n.presetTypeLabels[type] }}
</gl-button>
</gl-button-group>
<div class="gl-ml-3">{{ scheduleRange }}</div>
</div>
<gl-button-group data-testid="shift-preset-change">
<gl-button
v-for="type in $options.PRESET_TYPES"
:key="type"
:selected="type === presetType"
:title="formatPresetType(type)"
@click="switchPresetType(type)"
>
{{ $options.i18n.presetTypeLabels[type] }}
</gl-button>
</gl-button-group>
</div>
<gl-card header-class="gl-bg-transparent">
<template #header>
<div
class="gl-display-flex gl-justify-content-space-between"
data-testid="rotationsHeader"
>
<h6 class="gl-m-0">{{ $options.i18n.rotationTitle }}</h6>
<gl-button v-gl-modal="$options.addRotationModalId" variant="link"
>{{ $options.i18n.addARotation }}
</gl-button>
<gl-card header-class="gl-bg-transparent">
<template #header>
<div
class="gl-display-flex gl-justify-content-space-between"
data-testid="rotationsHeader"
>
<h6 class="gl-m-0">{{ $options.i18n.rotationTitle }}</h6>
<gl-button v-gl-modal="addRotationModalId" variant="link"
>{{ $options.i18n.addARotation }}
</gl-button>
</div>
</template>
<div class="schedule-shell" data-testid="rotationsBody">
<schedule-timeline-section :preset-type="presetType" :timeframe="timeframe" />
<rotations-list-section
:preset-type="presetType"
:rotations="rotations"
:timeframe="timeframe"
:schedule-iid="schedule.iid"
:loading="loading"
@set-rotation-to-update="setRotationToUpdate"
/>
</div>
</template>
<div class="schedule-shell" data-testid="rotationsBody">
<schedule-timeline-section :preset-type="presetType" :timeframe="timeframe" />
<rotations-list-section
:preset-type="presetType"
:rotations="rotations"
:timeframe="timeframe"
:schedule-iid="schedule.iid"
:loading="loading"
@set-rotation-to-update="setRotationToUpdate"
/>
</div>
</gl-card>
</gl-card>
</gl-collapse>
</gl-card>
<delete-schedule-modal :schedule="schedule" :modal-id="$options.deleteScheduleModalId" />
<edit-schedule-modal
:schedule="schedule"
:modal-id="$options.editScheduleModalId"
is-edit-mode
/>
<add-edit-rotation-modal
:schedule="schedule"
:modal-id="$options.addRotationModalId"
@fetch-rotation-shifts="fetchRotationShifts"
/>
<add-edit-rotation-modal
:schedule="schedule"
:modal-id="$options.editRotationModalId"
:rotation="rotationToUpdate"
is-edit-mode
@fetch-rotation-shifts="fetchRotationShifts"
/>
<div v-if="scheduleVisible">
<delete-schedule-modal :schedule="schedule" :modal-id="deleteScheduleModalId" />
<edit-schedule-modal :schedule="schedule" :modal-id="editScheduleModalId" is-edit-mode />
<add-edit-rotation-modal
:schedule="schedule"
:modal-id="addRotationModalId"
@fetch-rotation-shifts="fetchRotationShifts"
/>
<add-edit-rotation-modal
:schedule="schedule"
:modal-id="editRotationModalId"
:rotation="rotationToUpdate"
is-edit-mode
@fetch-rotation-shifts="fetchRotationShifts"
/>
<delete-rotation-modal
:rotation="rotationToUpdate"
:schedule="schedule"
:modal-id="deleteRotationModalId"
@fetch-rotation-shifts="fetchRotationShifts"
/>
</div>
</div>
</template>
<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 { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getOncallSchedulesWithRotationsQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import AddScheduleModal from './add_edit_schedule_modal.vue';
import OncallSchedule from './oncall_schedule.vue';
......@@ -10,10 +18,13 @@ export const addScheduleModalId = 'addScheduleModal';
export const i18n = {
title: s__('OnCallSchedules|On-call schedules'),
add: {
button: s__('OnCallSchedules|Add a schedule'),
tooltip: s__('OnCallSchedules|Add an additional schedule to your project'),
},
emptyState: {
title: s__('OnCallSchedules|Create on-call schedules in GitLab'),
description: s__('OnCallSchedules|Route alerts directly to specific members of your team'),
button: s__('OnCallSchedules|Add a schedule'),
},
successNotification: {
title: s__('OnCallSchedules|Try adding a rotation'),
......@@ -36,16 +47,18 @@ export default {
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
inject: ['emptyOncallSchedulesSvgPath', 'projectPath'],
data() {
return {
schedule: {},
schedules: [],
showSuccessNotification: false,
};
},
apollo: {
schedule: {
schedules: {
query: getOncallSchedulesWithRotationsQuery,
variables() {
return {
......@@ -54,7 +67,10 @@ export default {
},
update(data) {
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) {
Sentry.captureException(error);
......@@ -63,7 +79,10 @@ export default {
},
computed: {
isLoading() {
return this.$apollo.queries.schedule.loading;
return this.$apollo.queries.schedules.loading;
},
hasSchedules() {
return this.schedules.length;
},
},
};
......@@ -73,8 +92,23 @@ export default {
<div>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else-if="schedule">
<h2>{{ $options.i18n.title }}</h2>
<template v-else-if="hasSchedules">
<div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
<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
v-if="showSuccessNotification"
variant="tip"
......@@ -84,7 +118,7 @@ export default {
>
{{ $options.i18n.successNotification.description }}
</gl-alert>
<oncall-schedule :schedule="schedule" />
<oncall-schedule v-for="schedule in schedules" :key="schedule.iid" :schedule="schedule" />
</template>
<gl-empty-state
......@@ -94,8 +128,8 @@ export default {
:svg-path="emptyOncallSchedulesSvgPath"
>
<template #actions>
<gl-button v-gl-modal="$options.addScheduleModalId" variant="info">
{{ $options.i18n.emptyState.button }}
<gl-button v-gl-modal="$options.addScheduleModalId" variant="confirm">
{{ $options.i18n.add.button }}
</gl-button>
</template>
</gl-empty-state>
......
......@@ -29,8 +29,8 @@ export default {
validator: (rotation) =>
isEmpty(rotation) || [rotation.id, rotation.name, rotation.startsAt].every(Boolean),
},
scheduleIid: {
type: String,
schedule: {
type: Object,
required: true,
},
modalId: {
......@@ -65,7 +65,7 @@ export default {
const {
projectPath,
rotation: { id },
scheduleIid,
schedule: { iid },
} = this;
this.loading = true;
......@@ -74,14 +74,14 @@ export default {
mutation: destroyOncallRotationMutation,
variables: {
id,
scheduleIid,
scheduleIid: iid,
projectPath,
},
update(store, { data }) {
updateStoreAfterRotationDelete(
store,
getOncallSchedulesQuery,
{ ...data, scheduleIid },
{ ...data, scheduleIid: iid },
{
projectPath,
},
......@@ -93,6 +93,7 @@ export default {
if (error) {
throw error;
}
this.$emit('fetch-rotation-shifts');
this.$refs.deleteRotationModal.hide();
})
.catch((error) => {
......
......@@ -6,7 +6,6 @@ import {
GlTooltipDirective,
GlModalDirective,
} 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 {
editRotationModalId,
......@@ -31,7 +30,6 @@ export default {
GlButtonGroup,
GlLoadingIcon,
CurrentDayIndicator,
DeleteRotationModal,
ScheduleShiftWrapper,
},
directives: {
......@@ -67,6 +65,12 @@ export default {
};
},
computed: {
editRotationModalId() {
return `${this.$options.editRotationModalId}-${this.scheduleIid}`;
},
deleteRotationModalId() {
return `${this.$options.deleteRotationModalId}-${this.scheduleIid}`;
},
timelineStyles() {
return {
width: `calc(${100}% - ${TIMELINE_CELL_WIDTH}px)`,
......@@ -117,7 +121,7 @@ export default {
>
<gl-button-group class="gl-px-2">
<gl-button
v-gl-modal="$options.editRotationModalId"
v-gl-modal="editRotationModalId"
v-gl-tooltip
category="tertiary"
:title="$options.i18n.editRotationLabel"
......@@ -126,7 +130,7 @@ export default {
@click="setRotationToUpdate(rotation)"
/>
<gl-button
v-gl-modal="$options.deleteRotationModalId"
v-gl-modal="deleteRotationModalId"
v-gl-tooltip
category="tertiary"
:title="$options.i18n.deleteRotationLabel"
......@@ -155,11 +159,5 @@ export default {
</span>
</div>
</div>
<delete-rotation-modal
:rotation="rotationToUpdate"
:schedule-iid="scheduleIid"
:modal-id="$options.deleteRotationModalId"
@set-rotation-to-update="setRotationToUpdate"
/>
</div>
</template>
......@@ -34,11 +34,16 @@ export default {
},
methods: {
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({
mutation: updateTimelineWidthMutation,
variables: {
timelineWidth:
this.$refs.timelineHeaderWrapper.getBoundingClientRect().width - TIMELINE_CELL_WIDTH,
timelineWidth: timelineWidth - TIMELINE_CELL_WIDTH,
},
});
},
......
#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) {
incidentManagementOncallSchedules {
incidentManagementOncallSchedules(iids: $iids) {
nodes {
rotations {
nodes {
......
......@@ -48,7 +48,7 @@ const deleteScheduleFromStore = (store, query, { oncallScheduleDestroy }, variab
const data = produce(sourceData, (draftData) => {
draftData.project.incidentManagementOncallSchedules.nodes = draftData.project.incidentManagementOncallSchedules.nodes.filter(
({ id }) => id !== schedule.id,
({ iid }) => iid !== schedule.iid,
);
});
......
......@@ -4,6 +4,9 @@ module Projects
module IncidentManagement
class OncallSchedulesController < Projects::ApplicationController
before_action :authorize_read_incident_management_oncall_schedule!
before_action do
push_frontend_feature_flag(:multiple_oncall_schedules, @project)
end
feature_category :incident_management
......
......@@ -10,8 +10,12 @@ module Resolvers
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)
apply_lookahead(::IncidentManagement::OncallSchedulesFinder.new(context[:current_user], project).execute)
apply_lookahead(::IncidentManagement::OncallSchedulesFinder.new(context[:current_user], project, iid: args[:iids]).execute)
end
private
......
......@@ -8,6 +8,8 @@ import OnCallScheduleWrapper, {
} from 'ee/oncall_schedules/components/oncall_schedules_wrapper.vue';
import getOncallSchedulesWithRotationsQuery from 'ee/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql';
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';
const localVue = createLocalVue();
......@@ -17,27 +19,33 @@ describe('On-call schedule wrapper', () => {
const emptyOncallSchedulesSvgPath = 'illustration/path.svg';
const projectPath = 'group/project';
function mountComponent({ loading, schedule } = {}) {
function mountComponent({ loading, schedules, multipleOncallSchedules = false } = {}) {
const $apollo = {
queries: {
schedule: {
schedules: {
loading,
},
},
};
wrapper = shallowMount(OnCallScheduleWrapper, {
data() {
return {
schedule,
};
},
provide: {
emptyOncallSchedulesSvgPath,
projectPath,
},
mocks: { $apollo },
});
wrapper = extendedWrapper(
shallowMount(OnCallScheduleWrapper, {
data() {
return {
schedules,
};
},
provide: {
emptyOncallSchedulesSvgPath,
projectPath,
glFeatures: { multipleOncallSchedules },
},
directives: {
GlTooltip: createMockDirective(),
},
mocks: { $apollo },
}),
);
}
let getOncallSchedulesQuerySpy;
......@@ -53,7 +61,7 @@ describe('On-call schedule wrapper', () => {
apolloProvider: fakeApollo,
data() {
return {
schedule: {},
schedules: [],
};
},
provide: {
......@@ -71,9 +79,10 @@ describe('On-call schedule wrapper', () => {
const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findSchedule = () => wrapper.findComponent(OnCallSchedule);
const findSchedules = () => wrapper.findAllComponents(OnCallSchedule);
const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(AddScheduleModal);
const findAddAdditionalButton = () => wrapper.findByTestId('add-additional-schedules-button');
it('shows a loader while data is requested', () => {
mountComponent({ loading: true });
......@@ -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', () => {
mountComponent({ loading: false, schedule: null });
mountComponent({ loading: false, schedules: [] });
const emptyState = findEmptyState();
expect(emptyState.exists()).toBe(true);
......@@ -94,13 +103,14 @@ describe('On-call schedule wrapper', () => {
describe('Schedule created', () => {
beforeEach(() => {
mountComponent({ loading: false, schedule: { name: 'monitor rotation' } });
mountComponent({ loading: false, schedules: [{ name: 'monitor rotation' }] });
});
it('renders the schedule when data received ', () => {
const schedule = findSchedules().at(0);
expect(findLoader().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
expect(findSchedule().exists()).toBe(true);
expect(schedule.exists()).toBe(true);
});
it('shows success alert', async () => {
......@@ -112,8 +122,9 @@ describe('On-call schedule wrapper', () => {
});
it('renders a newly created schedule', async () => {
const schedule = findSchedules().at(0);
await findModal().vm.$emit('scheduleCreated');
expect(findSchedule().exists()).toBe(true);
expect(schedule.exists()).toBe(true);
});
});
......@@ -134,7 +145,31 @@ describe('On-call schedule wrapper', () => {
mountComponentWithApollo();
jest.runOnlyPendingTimers();
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 {
getOncallSchedulesQueryResponse,
destroyRotationResponse,
destroyRotationResponseWithErrors,
scheduleIid,
} from '../../mocks/apollo_mock';
import mockRotations from '../../mocks/mock_rotation.json';
......@@ -22,6 +21,8 @@ const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
const rotation = mockRotations[0];
const schedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
describe('DeleteRotationModal', () => {
let wrapper;
......@@ -51,7 +52,7 @@ describe('DeleteRotationModal', () => {
},
propsData: {
modalId: deleteRotationModalId,
scheduleIid,
schedule,
rotation,
...props,
},
......@@ -95,7 +96,7 @@ describe('DeleteRotationModal', () => {
propsData: {
rotation,
modalId: deleteRotationModalId,
scheduleIid,
schedule,
},
provide: {
projectPath,
......@@ -128,7 +129,7 @@ describe('DeleteRotationModal', () => {
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
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
</span>
</div>
</div>
<!---->
</div>
`;
......@@ -8,6 +8,7 @@ RSpec.describe Resolvers::IncidentManagement::OncallScheduleResolver do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(: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) }
......@@ -21,7 +22,21 @@ RSpec.describe Resolvers::IncidentManagement::OncallScheduleResolver do
end
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
private
......
......@@ -72,6 +72,7 @@ RSpec.describe 'getting Incident Management on-call schedules' do
context 'with on-call schedules' do
let!(:oncall_schedule) { create(:incident_management_oncall_schedule, project: project) }
let(:first_oncall_schedule) { oncall_schedules.first }
let(:last_oncall_schedule) { oncall_schedules.last }
before do
......@@ -88,6 +89,15 @@ RSpec.describe 'getting Incident Management on-call schedules' do
'timezone' => oncall_schedule.timezone
)
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
......@@ -22409,6 +22409,9 @@ msgstr ""
msgid "OnCallSchedules|Add a schedule"
msgstr ""
msgid "OnCallSchedules|Add an additional schedule to your project"
msgstr ""
msgid "OnCallSchedules|Add rotation"
msgstr ""
......@@ -22421,6 +22424,9 @@ msgstr ""
msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteSchedule}\" schedule? This action cannot be undone."
msgstr ""
msgid "OnCallSchedules|Collapse schedule"
msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr ""
......@@ -22442,6 +22448,9 @@ msgstr ""
msgid "OnCallSchedules|Enable end date"
msgstr ""
msgid "OnCallSchedules|Expand schedule"
msgstr ""
msgid "OnCallSchedules|Failed to add rotation"
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