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> <script>
import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui'; import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import AddScheduleModal from './add_schedule_modal.vue'; import AddScheduleModal from './add_schedule_modal.vue';
import AddRotationModal from './rotations/add_rotation_modal.vue';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
const addScheduleModalId = 'addScheduleModal'; const addScheduleModalId = 'addScheduleModal';
...@@ -21,6 +22,7 @@ export default { ...@@ -21,6 +22,7 @@ export default {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
AddScheduleModal, AddScheduleModal,
AddRotationModal,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
...@@ -40,8 +42,12 @@ export default { ...@@ -40,8 +42,12 @@ export default {
<gl-button v-gl-modal="$options.addScheduleModalId" variant="info"> <gl-button v-gl-modal="$options.addScheduleModalId" variant="info">
{{ $options.i18n.emptyState.button }} {{ $options.i18n.emptyState.button }}
</gl-button> </gl-button>
<gl-button v-gl-modal="'create-schedule-rotation-modal'" variant="danger">
{{ $options.i18n.emptyState.button }}
</gl-button>
</template> </template>
</gl-empty-state> </gl-empty-state>
<add-schedule-modal :modal-id="$options.addScheduleModalId" /> <add-schedule-modal :modal-id="$options.addScheduleModalId" />
<add-rotation-modal />
</div> </div>
</template> </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 "" ...@@ -19085,18 +19085,39 @@ msgstr ""
msgid "OnCallSchedules|Add a schedule" msgid "OnCallSchedules|Add a schedule"
msgstr "" msgstr ""
msgid "OnCallSchedules|Add rotation"
msgstr ""
msgid "OnCallSchedules|Add schedule" msgid "OnCallSchedules|Add schedule"
msgstr "" msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab" msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr "" msgstr ""
msgid "OnCallSchedules|Failed to add rotation"
msgstr ""
msgid "OnCallSchedules|Failed to add schedule" msgid "OnCallSchedules|Failed to add schedule"
msgstr "" 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" msgid "OnCallSchedules|Route alerts directly to specific members of your team"
msgstr "" msgstr ""
msgid "OnCallSchedules|Select participant"
msgstr ""
msgid "OnCallSchedules|Select timezone" msgid "OnCallSchedules|Select timezone"
msgstr "" msgstr ""
...@@ -19414,6 +19435,9 @@ msgstr "" ...@@ -19414,6 +19435,9 @@ msgstr ""
msgid "Owner" msgid "Owner"
msgstr "" msgstr ""
msgid "PST"
msgstr ""
msgid "Package Registry" msgid "Package Registry"
msgstr "" msgstr ""
...@@ -26042,6 +26066,9 @@ msgstr "" ...@@ -26042,6 +26066,9 @@ msgstr ""
msgid "Starts at (UTC)" msgid "Starts at (UTC)"
msgstr "" msgstr ""
msgid "Starts on"
msgstr ""
msgid "State your message to activate" msgid "State your message to activate"
msgstr "" msgstr ""
...@@ -31920,6 +31947,9 @@ msgstr "" ...@@ -31920,6 +31947,9 @@ msgstr ""
msgid "assign yourself" msgid "assign yourself"
msgstr "" msgstr ""
msgid "at"
msgstr ""
msgid "at risk" msgid "at risk"
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