Commit f8c7b8f3 authored by Rajat Jain's avatar Rajat Jain

Specify group when creating epic

In related items tree, specify a group when creating a new epic
parent 04c6d8f7
...@@ -7,7 +7,7 @@ export default { ...@@ -7,7 +7,7 @@ export default {
geoReplicationPath: '/api/:version/geo_replication/:replicable', geoReplicationPath: '/api/:version/geo_replication/:replicable',
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json', ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription', subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription',
childEpicPath: '/api/:version/groups/:id/epics/:epic_iid/epics', childEpicPath: '/api/:version/groups/:id/epics',
groupEpicsPath: '/api/:version/groups/:id/epics', groupEpicsPath: '/api/:version/groups/:id/epics',
epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id', epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id',
cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type', cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type',
...@@ -41,6 +41,7 @@ export default { ...@@ -41,6 +41,7 @@ export default {
vulnerabilityActionPath: '/api/:version/vulnerabilities/:id/:action', vulnerabilityActionPath: '/api/:version/vulnerabilities/:id/:action',
vulnerabilityIssueLinksPath: '/api/:version/vulnerabilities/:id/issue_links', vulnerabilityIssueLinksPath: '/api/:version/vulnerabilities/:id/issue_links',
applicationSettingsPath: '/api/:version/application/settings', applicationSettingsPath: '/api/:version/application/settings',
descendantGroupsPath: '/api/:version/groups/:group_id/descendant_groups',
userSubscription(namespaceId) { userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
...@@ -65,17 +66,26 @@ export default { ...@@ -65,17 +66,26 @@ export default {
}); });
}, },
createChildEpic({ confidential, groupId, parentEpicIid, title }) { createChildEpic({ confidential, groupId, parentEpicId, title }) {
const url = Api.buildUrl(this.childEpicPath) const url = Api.buildUrl(this.childEpicPath).replace(':id', encodeURIComponent(groupId));
.replace(':id', encodeURIComponent(groupId))
.replace(':epic_iid', parentEpicIid);
return axios.post(url, { return axios.post(url, {
parent_id: parentEpicId,
confidential, confidential,
title, title,
}); });
}, },
descendantGroups({ groupId, search }) {
const url = Api.buildUrl(this.descendantGroupsPath).replace(':group_id', groupId);
return axios.get(url, {
params: {
search,
},
});
},
groupEpics({ groupEpics({
groupId, groupId,
includeAncestorGroups = false, includeAncestorGroups = false,
......
<script> <script>
import { mapState } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui'; import {
GlAvatar,
GlButton,
GlFormInput,
GlDropdown,
GlSearchBoxByType,
GlDropdownItem,
GlLoadingIcon,
} from '@gitlab/ui';
import { SEARCH_DEBOUNCE } from '../constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
export default { export default {
components: { components: {
GlButton, GlButton,
GlFormInput,
GlDropdown,
GlSearchBoxByType,
GlDropdownItem,
GlAvatar,
GlLoadingIcon,
}, },
props: { props: {
isSubmitting: { isSubmitting: {
...@@ -19,16 +34,45 @@ export default { ...@@ -19,16 +34,45 @@ export default {
data() { data() {
return { return {
inputValue: '', inputValue: '',
searchTerm: '',
selectedGroup: null,
}; };
}, },
computed: { computed: {
...mapState(['parentItem']), ...mapState([
'descendantGroupsFetchInProgress',
'itemCreateInProgress',
'descendantGroups',
'parentItem',
]),
isSubmitButtonDisabled() { isSubmitButtonDisabled() {
return this.inputValue.length === 0 || this.isSubmitting; return this.inputValue.length === 0 || this.isSubmitting;
}, },
buttonLabel() { buttonLabel() {
return this.isSubmitting ? __('Creating epic') : __('Create epic'); return this.isSubmitting ? __('Creating epic') : __('Create epic');
}, },
dropdownPlaceholderText() {
return this.selectedGroup?.name || __('Search a group');
},
canRenderNoResults() {
return !this.descendantGroupsFetchInProgress && !this.descendantGroups?.length;
},
canRenderSearchResults() {
return !this.descendantGroupsFetchInProgress;
},
},
watch: {
searchTerm() {
this.handleDropdownShow();
},
descendantGroupsFetchInProgress(value) {
if (!value) {
this.$nextTick(() => {
this.$refs.searchInputField.focusInput();
});
}
},
}, },
mounted() { mounted() {
this.$nextTick() this.$nextTick()
...@@ -37,29 +81,95 @@ export default { ...@@ -37,29 +81,95 @@ export default {
}) })
.catch(() => {}); .catch(() => {});
}, },
methods: { methods: {
...mapActions(['fetchDescendantGroups']),
onFormSubmit() { onFormSubmit() {
this.$emit('createEpicFormSubmit', this.inputValue.trim()); const groupFullPath = this.selectedGroup?.full_path;
this.$emit('createEpicFormSubmit', this.inputValue.trim(), groupFullPath);
}, },
onFormCancel() { onFormCancel() {
this.$emit('createEpicFormCancel'); this.$emit('createEpicFormCancel');
}, },
handleDropdownShow() {
const {
parentItem: { groupId },
searchTerm,
} = this;
this.fetchDescendantGroups({ groupId, search: searchTerm });
},
}, },
debounce: SEARCH_DEBOUNCE,
}; };
</script> </script>
<template> <template>
<form @submit.prevent="onFormSubmit"> <form @submit.prevent="onFormSubmit">
<input <div class="row mb-3">
ref="input" <div class="col-sm">
v-model="inputValue" <label class="label-bold">{{ s__('Issue|Title') }}</label>
:placeholder=" <gl-form-input
parentItem.confidential ? __('New confidential epic title ') : __('New epic title') ref="input"
" v-model="inputValue"
type="text" :placeholder="
class="form-control" parentItem.confidential ? __('New confidential epic title ') : __('New epic title')
@keyup.escape.exact="onFormCancel" "
/> type="text"
class="form-control"
@keyup.escape.exact="onFormCancel"
/>
</div>
<div class="col-sm">
<label class="label-bold">{{ __('Group') }}</label>
<gl-dropdown
block
:text="dropdownPlaceholderText"
class="dropdown-descendant-groups"
menu-class="w-100 gl-pt-0"
@show="handleDropdownShow"
>
<gl-search-box-by-type
ref="searchInputField"
v-model.trim="searchTerm"
:disabled="descendantGroupsFetchInProgress"
:debounce="$options.debounce"
/>
<gl-loading-icon
v-show="descendantGroupsFetchInProgress"
class="projects-fetch-loading align-items-center p-2"
size="md"
/>
<template v-if="canRenderSearchResults">
<gl-dropdown-item
v-for="group in descendantGroups"
:key="group.id"
class="w-100"
@click="selectedGroup = group"
>
<gl-avatar
:src="group.avatar_url"
:entity-name="group.name"
shape="rect"
:size="32"
class="d-inline-flex"
/>
<div class="d-inline-flex flex-column">
{{ group.name }}
<div class="text-secondary">{{ group.path }}</div>
</div>
</gl-dropdown-item>
</template>
<gl-dropdown-item v-if="canRenderNoResults">{{
__('No matching results')
}}</gl-dropdown-item>
</gl-dropdown>
</div>
</div>
<div class="add-issuable-form-actions clearfix"> <div class="add-issuable-form-actions clearfix">
<gl-button <gl-button
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
......
...@@ -137,9 +137,10 @@ export default { ...@@ -137,9 +137,10 @@ export default {
this.addItem(); this.addItem();
} }
}, },
handleCreateEpicFormSubmit(newValue) { handleCreateEpicFormSubmit(newValue, groupFullPath) {
this.createItem({ this.createItem({
itemTitle: newValue, itemTitle: newValue,
groupFullPath,
}); });
}, },
handleAddItemFormCancel() { handleAddItemFormCancel() {
......
...@@ -21,7 +21,9 @@ export default () => { ...@@ -21,7 +21,9 @@ export default () => {
const { const {
id, id,
iid, iid,
numericalId,
fullPath, fullPath,
groupId,
autoCompleteEpics, autoCompleteEpics,
autoCompleteIssues, autoCompleteIssues,
userSignedIn, userSignedIn,
...@@ -40,8 +42,10 @@ export default () => { ...@@ -40,8 +42,10 @@ export default () => {
created() { created() {
this.setInitialParentItem({ this.setInitialParentItem({
fullPath, fullPath,
numericalId: parseInt(numericalId, 10),
groupId: parseInt(groupId, 10),
id, id,
iid: Number(iid), iid: parseInt(iid, 10),
title: initialData.initialTitleText, title: initialData.initialTitleText,
confidential: initialData.confidential, confidential: initialData.confidential,
reference: `${initialData.fullPath}${initialData.issuableRef}`, reference: `${initialData.fullPath}${initialData.issuableRef}`,
......
...@@ -385,13 +385,13 @@ export const receiveCreateItemFailure = ({ commit }) => { ...@@ -385,13 +385,13 @@ export const receiveCreateItemFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_ITEM_FAILURE); commit(types.RECEIVE_CREATE_ITEM_FAILURE);
flash(s__('Epics|Something went wrong while creating child epics.')); flash(s__('Epics|Something went wrong while creating child epics.'));
}; };
export const createItem = ({ state, dispatch }, { itemTitle }) => { export const createItem = ({ state, dispatch }, { itemTitle, groupFullPath }) => {
dispatch('requestCreateItem'); dispatch('requestCreateItem');
Api.createChildEpic({ Api.createChildEpic({
confidential: state.parentItem.confidential, confidential: state.parentItem.confidential,
groupId: state.parentItem.fullPath, groupId: groupFullPath || state.parentItem.fullPath,
parentEpicIid: state.parentItem.iid, parentEpicId: Number(state.parentItem.id.match(/\d.*/)),
title: itemTitle, title: itemTitle,
}) })
.then(({ data }) => { .then(({ data }) => {
...@@ -404,6 +404,9 @@ export const createItem = ({ state, dispatch }, { itemTitle }) => { ...@@ -404,6 +404,9 @@ export const createItem = ({ state, dispatch }, { itemTitle }) => {
}); });
dispatch('receiveCreateItemSuccess', { rawItem: data }); dispatch('receiveCreateItemSuccess', { rawItem: data });
dispatch('fetchItems', {
parentItem: state.parentItem,
});
}) })
.catch(() => { .catch(() => {
dispatch('receiveCreateItemFailure'); dispatch('receiveCreateItemFailure');
...@@ -590,3 +593,15 @@ export const fetchProjects = ({ state, dispatch }, searchKey = '') => { ...@@ -590,3 +593,15 @@ export const fetchProjects = ({ state, dispatch }, searchKey = '') => {
}) })
.catch(() => dispatch('receiveProjectsFailure')); .catch(() => dispatch('receiveProjectsFailure'));
}; };
export const fetchDescendantGroups = ({ commit }, { groupId, search = '' }) => {
commit(types.REQUEST_DESCENDANT_GROUPS);
return Api.descendantGroups({ groupId, search })
.then(({ data }) => {
commit(types.RECEIVE_DESCENDANT_GROUPS_SUCCESS, data);
})
.catch(() => {
commit(types.RECEIVE_DESCENDANT_GROUPS_FAILURE);
});
};
...@@ -48,3 +48,7 @@ export const SET_PROJECTS = 'SET_PROJECTS'; ...@@ -48,3 +48,7 @@ export const SET_PROJECTS = 'SET_PROJECTS';
export const REQUEST_PROJECTS = 'REQUEST_PROJECTS'; export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
export const RECIEVE_PROJECTS_SUCCESS = 'RECIEVE_PROJECTS_SUCCESS'; export const RECIEVE_PROJECTS_SUCCESS = 'RECIEVE_PROJECTS_SUCCESS';
export const RECIEVE_PROJECTS_FAILURE = 'RECIEVE_PROJECTS_FAILURE'; export const RECIEVE_PROJECTS_FAILURE = 'RECIEVE_PROJECTS_FAILURE';
export const REQUEST_DESCENDANT_GROUPS = 'REQUEST_DESCENDANT_GROUPS';
export const RECEIVE_DESCENDANT_GROUPS_SUCCESS = 'RECEIVE_DESCENDANT_GROUPS_SUCCESS';
export const RECEIVE_DESCENDANT_GROUPS_FAILURE = 'RECEIVE_DESCENDANT_GROUPS_FAILURE';
...@@ -262,4 +262,15 @@ export default { ...@@ -262,4 +262,15 @@ export default {
[types.RECIEVE_PROJECTS_FAILURE](state) { [types.RECIEVE_PROJECTS_FAILURE](state) {
state.projectsFetchInProgress = false; state.projectsFetchInProgress = false;
}, },
[types.REQUEST_DESCENDANT_GROUPS](state) {
state.descendantGroupsFetchInProgress = true;
},
[types.RECEIVE_DESCENDANT_GROUPS_SUCCESS](state, descendantGroups) {
state.descendantGroups = descendantGroups;
state.descendantGroupsFetchInProgress = false;
},
[types.RECEIVE_DESCENDANT_GROUPS_FAILURE](state) {
state.descendantGroupsFetchInProgress = false;
},
}; };
...@@ -52,4 +52,7 @@ export default () => ({ ...@@ -52,4 +52,7 @@ export default () => ({
}, },
projects: [], projects: [],
descendantGroups: [],
descendantGroupsFetchInProgress: false,
}); });
...@@ -39,7 +39,9 @@ ...@@ -39,7 +39,9 @@
.row .row
%section.col-md-12 %section.col-md-12
#js-tree{ data: { id: @epic.to_global_id, #js-tree{ data: { id: @epic.to_global_id,
numerical_id: @epic.id,
iid: @epic.iid, iid: @epic.iid,
group_id: @group.id,
full_path: @group.full_path, full_path: @group.full_path,
auto_complete_epics: 'true', auto_complete_epics: 'true',
auto_complete_issues: 'true', auto_complete_issues: 'true',
......
---
title: Specify group when creating epic
merge_request: 45741
author:
type: added
...@@ -66,22 +66,22 @@ describe('Api', () => { ...@@ -66,22 +66,22 @@ describe('Api', () => {
describe('createChildEpic', () => { describe('createChildEpic', () => {
it('calls `axios.post` using params `groupId`, `parentEpicIid` and title', done => { it('calls `axios.post` using params `groupId`, `parentEpicIid` and title', done => {
const groupId = 'gitlab-org'; const groupId = 'gitlab-org';
const parentEpicIid = 1; const parentEpicId = 1;
const title = 'Sample epic'; const title = 'Sample epic';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics/${parentEpicIid}/epics`; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics`;
const expectedRes = { const expectedRes = {
title, title,
id: 20, id: 20,
iid: 5, parentId: 5,
}; };
mock.onPost(expectedUrl).reply(httpStatus.OK, expectedRes); mock.onPost(expectedUrl).reply(httpStatus.OK, expectedRes);
Api.createChildEpic({ groupId, parentEpicIid, title }) Api.createChildEpic({ groupId, parentEpicId, title })
.then(({ data }) => { .then(({ data }) => {
expect(data.title).toBe(expectedRes.title); expect(data.title).toBe(expectedRes.title);
expect(data.id).toBe(expectedRes.id); expect(data.id).toBe(expectedRes.id);
expect(data.iid).toBe(expectedRes.iid); expect(data.parentId).toBe(expectedRes.parentId);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
......
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlButton } from '@gitlab/ui'; import { GlFormInput, GlButton } from '@gitlab/ui';
import CreateEpicForm from 'ee/related_items_tree/components/create_epic_form.vue'; import CreateEpicForm from 'ee/related_items_tree/components/create_epic_form.vue';
import createDefaultStore from 'ee/related_items_tree/store'; import createDefaultStore from 'ee/related_items_tree/store';
...@@ -74,18 +74,29 @@ describe('RelatedItemsTree', () => { ...@@ -74,18 +74,29 @@ describe('RelatedItemsTree', () => {
expect(wrapper.vm.buttonLabel).toBe('Create epic'); expect(wrapper.vm.buttonLabel).toBe('Create epic');
}); });
}); });
describe('dropdownPlaceholderText', () => {
it('returns placeholder when no group is selected', () => {
expect(wrapper.vm.dropdownPlaceholderText).toBe('Search a group');
});
it('returns group name when a group is selected', () => {
const group = { name: 'Group 1' };
wrapper.setData({ selectedGroup: group });
expect(wrapper.vm.dropdownPlaceholderText).toBe(group.name);
});
});
}); });
describe('methods', () => { describe('methods', () => {
describe('onFormSubmit', () => { describe('onFormSubmit', () => {
it('emits `createEpicFormSubmit` event on component with input value as param', () => { it('emits `createEpicFormSubmit` event on component with input value as param', () => {
const value = 'foo'; const value = 'foo';
wrapper.find('input.form-control').setValue(value); wrapper.find(GlFormInput).vm.$emit('input', value);
wrapper.vm.onFormSubmit(); wrapper.vm.onFormSubmit();
expect(wrapper.emitted().createEpicFormSubmit).toBeTruthy(); expect(wrapper.emitted().createEpicFormSubmit).toBeTruthy();
expect(wrapper.emitted().createEpicFormSubmit[0]).toEqual([value]); expect(wrapper.emitted().createEpicFormSubmit[0]).toEqual([value, undefined]);
}); });
}); });
...@@ -96,11 +107,26 @@ describe('RelatedItemsTree', () => { ...@@ -96,11 +107,26 @@ describe('RelatedItemsTree', () => {
expect(wrapper.emitted().createEpicFormCancel).toBeTruthy(); expect(wrapper.emitted().createEpicFormCancel).toBeTruthy();
}); });
}); });
describe('handleDropdownShow', () => {
it('fetches descendant groups based on searchTerm', () => {
const handleDropdownShow = jest
.spyOn(wrapper.vm, 'fetchDescendantGroups')
.mockImplementation(jest.fn());
wrapper.vm.handleDropdownShow();
expect(handleDropdownShow).toHaveBeenCalledWith({
groupId: mockParentItem.groupId,
search: wrapper.vm.searchTerm,
});
});
});
}); });
describe('template', () => { describe('template', () => {
it('renders input element within form', () => { it('renders input element within form', () => {
const inputEl = wrapper.find('input.form-control'); const inputEl = wrapper.find(GlFormInput);
expect(inputEl.attributes('placeholder')).toBe('New epic title'); expect(inputEl.attributes('placeholder')).toBe('New epic title');
}); });
......
...@@ -1082,6 +1082,14 @@ describe('RelatedItemTree', () => { ...@@ -1082,6 +1082,14 @@ describe('RelatedItemTree', () => {
rawItem: { ...mockEpic1, path: '', state: ChildState.Open, created_at: '' }, rawItem: { ...mockEpic1, path: '', state: ChildState.Open, created_at: '' },
}, },
}, },
{
type: 'fetchItems',
payload: {
parentItem: {
...mockParentItem,
},
},
},
], ],
); );
}); });
......
...@@ -23131,6 +23131,9 @@ msgstr "" ...@@ -23131,6 +23131,9 @@ msgstr ""
msgid "Search Jira issues" msgid "Search Jira issues"
msgstr "" msgstr ""
msgid "Search a group"
msgstr ""
msgid "Search an environment spec" msgid "Search an environment spec"
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