Commit 116cd1d7 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '262857-add-a-rotation-modal' into 'master'

Add rotation modal

See merge request gitlab-org/gitlab!48553
parents 36ac4071 3eb079ea
#import "../fragments/user.fragment.graphql"
query usersSearch($search: String!) {
users(search: $search) {
nodes {
...User
}
}
}
<script>
import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import AddScheduleModal from './add_schedule_modal.vue';
import AddRotationModal from './rotations/add_rotation_modal.vue';
import { s__ } from '~/locale';
const addScheduleModalId = 'addScheduleModal';
......@@ -21,6 +22,7 @@ export default {
GlEmptyState,
GlButton,
AddScheduleModal,
AddRotationModal,
},
directives: {
GlModal: GlModalDirective,
......@@ -40,8 +42,12 @@ export default {
<gl-button v-gl-modal="$options.addScheduleModalId" variant="info">
{{ $options.i18n.emptyState.button }}
</gl-button>
<gl-button v-gl-modal="'create-schedule-rotation-modal'" variant="danger">
{{ $options.i18n.emptyState.button }}
</gl-button>
</template>
</gl-empty-state>
<add-schedule-modal :modal-id="$options.addScheduleModalId" />
<add-rotation-modal />
</div>
</template>
<script>
import {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlAlert,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import createOncallScheduleRotationMutation from '../../graphql/create_oncall_schedule_rotation.mutation.graphql';
import {
LENGTH_ENUM,
CHEVRON_SKIPPING_SHADE_ENUM,
CHEVRON_SKIPPING_PALETTE_ENUM,
} from '../../constants';
export default {
i18n: {
selectParticipant: s__('OnCallSchedules|Select participant'),
addRotation: s__('OnCallSchedules|Add rotation'),
noResults: __('No matching results'),
cancel: __('Cancel'),
errorMsg: s__('OnCallSchedules|Failed to add rotation'),
fields: {
name: { title: __('Name'), error: s__('OnCallSchedules|Rotation name cannot be empty') },
participants: {
title: __('Participants'),
error: s__('OnCallSchedules|Rotation participants cannot be empty'),
},
length: { title: s__('OnCallSchedules|Rotation length') },
startsOn: {
title: __('Starts on'),
error: s__('OnCallSchedules|Rotation start date cannot be empty'),
},
},
},
tokenColorPalette: {
shade: CHEVRON_SKIPPING_SHADE_ENUM,
palette: CHEVRON_SKIPPING_PALETTE_ENUM,
},
LENGTH_ENUM,
inject: ['projectPath'],
components: {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlAlert,
},
apollo: {
participants: {
query: usersSearchQuery,
variables() {
return {
search: this.ptSearchTerm,
};
},
update({ users: { nodes = [] } = {} }) {
return nodes;
},
error(error) {
this.showErrorAlert = true;
this.error = error;
},
},
},
data() {
return {
participants: [],
loading: false,
ptSearchTerm: '',
form: {
name: '',
participants: [],
length: {
value: 1,
type: this.$options.LENGTH_ENUM.hours,
},
startsOn: {
date: null,
time: 0,
},
},
showErrorAlert: false,
error: '',
};
},
computed: {
actionsProps() {
return {
primary: {
text: this.$options.i18n.addRotation,
attributes: [{ variant: 'info' }, { loading: this.loading }],
},
cancel: {
text: this.$options.i18n.cancel,
},
};
},
rotationNameIsValid() {
return this.form.name !== '';
},
rotationParticipantsAreValid() {
return this.form.participants.length > 0;
},
rotationStartsOnIsValid() {
return this.form.startsOn.date !== null || this.form.startsOn.date !== undefined;
},
noResults() {
return this.participants.length === 0;
},
},
methods: {
createRotation() {
this.loading = true;
this.$apollo
.mutate({
mutation: createOncallScheduleRotationMutation,
variables: {
oncallScheduleRotationCreate: {
projectPath: this.projectPath,
...this.form,
},
},
})
.then(({ data: { oncallScheduleRotationCreate: { errors: [error] } } }) => {
if (error) {
throw error;
}
this.$refs.createScheduleModal.hide();
})
.catch(error => {
this.error = error;
this.showErrorAlert = true;
})
.finally(() => {
this.loading = false;
});
},
formatTime(time) {
return time > 9 ? `${time}:00` : `0${time}:00`;
},
filterParticipants(query) {
this.ptSearchTerm = query;
},
setRotationLengthType(type) {
this.form.length.type = type;
},
setRotationStartsOnTime(time) {
this.form.startsOn.time = time;
},
},
};
</script>
<template>
<gl-modal
ref="createScheduleRotationModal"
modal-id="create-schedule-rotation-modal"
size="sm"
:title="$options.i18n.addRotation"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
@primary="createRotation"
>
<gl-alert v-if="showErrorAlert" variant="danger" @dismiss="showErrorAlert = false">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<gl-form class="w-75 gl-xs-w-full!" @submit.prevent="createRotation">
<gl-form-group
:label="$options.i18n.fields.name.title"
label-size="sm"
label-for="rotation-name"
:invalid-feedback="$options.i18n.fields.name.error"
:state="rotationNameIsValid"
>
<gl-form-input id="rotation-name" v-model="form.name" />
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.participants.title"
label-size="sm"
label-for="rotation-participants"
:invalid-feedback="$options.i18n.fields.participants.error"
:state="rotationParticipantsAreValid"
>
<gl-token-selector
v-model="form.participants"
:dropdown-items="participants"
:loading="this.$apollo.queries.participants.loading"
:container-class="'gl-h-13! gl-overflow-y-auto'"
@text-input="filterParticipants"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatarUrl" :src="token.avatarUrl" :size="16" />
{{ token.name }}
</template>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatarUrl"
:size="32"
:label="dropdownItem.name"
:sub-label="dropdownItem.username"
/>
</template>
</gl-token-selector>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.length.title"
label-size="sm"
label-for="rotation-length"
>
<div class="gl-display-flex">
<gl-form-input
id="rotation-length"
v-model="form.length.value"
type="number"
class="gl-w-12 gl-mr-3"
min="1"
/>
<gl-dropdown id="rotation-length" :text="form.length.type">
<gl-dropdown-item
v-for="type in $options.LENGTH_ENUM"
:key="type"
:is-checked="form.length.type === type"
is-check-item
@click="setRotationLengthType(type)"
>
{{ type }}
</gl-dropdown-item>
</gl-dropdown>
</div>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.startsOn.title"
label-size="sm"
label-for="rotation-time"
:invalid-feedback="$options.i18n.fields.startsOn.error"
:state="rotationStartsOnIsValid"
>
<div class="gl-display-flex gl-align-items-center">
<gl-datepicker v-model="form.startsOn.date" class="gl-mr-3" />
<span> {{ __('at') }} </span>
<gl-dropdown
id="rotation-time"
:text="formatTime(form.startsOn.time)"
class="gl-w-12 gl-pl-3"
>
<gl-dropdown-item
v-for="n in 24"
:key="n"
:is-checked="form.startsOn.time === n"
is-check-item
@click="setRotationStartsOnTime(n)"
>
<span class="gl-white-space-nowrap"> {{ formatTime(n) }}</span>
</gl-dropdown-item>
</gl-dropdown>
<!-- TODO: // Replace with actual timezone following coming work -->
<span class="gl-pl-5"> {{ __('PST') }} </span>
</div>
</gl-form-group>
</gl-form>
</gl-modal>
</template>
export const LENGTH_ENUM = {
hours: 'hours',
days: 'days',
weeks: 'weeks',
};
export const CHEVRON_SKIPPING_SHADE_ENUM = ['500', '600', '700', '800', '900', '950'];
export const CHEVRON_SKIPPING_PALETTE_ENUM = ['blue', 'orange', 'aqua', 'green', 'magenta'];
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation oncallScheduleRotationCreate(
$oncallScheduleRotationCreateInput: OncallScheduleRotationCreateInput!
) {
oncallScheduleRotationCreate(input: $oncallScheduleRotationCreateInput) {
errors
oncallScheduleRotation {
iid
name
participants {
nodes {
...User
}
}
length {
value
type
}
startsOn {
date
time
}
}
}
}
export const participants = [
{
id: '1',
username: 'test',
name: 'test',
avatar: '',
avatarUrl: '',
},
{
id: '2',
username: 'hello',
name: 'hello',
avatar: '',
avatarUrl: '',
},
];
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import { GlDropdownItem, GlModal, GlAlert, GlTokenSelector } from '@gitlab/ui';
import AddRotationModal from 'ee/oncall_schedules/components/rotations/add_rotation_modal.vue';
// import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/create_oncall_schedule_rotation.mutation.graphql';
import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { participants } from '../mocks/apollo_mock';
const localVue = createLocalVue();
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
localVue.use(VueApollo);
describe('AddRotationModal', () => {
let wrapper;
let fakeApollo;
let userSearchQueryHandler;
async function awaitApolloDomMock() {
await wrapper.vm.$nextTick(); // kick off the DOM update
await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
await wrapper.vm.$nextTick(); // kick off the DOM update for flash
}
const createComponent = ({ data = {}, props = {}, loading = false } = {}) => {
wrapper = shallowMount(AddRotationModal, {
data() {
return {
...data,
};
},
propsData: {
...props,
},
provide: {
projectPath,
},
mocks: {
$apollo: {
queries: {
participants: {
loading,
},
},
mutate,
},
},
});
wrapper.vm.$refs.createScheduleRotationModal.hide = mockHideModal;
};
const createComponentWithApollo = ({ search = '' } = {}) => {
fakeApollo = createMockApollo([[usersSearchQuery, userSearchQueryHandler]]);
wrapper = shallowMount(AddRotationModal, {
localVue,
apolloProvider: fakeApollo,
data() {
return {
ptSearchTerm: search,
form: {
participants,
},
participants,
};
},
provide: {
projectPath,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findModal = () => wrapper.find(GlModal);
const findRotationLength = () => wrapper.find('[id = "rotation-length"]');
const findRotationStartsOn = () => wrapper.find('[id = "rotation-time"]');
const findUserSelector = () => wrapper.find(GlTokenSelector);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
const findAlert = () => wrapper.find(GlAlert);
it('renders rotation modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('Rotation length and start time', () => {
it('renders the rotation length value', async () => {
const rotationLength = findRotationLength();
expect(rotationLength.exists()).toBe(true);
expect(rotationLength.attributes('value')).toBe('1');
});
it('renders the rotation starts on datepicker', async () => {
const startsOn = findRotationStartsOn();
expect(startsOn.exists()).toBe(true);
expect(startsOn.attributes('text')).toBe('00:00');
expect(startsOn.attributes('headertext')).toBe('');
});
it('should add a check for a rotation length type selected', async () => {
const selectedLengthType1 = findDropdownOptions().at(0);
const selectedLengthType2 = findDropdownOptions().at(1);
selectedLengthType1.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(selectedLengthType1.props('isChecked')).toBe(true);
expect(selectedLengthType2.props('isChecked')).toBe(false);
});
});
describe('filter participants', () => {
beforeEach(() => {
createComponent({ data: { participants } });
});
it('has user options that are populated via apollo', () => {
expect(findUserSelector().props('dropdownItems').length).toBe(participants.length);
});
it('calls the API and sets dropdown items as request result', async () => {
const tokenSelector = findUserSelector();
tokenSelector.vm.$emit('focus');
tokenSelector.vm.$emit('blur');
tokenSelector.vm.$emit('focus');
await waitForPromises();
expect(tokenSelector.props('dropdownItems')).toMatchObject(participants);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
it('emits `input` event with selected users', () => {
findUserSelector().vm.$emit('input', participants);
expect(findUserSelector().emitted().input[0][0]).toEqual(participants);
});
it('when text input is blurred the text input clears', async () => {
const tokenSelector = findUserSelector();
tokenSelector.vm.$emit('blur');
await wrapper.vm.$nextTick();
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
describe('Rotation create', () => {
it('makes a request with `oncallScheduleRotationCreate` to create a schedule rotation', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
variables: { oncallScheduleRotationCreate: expect.objectContaining({ projectPath }) },
});
});
it('does not hide the rotation modal and shows error alert on fail', async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { oncallScheduleRotationCreate: { errors: [error] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).not.toHaveBeenCalled();
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toContain(error);
});
});
describe('with mocked Apollo client', () => {
it('it calls searchUsers query with the search paramter', async () => {
userSearchQueryHandler = jest.fn().mockResolvedValue({
data: {
users: {
nodes: participants,
},
},
});
createComponentWithApollo({ search: 'root' });
await awaitApolloDomMock();
expect(userSearchQueryHandler).toHaveBeenCalledWith({ search: 'root' });
});
// TODO: Once the BE is complete for the mutation add specs here for that via a creationHandler
});
});
......@@ -19085,18 +19085,39 @@ msgstr ""
msgid "OnCallSchedules|Add a schedule"
msgstr ""
msgid "OnCallSchedules|Add rotation"
msgstr ""
msgid "OnCallSchedules|Add schedule"
msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr ""
msgid "OnCallSchedules|Failed to add rotation"
msgstr ""
msgid "OnCallSchedules|Failed to add schedule"
msgstr ""
msgid "OnCallSchedules|Rotation length"
msgstr ""
msgid "OnCallSchedules|Rotation name cannot be empty"
msgstr ""
msgid "OnCallSchedules|Rotation participants cannot be empty"
msgstr ""
msgid "OnCallSchedules|Rotation start date cannot be empty"
msgstr ""
msgid "OnCallSchedules|Route alerts directly to specific members of your team"
msgstr ""
msgid "OnCallSchedules|Select participant"
msgstr ""
msgid "OnCallSchedules|Select timezone"
msgstr ""
......@@ -19414,6 +19435,9 @@ msgstr ""
msgid "Owner"
msgstr ""
msgid "PST"
msgstr ""
msgid "Package Registry"
msgstr ""
......@@ -26042,6 +26066,9 @@ msgstr ""
msgid "Starts at (UTC)"
msgstr ""
msgid "Starts on"
msgstr ""
msgid "State your message to activate"
msgstr ""
......@@ -31920,6 +31947,9 @@ msgstr ""
msgid "assign yourself"
msgstr ""
msgid "at"
msgstr ""
msgid "at risk"
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