Commit 3eb0e42c authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'winh-epic-new-issue' into 'master'

Add form to create issues to epics tree feature

See merge request gitlab-org/gitlab!17932
parents 34af4d8f 4b5b9ec5
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlFormInput } from '@gitlab/ui';
import { __ } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlFormInput,
ProjectAvatar,
},
props: {
projects: {
type: Array,
required: true,
},
},
data() {
return {
selectedProject: null,
title: '',
};
},
computed: {
dropdownToggleText() {
if (this.selectedProject) {
return this.selectedProject.name_with_namespace;
}
return __('Select a project');
},
hasValidInput() {
return this.title.trim() !== '' && this.selectedProject;
},
},
methods: {
cancel() {
this.$emit('cancel');
},
createIssue() {
if (!this.hasValidInput) {
return;
}
const { selectedProject, title } = this;
const { issues: issuesEndpoint } = selectedProject._links;
this.$emit('submit', { issuesEndpoint, title });
},
},
};
</script>
<template> <template>
<div> <div>
<!-- eslint-disable @gitlab/vue-i18n/no-bare-strings --> <div class="row mb-3">
<p> <div class="col-sm">
This is a placeholder for <label class="label-bold">{{ s__('Issue|Title') }}</label>
<a href="https://gitlab.com/gitlab-org/gitlab/issues/5419">#5419</a>. <gl-form-input
</p> ref="titleInput"
<button class="btn btn-secondary" type="button" @click="$emit('cancel')">Cancel</button> v-model="title"
:placeholder="__('New issue title')"
autofocus
/>
</div>
<div class="col-sm">
<label class="label-bold">{{ __('Project') }}</label>
<gl-dropdown
:text="dropdownToggleText"
class="w-100"
menu-class="w-100"
toggle-class="d-flex align-items-center justify-content-between text-truncate"
>
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
class="w-100"
@click="selectedProject = project"
>
<project-avatar :project="project" :size="32" />
{{ project.name }}
<div class="text-secondary">{{ project.namespace.name }}</div>
</gl-dropdown-item>
</gl-dropdown>
</div>
</div>
<div class="row my-1">
<div class="col-sm flex-sm-grow-0 mb-2 mb-sm-0">
<gl-button class="w-100" variant="success" :disabled="!hasValidInput" @click="createIssue">
{{ __('Create issue') }}
</gl-button>
</div>
<div class="col-sm flex-sm-grow-0 ml-auto">
<gl-button class="w-100" @click="cancel">{{ __('Cancel') }}</gl-button>
</div>
</div>
</div> </div>
</template> </template>
...@@ -60,6 +60,7 @@ export default { ...@@ -60,6 +60,7 @@ export default {
'issuableType', 'issuableType',
'epicsEndpoint', 'epicsEndpoint',
'issuesEndpoint', 'issuesEndpoint',
'projects',
]), ]),
...mapGetters(['itemAutoCompleteSources', 'itemPathIdSeparator', 'directChildren']), ...mapGetters(['itemAutoCompleteSources', 'itemPathIdSeparator', 'directChildren']),
disableContents() { disableContents() {
...@@ -100,6 +101,8 @@ export default { ...@@ -100,6 +101,8 @@ export default {
'setItemInputValue', 'setItemInputValue',
'addItem', 'addItem',
'createItem', 'createItem',
'createNewIssue',
'fetchProjects',
]), ]),
getRawRefs(value) { getRawRefs(value) {
return value.split(/\s+/).filter(ref => ref.trim().length > 0); return value.split(/\s+/).filter(ref => ref.trim().length > 0);
...@@ -140,9 +143,11 @@ export default { ...@@ -140,9 +143,11 @@ export default {
this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE }); this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE });
}, },
showCreateIssueForm() { showCreateIssueForm() {
return this.fetchProjects().then(() => {
this.toggleAddItemForm({ toggleState: false }); this.toggleAddItemForm({ toggleState: false });
this.toggleCreateEpicForm({ toggleState: false }); this.toggleCreateEpicForm({ toggleState: false });
this.isCreateIssueFormVisible = true; this.isCreateIssueFormVisible = true;
});
}, },
}, },
}; };
...@@ -203,7 +208,9 @@ export default { ...@@ -203,7 +208,9 @@ export default {
/> />
<create-issue-form <create-issue-form
:slot="$options.FORM_SLOTS.createIssue" :slot="$options.FORM_SLOTS.createIssue"
:projects="projects"
@cancel="isCreateIssueFormVisible = false" @cancel="isCreateIssueFormVisible = false"
@submit="createNewIssue"
/> />
</slot-switch> </slot-switch>
<related-items-tree-body <related-items-tree-body
......
...@@ -42,6 +42,7 @@ export default () => { ...@@ -42,6 +42,7 @@ export default () => {
this.setInitialConfig({ this.setInitialConfig({
epicsEndpoint: initialData.epicLinksEndpoint, epicsEndpoint: initialData.epicLinksEndpoint,
issuesEndpoint: initialData.issueLinksEndpoint, issuesEndpoint: initialData.issueLinksEndpoint,
projectsEndpoint: initialData.projectsEndpoint,
autoCompleteEpics: parseBoolean(autoCompleteEpics), autoCompleteEpics: parseBoolean(autoCompleteEpics),
autoCompleteIssues: parseBoolean(autoCompleteIssues), autoCompleteIssues: parseBoolean(autoCompleteIssues),
userSignedIn: parseBoolean(userSignedIn), userSignedIn: parseBoolean(userSignedIn),
......
...@@ -6,7 +6,7 @@ import { ...@@ -6,7 +6,7 @@ import {
relatedIssuesRemoveErrorMap, relatedIssuesRemoveErrorMap,
} from 'ee/related_issues/constants'; } from 'ee/related_issues/constants';
import flash from '~/flash'; import flash from '~/flash';
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
...@@ -75,7 +75,10 @@ export const receiveItemsFailure = ({ commit }, data) => { ...@@ -75,7 +75,10 @@ export const receiveItemsFailure = ({ commit }, data) => {
flash(s__('Epics|Something went wrong while fetching child epics.')); flash(s__('Epics|Something went wrong while fetching child epics.'));
commit(types.RECEIVE_ITEMS_FAILURE, data); commit(types.RECEIVE_ITEMS_FAILURE, data);
}; };
export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => { export const fetchItems = (
{ dispatch },
{ parentItem, isSubItem = false, fetchPolicy = 'cache-first' },
) => {
const { iid, fullPath } = parentItem; const { iid, fullPath } = parentItem;
dispatch('requestItems', { dispatch('requestItems', {
...@@ -87,6 +90,7 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => { ...@@ -87,6 +90,7 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
.query({ .query({
query: epicChildren, query: epicChildren,
variables: { iid, fullPath }, variables: { iid, fullPath },
fetchPolicy,
}) })
.then(({ data }) => { .then(({ data }) => {
const children = processQueryResponse(data.group); const children = processQueryResponse(data.group);
...@@ -443,5 +447,43 @@ export const reorderItem = ( ...@@ -443,5 +447,43 @@ export const reorderItem = (
}); });
}; };
export const createNewIssue = ({ state, dispatch }, { issuesEndpoint, title }) => {
const { parentItem } = state;
// necessary because parentItem comes from GraphQL and we are using REST API here
const epicId = parseInt(parentItem.id.replace(/^gid:\/\/gitlab\/Epic\//, ''), 10);
return axios
.post(issuesEndpoint, { epic_id: epicId, title })
.then(() =>
dispatch('fetchItems', {
parentItem,
fetchPolicy: 'network-only',
}),
)
.catch(e => {
flash(__('Could not create issue'));
throw e;
});
};
export const fetchProjects = ({ state, commit }) =>
axios
.get(state.projectsEndpoint, {
params: {
include_subgroups: true,
order_by: 'last_activity_at',
with_issues_enabled: true,
with_shared: false,
},
})
.then(({ data }) => {
commit(types.SET_PROJECTS, data);
})
.catch(e => {
flash(__('Could not fetch projects'));
throw e;
});
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -38,3 +38,5 @@ export const RECEIVE_CREATE_ITEM_SUCCESS = 'RECEIVE_CREATE_ITEM_SUCCESS'; ...@@ -38,3 +38,5 @@ export const RECEIVE_CREATE_ITEM_SUCCESS = 'RECEIVE_CREATE_ITEM_SUCCESS';
export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE'; export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE';
export const REORDER_ITEM = 'REORDER_ITEM'; export const REORDER_ITEM = 'REORDER_ITEM';
export const SET_PROJECTS = 'SET_PROJECTS';
...@@ -5,12 +5,20 @@ import * as types from './mutation_types'; ...@@ -5,12 +5,20 @@ import * as types from './mutation_types';
export default { export default {
[types.SET_INITIAL_CONFIG]( [types.SET_INITIAL_CONFIG](
state, state,
{ epicsEndpoint, issuesEndpoint, autoCompleteEpics, autoCompleteIssues, userSignedIn }, {
epicsEndpoint,
issuesEndpoint,
autoCompleteEpics,
autoCompleteIssues,
projectsEndpoint,
userSignedIn,
},
) { ) {
state.epicsEndpoint = epicsEndpoint; state.epicsEndpoint = epicsEndpoint;
state.issuesEndpoint = issuesEndpoint; state.issuesEndpoint = issuesEndpoint;
state.autoCompleteEpics = autoCompleteEpics; state.autoCompleteEpics = autoCompleteEpics;
state.autoCompleteIssues = autoCompleteIssues; state.autoCompleteIssues = autoCompleteIssues;
state.projectsEndpoint = projectsEndpoint;
state.userSignedIn = userSignedIn; state.userSignedIn = userSignedIn;
}, },
...@@ -191,4 +199,8 @@ export default { ...@@ -191,4 +199,8 @@ export default {
// Insert at new position // Insert at new position
state.children[parentItem.reference].splice(newIndex, 0, targetItem); state.children[parentItem.reference].splice(newIndex, 0, targetItem);
}, },
[types.SET_PROJECTS](state, projects) {
state.projects = projects;
},
}; };
...@@ -3,6 +3,7 @@ export default () => ({ ...@@ -3,6 +3,7 @@ export default () => ({
parentItem: {}, parentItem: {},
epicsEndpoint: '', epicsEndpoint: '',
issuesEndpoint: '', issuesEndpoint: '',
projectsEndpoint: null,
userSignedIn: false, userSignedIn: false,
children: {}, children: {},
...@@ -38,4 +39,6 @@ export default () => ({ ...@@ -38,4 +39,6 @@ export default () => ({
parentItem: {}, parentItem: {},
item: {}, item: {},
}, },
projects: [],
}); });
import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.vue';
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlDropdownItem, GlFormInput } from '@gitlab/ui';
const projects = getJSONFixture('static/projects.json');
const GlDropdownStub = {
name: 'GlDropdown',
template: '<div><slot></slot></div>',
};
describe('CreateIssueForm', () => {
let wrapper;
const createWrapper = () => {
wrapper = shallowMount(CreateIssueForm, {
sync: false,
stubs: {
GlDropdown: GlDropdownStub,
},
propsData: {
projects,
},
});
};
const findButton = text =>
wrapper.findAll(GlButton).wrappers.find(button => button.text() === text);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const getDropdownToggleText = () => wrapper.find(GlDropdownStub).attributes().text;
const clickDropdownItem = index =>
findDropdownItems()
.at(index)
.vm.$emit('click');
it('renders projects dropdown', () => {
createWrapper();
expect(findDropdownItems().length).toBeGreaterThan(0);
expect(findDropdownItems().length).toBe(projects.length);
const itemTexts = findDropdownItems().wrappers.map(item => item.text());
itemTexts.forEach((text, index) => {
const project = projects[index];
expect(text).toContain(project.name);
expect(text).toContain(project.namespace.name);
});
});
it('uses selected project as dropdown button text', () => {
createWrapper();
expect(getDropdownToggleText()).toBe('Select a project');
clickDropdownItem(1);
return wrapper.vm.$nextTick().then(() => {
expect(getDropdownToggleText()).toBe(projects[1].name_with_namespace);
});
});
describe('cancel button', () => {
const clickCancel = () => findButton('Cancel').vm.$emit('click');
it('emits cancel event', () => {
createWrapper();
clickCancel();
expect(wrapper.emitted()).toEqual({ cancel: [[]] });
});
});
describe('submit button', () => {
const dummyTitle = 'some issue title';
const clickSubmit = () => findButton('Create issue').vm.$emit('click');
const fillTitle = title => wrapper.find(GlFormInput).vm.$emit('input', title);
it('does not emit submit if project is missing', () => {
createWrapper();
fillTitle(dummyTitle);
clickSubmit();
expect(wrapper.emitted()).toEqual({});
});
it('does not emit submit if title is missing', () => {
createWrapper();
clickDropdownItem(1);
clickSubmit();
expect(wrapper.emitted()).toEqual({});
});
it('emits submit event for filled form', () => {
createWrapper();
fillTitle(dummyTitle);
clickDropdownItem(1);
clickSubmit();
const issuesEndpoint = projects[1]._links.issues;
const expectedParams = [{ issuesEndpoint, title: dummyTitle }];
expect(wrapper.emitted()).toEqual({ submit: [expectedParams] });
});
});
});
...@@ -8,6 +8,7 @@ import { issuableTypesMap } from 'ee/related_issues/constants'; ...@@ -8,6 +8,7 @@ import { issuableTypesMap } from 'ee/related_issues/constants';
import AddItemForm from 'ee/related_issues/components/add_issuable_form.vue'; import AddItemForm from 'ee/related_issues/components/add_issuable_form.vue';
import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.vue'; import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.vue';
import IssueActionsSplitButton from 'ee/related_items_tree/components/issue_actions_split_button.vue'; import IssueActionsSplitButton from 'ee/related_items_tree/components/issue_actions_split_button.vue';
import { TEST_HOST } from 'spec/test_constants';
import { mockInitialConfig, mockParentItem } from '../mock_data'; import { mockInitialConfig, mockParentItem } from '../mock_data';
...@@ -282,7 +283,10 @@ describe('RelatedItemsTreeApp', () => { ...@@ -282,7 +283,10 @@ describe('RelatedItemsTreeApp', () => {
wrapper.vm wrapper.vm
.$nextTick() .$nextTick()
.then(() => { .then(() => {
expect(findCreateIssueForm().exists()).toBe(true); const form = findCreateIssueForm();
expect(form.exists()).toBe(true);
expect(form.props().projectsEndpoint).toBe(mockInitialConfig.projectsEndpoint);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -313,5 +317,34 @@ describe('RelatedItemsTreeApp', () => { ...@@ -313,5 +317,34 @@ describe('RelatedItemsTreeApp', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('after create issue form emitted submit event', () => {
beforeEach(done => {
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
wrapper.vm
.$nextTick()
.then(done)
.catch(done.fail);
});
it('dispatches createNewIssue action', () => {
const createNewIssue = jasmine.createSpy('createNewIssue');
const store = wrapper.vm.$store;
store.hotUpdate({
actions: {
createNewIssue: (context, payload) => createNewIssue(payload),
},
});
const params = {
issuesEndpoint: `${TEST_HOST}/issues`,
title: 'some new issue',
};
findCreateIssueForm().vm.$emit('submit', params);
expect(createNewIssue).toHaveBeenCalledWith(params);
});
});
}); });
}); });
import { TEST_HOST } from 'spec/test_constants';
export const mockInitialConfig = { export const mockInitialConfig = {
epicsEndpoint: 'http://test.host', epicsEndpoint: `${TEST_HOST}/epics`,
issuesEndpoint: 'http://test.host', issuesEndpoint: `${TEST_HOST}/issues`,
projectsEndpoint: `${TEST_HOST}/projects`,
autoCompleteEpics: true, autoCompleteEpics: true,
autoCompleteIssues: false, autoCompleteIssues: false,
userSignedIn: true, userSignedIn: true,
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import createDefaultState from 'ee/related_items_tree/store/state'; import createDefaultState from 'ee/related_items_tree/store/state';
import * as actions from 'ee/related_items_tree/store/actions'; import actionsModule, * as actions from 'ee/related_items_tree/store/actions';
import * as types from 'ee/related_items_tree/store/mutation_types'; import * as types from 'ee/related_items_tree/store/mutation_types';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils'; import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { TEST_HOST } from 'spec/test_constants';
import { import {
mockInitialConfig, mockInitialConfig,
...@@ -1315,6 +1316,95 @@ describe('RelatedItemTree', () => { ...@@ -1315,6 +1316,95 @@ describe('RelatedItemTree', () => {
); );
}); });
}); });
describe('createNewIssue', () => {
const issuesEndpoint = `${TEST_HOST}/issues`;
const title = 'new issue title';
const epicId = 42;
const parentItem = {
id: `gid://gitlab/Epic/${epicId}`,
};
const expectedRequest = jasmine.objectContaining({
data: JSON.stringify({
epic_id: epicId,
title,
}),
});
let flashSpy;
let axiosMock;
let requestSpy;
let context;
let payload;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
});
afterEach(() => {
axiosMock.restore();
});
beforeEach(() => {
flashSpy = spyOnDependency(actionsModule, 'flash');
requestSpy = jasmine.createSpy('request');
axiosMock.onPost(issuesEndpoint).replyOnce(config => requestSpy(config));
context = {
state: {
parentItem,
},
dispatch: jasmine.createSpy('dispatch'),
};
payload = {
issuesEndpoint,
title,
};
});
describe('for successful request', () => {
beforeEach(() => {
requestSpy.and.returnValue([201, '']);
});
it('dispatches fetchItems', done => {
actions
.createNewIssue(context, payload)
.then(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
expect(context.dispatch).toHaveBeenCalledWith(
'fetchItems',
jasmine.objectContaining({ parentItem }),
);
expect(flashSpy).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
describe('for failed request', () => {
beforeEach(() => {
requestSpy.and.returnValue([500, '']);
});
it('fails and shows flash message', done => {
actions
.createNewIssue(context, payload)
.then(() => done.fail('expected action to throw error!'))
.catch(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
expect(context.dispatch).not.toHaveBeenCalled();
expect(flashSpy).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
});
}); });
}); });
}); });
...@@ -4928,12 +4928,18 @@ msgstr "" ...@@ -4928,12 +4928,18 @@ msgstr ""
msgid "Could not create group" msgid "Could not create group"
msgstr "" msgstr ""
msgid "Could not create issue"
msgstr ""
msgid "Could not create project" msgid "Could not create project"
msgstr "" msgstr ""
msgid "Could not delete chat nickname %{chat_name}." msgid "Could not delete chat nickname %{chat_name}."
msgstr "" msgstr ""
msgid "Could not fetch projects"
msgstr ""
msgid "Could not remove the trigger." msgid "Could not remove the trigger."
msgstr "" msgstr ""
...@@ -9917,6 +9923,9 @@ msgstr "" ...@@ -9917,6 +9923,9 @@ msgstr ""
msgid "IssuesAnalytics|Total:" msgid "IssuesAnalytics|Total:"
msgstr "" msgstr ""
msgid "Issue|Title"
msgstr ""
msgid "It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected." msgid "It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected."
msgstr "" msgstr ""
...@@ -11625,6 +11634,9 @@ msgstr "" ...@@ -11625,6 +11634,9 @@ msgstr ""
msgid "New issue" msgid "New issue"
msgstr "" msgstr ""
msgid "New issue title"
msgstr ""
msgid "New label" msgid "New label"
msgstr "" msgstr ""
......
...@@ -99,6 +99,15 @@ ...@@ -99,6 +99,15 @@
"access_level": 50, "access_level": 50,
"notification_level": 3 "notification_level": 3
} }
},
"_links": {
"self": "https://gitlab.com/api/v4/projects/278964",
"issues": "https://gitlab.com/api/v4/projects/278964/issues",
"merge_requests": "https://gitlab.com/api/v4/projects/278964/merge_requests",
"repo_branches": "https://gitlab.com/api/v4/projects/278964/repository/branches",
"labels": "https://gitlab.com/api/v4/projects/278964/labels",
"events": "https://gitlab.com/api/v4/projects/278964/events",
"members": "https://gitlab.com/api/v4/projects/278964/members"
} }
}, { }, {
"id": 7, "id": 7,
......
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