Commit ec5b51d3 authored by Florie Guibert's avatar Florie Guibert

Boards - Create new issue in GraphQL

Add VueX action to create issue using graphQL mutation
parent 7472a375
<script>
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import BoardNewIssue from './board_new_issue.vue';
import BoardNewIssue from './board_new_issue_new.vue';
import BoardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
......
<script>
import $ from 'jquery';
import { mapActions, mapGetters } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
......@@ -9,6 +7,8 @@ import ProjectSelect from './project_select.vue';
import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards
export default {
name: 'BoardNewIssue',
components: {
......@@ -31,23 +31,18 @@ export default {
};
},
computed: {
...mapGetters(['isSwimlanesOn']),
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
shouldDisplaySwimlanes() {
return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn;
},
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
methods: {
...mapActions(['addListIssue', 'addListIssueFailure']),
submit(e) {
e.preventDefault();
if (this.title.trim() === '') return Promise.resolve();
......@@ -74,31 +69,14 @@ export default {
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) {
this.addListIssue({ list: this.list, issue, position: 0 });
}
return this.list
.newIssue(issue)
.then(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
if (!this.shouldDisplaySwimlanes && !this.glFeatures.graphqlBoardLists) {
boardsStore.setIssueDetail(issue);
boardsStore.setListDetail(this.list);
}
boardsStore.setIssueDetail(issue);
boardsStore.setListDetail(this.list);
})
.catch(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
// Remove the issue
if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) {
this.addListIssueFailure({ list: this.list, issue });
} else {
this.list.removeIssue(issue);
}
this.list.removeIssue(issue);
// Show error message
this.error = true;
......@@ -137,7 +115,7 @@ export default {
<gl-button
ref="submitButton"
:disabled="disabled"
class="float-left"
class="float-left js-no-auto-disable"
variant="success"
category="primary"
type="submit"
......
<script>
import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
export default {
name: 'BoardNewIssue',
i18n: {
submit: __('Submit issue'),
cancel: __('Cancel'),
},
components: {
ProjectSelect,
GlButton,
},
mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
required: true,
},
},
inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
data() {
return {
title: '',
selectedProject: {},
};
},
computed: {
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
inputFieldId() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.list.id}-title`;
},
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
methods: {
...mapActions(['addListNewIssue']),
submit(e) {
e.preventDefault();
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
const weight = this.weightFeatureAvailable ? this.boardWeight : undefined;
const { title } = this;
eventHub.$emit(`scroll-board-list-${this.list.id}`);
return this.addListNewIssue({
issueInput: {
title,
labelIds: labels?.map(l => l.id),
assigneeIds: assignees?.map(a => a?.id),
milestoneId: milestone?.id,
projectPath: this.selectedProject.path,
weight: weight >= 0 ? weight : null,
},
list: this.list,
}).then(() => {
this.reset();
});
},
reset() {
this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
},
};
</script>
<template>
<div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded">
<form ref="submitForm" @submit="submit">
<label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
<input
:id="inputFieldId"
ref="input"
v-model="title"
class="form-control"
type="text"
name="issue_title"
autocomplete="off"
/>
<project-select v-if="groupId" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3">
<gl-button
ref="submitButton"
:disabled="disabled"
class="float-left js-no-auto-disable"
variant="success"
category="primary"
type="submit"
>
{{ $options.i18n.submit }}
</gl-button>
<gl-button
ref="cancelButton"
class="float-right"
type="button"
variant="default"
@click="reset"
>
{{ $options.i18n.cancel }}
</gl-button>
</div>
</form>
</div>
</div>
</template>
......@@ -44,6 +44,7 @@ export default {
this.selectedProject = {
id: $el.data('project-id'),
name: $el.data('project-name'),
path: $el.data('project-path'),
};
eventHub.$emit('setSelectedProject', this.selectedProject);
},
......@@ -75,11 +76,12 @@ export default {
renderRow(project) {
return `
<li>
<a href='#' class='dropdown-menu-link' data-project-id="${
project.id
}" data-project-name="${project.name}" data-project-name-with-namespace="${
project.name_with_namespace
}">
<a href='#' class='dropdown-menu-link'
data-project-id="${project.id}"
data-project-name="${project.name}"
data-project-name-with-namespace="${project.name_with_namespace}"
data-project-path="${project.path_with_namespace}"
>
${escape(project.name_with_namespace)}
</a>
</li>
......
......@@ -91,6 +91,10 @@ export default () => {
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
timeTrackingLimitToHours: parseBoolean($boardApp.dataset.timeTrackingLimitToHours),
weightFeatureAvailable: parseBoolean($boardApp.dataset.weightFeatureAvailable),
boardWeight: $boardApp.dataset.boardWeight
? parseInt($boardApp.dataset.boardWeight, 10)
: null,
},
store,
apolloProvider,
......
#import "ee_else_ce/boards/queries/issue.fragment.graphql"
mutation CreateIssue($input: CreateIssueInput!) {
createIssue(input: $input) {
issue {
...IssueNode
}
errors
}
}
......@@ -10,16 +10,18 @@ import {
formatListIssues,
fullBoardId,
formatListsPageInfo,
formatIssue,
} from '../boards_util';
import boardStore from '~/boards/stores/boards_store';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import listsIssuesQuery from '../queries/lists_issues.query.graphql';
import boardLabelsQuery from '../queries/board_labels.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
import destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import issueCreateMutation from '../queries/issue_create.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
......@@ -330,16 +332,43 @@ export default {
});
},
createNewIssue: () => {
notImplemented();
createNewIssue: ({ commit, state }, issueInput) => {
const input = issueInput;
const { boardType, endpoints } = state;
if (boardType === BoardType.project) {
input.projectPath = endpoints.fullPath;
}
return gqlClient
.mutate({
mutation: issueCreateMutation,
variables: { input },
})
.then(({ data }) => {
if (data.createIssue.errors.length) {
commit(types.CREATE_ISSUE_FAILURE);
} else {
return data.createIssue?.issue;
}
return null;
})
.catch(() => commit(types.CREATE_ISSUE_FAILURE));
},
addListIssue: ({ commit }, { list, issue, position }) => {
commit(types.ADD_ISSUE_TO_LIST, { list, issue, position });
},
addListIssueFailure: ({ commit }, { list, issue }) => {
commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue });
addListNewIssue: ({ commit, dispatch }, { issueInput, list }) => {
const issue = formatIssue({ ...issueInput, id: 'tmp' });
commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 });
dispatch('createNewIssue', issueInput)
.then(res => {
commit(types.ADD_ISSUE_TO_LIST, { list, issue: formatIssue(res) });
commit(types.REMOVE_ISSUE_FROM_LIST, { list, issue });
})
.catch(() => commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issueId: issueInput.id }));
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
......
......@@ -17,6 +17,7 @@ export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST';
export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE';
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR';
......@@ -28,6 +29,7 @@ export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS';
export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR';
export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST';
export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE';
export const REMOVE_ISSUE_FROM_LIST = 'REMOVE_ISSUE_FROM_LIST';
export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
......
......@@ -201,16 +201,28 @@ export default {
notImplemented();
},
[mutationTypes.CREATE_ISSUE_FAILURE]: state => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
},
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
const listIssues = state.issuesByListId[list.id];
listIssues.splice(position, 0, issue.id);
Vue.set(state.issuesByListId, list.id, listIssues);
addIssueToList({
state,
listId: list.id,
issueId: issue.id,
atIndex: position,
});
Vue.set(state.issues, issue.id, issue);
},
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => {
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
removeIssueFromList({ state, listId: list.id, issueId });
},
[mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
removeIssueFromList({ state, listId: list.id, issueId: issue.id });
Vue.delete(state.issues, issue.id);
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
......
......@@ -5,7 +5,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import defaultSortableConfig from '~/sortable/sortable_config';
import BoardCardLayout from '~/boards/components/board_card_layout.vue';
import eventHub from '~/boards/eventhub';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import BoardNewIssue from '~/boards/components/board_new_issue_new.vue';
import { ISSUABLE } from '~/boards/constants';
export default {
......
......@@ -77,6 +77,8 @@ const createComponent = ({
provide: {
groupId: null,
rootPath: '/',
weightFeatureAvailable: false,
boardWeight: null,
},
});
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import BoardNewIssue from '~/boards/components/board_new_issue_new.vue';
import '~/boards/models/list';
import { mockListsWithModel } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Issue boards new issue form', () => {
let wrapper;
let vm;
const addListNewIssuesSpy = jest.fn();
const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
const findSubmitForm = () => wrapper.find({ ref: 'submitForm' });
const submitIssue = () => {
const dummySubmitEvent = {
preventDefault() {},
};
return findSubmitForm().trigger('submit', dummySubmitEvent);
};
beforeEach(() => {
const store = new Vuex.Store({
state: {},
actions: { addListNewIssue: addListNewIssuesSpy },
getters: {},
});
wrapper = shallowMount(BoardNewIssue, {
propsData: {
disabled: false,
list: mockListsWithModel[0],
},
store,
localVue,
provide: {
groupId: null,
weightFeatureAvailable: false,
boardWeight: null,
},
});
vm = wrapper.vm;
return vm.$nextTick();
});
afterEach(() => {
wrapper.destroy();
});
it('calls submit if submit button is clicked', async () => {
jest.spyOn(wrapper.vm, 'submit').mockImplementation();
wrapper.setData({ title: 'Testing Title' });
await vm.$nextTick();
await submitIssue();
expect(wrapper.vm.submit).toHaveBeenCalled();
});
it('disables submit button if title is empty', () => {
expect(findSubmitButton().props().disabled).toBe(true);
});
it('enables submit button if title is not empty', async () => {
wrapper.setData({ title: 'Testing Title' });
await vm.$nextTick();
expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title');
expect(findSubmitButton().props().disabled).toBe(false);
});
it('clears title after clicking cancel', async () => {
findCancelButton().trigger('click');
await vm.$nextTick();
expect(vm.title).toBe('');
});
describe('submit success', () => {
it('creates new issue', async () => {
wrapper.setData({ title: 'submit issue' });
await vm.$nextTick();
await submitIssue();
expect(addListNewIssuesSpy).toHaveBeenCalled();
});
it('enables button after submit', async () => {
jest.spyOn(wrapper.vm, 'submit').mockImplementation();
wrapper.setData({ title: 'submit issue' });
await vm.$nextTick();
await submitIssue();
expect(findSubmitButton().props().disabled).toBe(false);
});
it('clears title after submit', async () => {
wrapper.setData({ title: 'submit issue' });
await vm.$nextTick();
await submitIssue();
await vm.$nextTick();
expect(vm.title).toBe('');
});
});
});
......@@ -291,6 +291,7 @@ export const mockLists = [
assignee: null,
milestone: null,
loading: false,
issuesSize: 1,
},
{
id: 'gid://gitlab/List/2',
......@@ -308,6 +309,7 @@ export const mockLists = [
assignee: null,
milestone: null,
loading: false,
issuesSize: 0,
},
];
......
......@@ -674,40 +674,63 @@ describe('setAssignees', () => {
});
describe('createNewIssue', () => {
expectNotImplemented(actions.createNewIssue);
});
const state = {
boardType: 'group',
endpoints: {
fullPath: 'gitlab-org/gitlab',
},
};
describe('addListIssue', () => {
it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
const payload = {
list: mockLists[0],
issue: mockIssue,
position: 0,
};
it('should return issue from API on success', async () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
createIssue: {
issue: mockIssue,
errors: [],
},
},
});
const result = await actions.createNewIssue({ state }, mockIssue);
expect(result).toEqual(mockIssue);
});
it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
createIssue: {
issue: {},
errors: [{ foo: 'bar' }],
},
},
});
const payload = mockIssue;
testAction(
actions.addListIssue,
actions.createNewIssue,
payload,
{},
[{ type: types.ADD_ISSUE_TO_LIST, payload }],
state,
[{ type: types.CREATE_ISSUE_FAILURE }],
[],
done,
);
});
});
describe('addListIssueFailure', () => {
it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
describe('addListIssue', () => {
it('should commit ADD_ISSUE_TO_LIST mutation', done => {
const payload = {
list: mockLists[0],
issue: mockIssue,
position: 0,
};
testAction(
actions.addListIssueFailure,
actions.addListIssue,
payload,
{},
[{ type: types.ADD_ISSUE_TO_LIST_FAILURE, payload }],
[{ type: types.ADD_ISSUE_TO_LIST, payload }],
[],
done,
);
......
......@@ -442,6 +442,14 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR);
});
describe('CREATE_ISSUE_FAILURE', () => {
it('sets error message on state', () => {
mutations.CREATE_ISSUE_FAILURE(state);
expect(state.error).toBe('An error occurred while creating the issue. Please try again.');
});
});
describe('ADD_ISSUE_TO_LIST', () => {
it('adds issue to issues state and issue id in list in issuesByListId', () => {
const listIssues = {
......@@ -455,17 +463,45 @@ describe('Board Store Mutations', () => {
...state,
issuesByListId: listIssues,
issues,
boardLists: initialBoardListsState,
};
mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 });
expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(1);
mutations.ADD_ISSUE_TO_LIST(state, { list: mockListsWithModel[0], issue: mockIssue2 });
expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id);
expect(state.issues[mockIssue2.id]).toEqual(mockIssue2);
expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(2);
});
});
describe('ADD_ISSUE_TO_LIST_FAILURE', () => {
it('removes issue id from list in issuesByListId', () => {
it('removes issue id from list in issuesByListId and sets error message', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
};
const issues = {
'1': mockIssue,
'2': mockIssue2,
};
state = {
...state,
issuesByListId: listIssues,
issues,
boardLists: initialBoardListsState,
};
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id });
expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
expect(state.error).toBe('An error occurred while creating the issue. Please try again.');
});
});
describe('REMOVE_ISSUE_FROM_LIST', () => {
it('removes issue id from list in issuesByListId and deletes issue from state', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
};
......@@ -481,9 +517,10 @@ describe('Board Store Mutations', () => {
boardLists: initialBoardListsState,
};
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 });
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id });
expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
expect(state.issues).not.toContain(mockIssue2);
});
});
......
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