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 { 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