Commit cf42ec6d 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 (second attempt)

See merge request gitlab-org/gitlab!21825
parents 9f2c83be 3a5d5c7b
<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>
<div>
<!-- eslint-disable @gitlab/vue-i18n/no-bare-strings -->
<p>
This is a placeholder for
<a href="https://gitlab.com/gitlab-org/gitlab/issues/5419">#5419</a>.
</p>
<button class="btn btn-secondary" type="button" @click="$emit('cancel')">Cancel</button>
<div class="row mb-3">
<div class="col-sm">
<label class="label-bold">{{ s__('Issue|Title') }}</label>
<gl-form-input
ref="titleInput"
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>
</template>
......@@ -60,6 +60,7 @@ export default {
'issuableType',
'epicsEndpoint',
'issuesEndpoint',
'projects',
]),
...mapGetters(['itemAutoCompleteSources', 'itemPathIdSeparator', 'directChildren']),
disableContents() {
......@@ -100,6 +101,8 @@ export default {
'setItemInputValue',
'addItem',
'createItem',
'createNewIssue',
'fetchProjects',
]),
getRawRefs(value) {
return value.split(/\s+/).filter(ref => ref.trim().length > 0);
......@@ -140,9 +143,11 @@ export default {
this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE });
},
showCreateIssueForm() {
this.toggleAddItemForm({ toggleState: false });
this.toggleCreateEpicForm({ toggleState: false });
this.isCreateIssueFormVisible = true;
return this.fetchProjects().then(() => {
this.toggleAddItemForm({ toggleState: false });
this.toggleCreateEpicForm({ toggleState: false });
this.isCreateIssueFormVisible = true;
});
},
},
};
......@@ -203,7 +208,9 @@ export default {
/>
<create-issue-form
:slot="$options.FORM_SLOTS.createIssue"
:projects="projects"
@cancel="isCreateIssueFormVisible = false"
@submit="createNewIssue"
/>
</slot-switch>
<related-items-tree-body
......
......@@ -42,6 +42,7 @@ export default () => {
this.setInitialConfig({
epicsEndpoint: initialData.epicLinksEndpoint,
issuesEndpoint: initialData.issueLinksEndpoint,
projectsEndpoint: initialData.projectsEndpoint,
autoCompleteEpics: parseBoolean(autoCompleteEpics),
autoCompleteIssues: parseBoolean(autoCompleteIssues),
userSignedIn: parseBoolean(userSignedIn),
......
......@@ -6,7 +6,7 @@ import {
relatedIssuesRemoveErrorMap,
} from 'ee/related_issues/constants';
import flash from '~/flash';
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
......@@ -75,7 +75,10 @@ export const receiveItemsFailure = ({ commit }, data) => {
flash(s__('Epics|Something went wrong while fetching child epics.'));
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;
dispatch('requestItems', {
......@@ -87,6 +90,7 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
.query({
query: epicChildren,
variables: { iid, fullPath },
fetchPolicy,
})
.then(({ data }) => {
const children = processQueryResponse(data.group);
......@@ -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
export default () => {};
......@@ -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 REORDER_ITEM = 'REORDER_ITEM';
export const SET_PROJECTS = 'SET_PROJECTS';
......@@ -5,12 +5,20 @@ import * as types from './mutation_types';
export default {
[types.SET_INITIAL_CONFIG](
state,
{ epicsEndpoint, issuesEndpoint, autoCompleteEpics, autoCompleteIssues, userSignedIn },
{
epicsEndpoint,
issuesEndpoint,
autoCompleteEpics,
autoCompleteIssues,
projectsEndpoint,
userSignedIn,
},
) {
state.epicsEndpoint = epicsEndpoint;
state.issuesEndpoint = issuesEndpoint;
state.autoCompleteEpics = autoCompleteEpics;
state.autoCompleteIssues = autoCompleteIssues;
state.projectsEndpoint = projectsEndpoint;
state.userSignedIn = userSignedIn;
},
......@@ -191,4 +199,8 @@ export default {
// Insert at new position
state.children[parentItem.reference].splice(newIndex, 0, targetItem);
},
[types.SET_PROJECTS](state, projects) {
state.projects = projects;
},
};
......@@ -3,6 +3,7 @@ export default () => ({
parentItem: {},
epicsEndpoint: '',
issuesEndpoint: '',
projectsEndpoint: null,
userSignedIn: false,
children: {},
......@@ -38,4 +39,6 @@ export default () => ({
parentItem: {},
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,7 +8,12 @@ import { issuableTypesMap } from 'ee/related_issues/constants';
import AddItemForm from 'ee/related_issues/components/add_issuable_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 { TEST_HOST } from 'spec/test_constants';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { getJSONFixture } from 'helpers/fixtures';
// https://gitlab.com/gitlab-org/gitlab/issues/118456
import {
mockInitialConfig,
mockParentItem,
......@@ -16,6 +21,8 @@ import {
const localVue = createLocalVue();
const mockProjects = getJSONFixture('static/projects.json');
const createComponent = () => {
const store = createDefaultStore();
......@@ -30,14 +37,25 @@ const createComponent = () => {
};
describe('RelatedItemsTreeApp', () => {
let axiosMock;
let wrapper;
const findAddItemForm = () => wrapper.find(AddItemForm);
const findCreateIssueForm = () => wrapper.find(CreateIssueForm);
const findIssueActionsSplitButton = () => wrapper.find(IssueActionsSplitButton);
const showCreateIssueForm = () => {
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
return axios.waitFor(mockInitialConfig.projectsEndpoint).then(() => wrapper.vm.$nextTick());
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(mockInitialConfig.projectsEndpoint).replyOnce(200, mockProjects);
});
afterEach(() => {
wrapper.destroy();
axiosMock.restore();
});
describe('methods', () => {
......@@ -266,20 +284,17 @@ describe('RelatedItemsTreeApp', () => {
it('shows create item form', () => {
expect(findCreateIssueForm().exists()).toBe(false);
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
return showCreateIssueForm().then(() => {
const form = findCreateIssueForm();
return wrapper.vm.$nextTick().then(() => {
expect(findCreateIssueForm().exists()).toBe(true);
expect(form.exists()).toBe(true);
expect(form.props().projects).toBe(mockProjects);
});
});
});
describe('after create issue form emitted cancel event', () => {
beforeEach(() => {
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
return wrapper.vm.$nextTick();
});
beforeEach(() => showCreateIssueForm());
it('hides the form', () => {
expect(findCreateIssueForm().exists()).toBe(true);
......@@ -291,5 +306,24 @@ describe('RelatedItemsTreeApp', () => {
});
});
});
describe('after create issue form emitted submit event', () => {
beforeEach(() => showCreateIssueForm());
it('dispatches createNewIssue action', () => {
const issuesEndpoint = `${TEST_HOST}/issues`;
axiosMock.onPost(issuesEndpoint).replyOnce(200, {});
const params = {
issuesEndpoint,
title: 'some new issue',
};
findCreateIssueForm().vm.$emit('submit', params);
return axios.waitFor(issuesEndpoint).then(({ data }) => {
expect(JSON.parse(data).title).toBe(params.title);
});
});
});
});
});
import { TEST_HOST } from 'spec/test_constants';
export const mockInitialConfig = {
epicsEndpoint: 'http://test.host',
issuesEndpoint: 'http://test.host',
epicsEndpoint: `${TEST_HOST}/epics`,
issuesEndpoint: `${TEST_HOST}/issues`,
projectsEndpoint: `${TEST_HOST}/projects`,
autoCompleteEpics: true,
autoCompleteIssues: false,
userSignedIn: true,
};
export const mockParentItem = {
id: 'gid://gitlab/Epic/42',
iid: 1,
fullPath: 'gitlab-org',
title: 'Some sample epic',
......
import MockAdapter from 'axios-mock-adapter';
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 epicUtils from 'ee/related_items_tree/utils/epic_utils';
......@@ -14,6 +14,7 @@ import {
import testAction from 'spec/helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import { TEST_HOST } from 'spec/test_constants';
import {
mockInitialConfig,
......@@ -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);
});
});
});
});
});
});
......@@ -4994,12 +4994,18 @@ msgstr ""
msgid "Could not create group"
msgstr ""
msgid "Could not create issue"
msgstr ""
msgid "Could not create project"
msgstr ""
msgid "Could not delete chat nickname %{chat_name}."
msgstr ""
msgid "Could not fetch projects"
msgstr ""
msgid "Could not remove the trigger."
msgstr ""
......@@ -10007,6 +10013,9 @@ msgstr ""
msgid "IssuesAnalytics|Total:"
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."
msgstr ""
......@@ -11733,6 +11742,9 @@ msgstr ""
msgid "New issue"
msgstr ""
msgid "New issue title"
msgstr ""
msgid "New label"
msgstr ""
......
......@@ -99,6 +99,15 @@
"access_level": 50,
"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,
......
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