Commit 0acf8815 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'create-epic-groups' into 'master'

Specify group when creating epic

See merge request gitlab-org/gitlab!45741
parents 6c28d848 f8c7b8f3
......@@ -7,7 +7,7 @@ export default {
geoReplicationPath: '/api/:version/geo_replication/:replicable',
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
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',
epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id',
cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type',
......@@ -41,6 +41,7 @@ export default {
vulnerabilityActionPath: '/api/:version/vulnerabilities/:id/:action',
vulnerabilityIssueLinksPath: '/api/:version/vulnerabilities/:id/issue_links',
applicationSettingsPath: '/api/:version/application/settings',
descendantGroupsPath: '/api/:version/groups/:group_id/descendant_groups',
userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
......@@ -65,17 +66,26 @@ export default {
});
},
createChildEpic({ confidential, groupId, parentEpicIid, title }) {
const url = Api.buildUrl(this.childEpicPath)
.replace(':id', encodeURIComponent(groupId))
.replace(':epic_iid', parentEpicIid);
createChildEpic({ confidential, groupId, parentEpicId, title }) {
const url = Api.buildUrl(this.childEpicPath).replace(':id', encodeURIComponent(groupId));
return axios.post(url, {
parent_id: parentEpicId,
confidential,
title,
});
},
descendantGroups({ groupId, search }) {
const url = Api.buildUrl(this.descendantGroupsPath).replace(':group_id', groupId);
return axios.get(url, {
params: {
search,
},
});
},
groupEpics({
groupId,
includeAncestorGroups = false,
......
<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';
export default {
components: {
GlButton,
GlFormInput,
GlDropdown,
GlSearchBoxByType,
GlDropdownItem,
GlAvatar,
GlLoadingIcon,
},
props: {
isSubmitting: {
......@@ -19,16 +34,45 @@ export default {
data() {
return {
inputValue: '',
searchTerm: '',
selectedGroup: null,
};
},
computed: {
...mapState(['parentItem']),
...mapState([
'descendantGroupsFetchInProgress',
'itemCreateInProgress',
'descendantGroups',
'parentItem',
]),
isSubmitButtonDisabled() {
return this.inputValue.length === 0 || this.isSubmitting;
},
buttonLabel() {
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() {
this.$nextTick()
......@@ -37,29 +81,95 @@ export default {
})
.catch(() => {});
},
methods: {
...mapActions(['fetchDescendantGroups']),
onFormSubmit() {
this.$emit('createEpicFormSubmit', this.inputValue.trim());
const groupFullPath = this.selectedGroup?.full_path;
this.$emit('createEpicFormSubmit', this.inputValue.trim(), groupFullPath);
},
onFormCancel() {
this.$emit('createEpicFormCancel');
},
handleDropdownShow() {
const {
parentItem: { groupId },
searchTerm,
} = this;
this.fetchDescendantGroups({ groupId, search: searchTerm });
},
},
debounce: SEARCH_DEBOUNCE,
};
</script>
<template>
<form @submit.prevent="onFormSubmit">
<input
ref="input"
v-model="inputValue"
:placeholder="
parentItem.confidential ? __('New confidential epic title ') : __('New epic title')
"
type="text"
class="form-control"
@keyup.escape.exact="onFormCancel"
/>
<div class="row mb-3">
<div class="col-sm">
<label class="label-bold">{{ s__('Issue|Title') }}</label>
<gl-form-input
ref="input"
v-model="inputValue"
:placeholder="
parentItem.confidential ? __('New confidential epic title ') : __('New epic title')
"
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">
<gl-button
:disabled="isSubmitButtonDisabled"
......
......@@ -137,9 +137,10 @@ export default {
this.addItem();
}
},
handleCreateEpicFormSubmit(newValue) {
handleCreateEpicFormSubmit(newValue, groupFullPath) {
this.createItem({
itemTitle: newValue,
groupFullPath,
});
},
handleAddItemFormCancel() {
......
......@@ -21,7 +21,9 @@ export default () => {
const {
id,
iid,
numericalId,
fullPath,
groupId,
autoCompleteEpics,
autoCompleteIssues,
userSignedIn,
......@@ -40,8 +42,10 @@ export default () => {
created() {
this.setInitialParentItem({
fullPath,
numericalId: parseInt(numericalId, 10),
groupId: parseInt(groupId, 10),
id,
iid: Number(iid),
iid: parseInt(iid, 10),
title: initialData.initialTitleText,
confidential: initialData.confidential,
reference: `${initialData.fullPath}${initialData.issuableRef}`,
......
......@@ -385,13 +385,13 @@ export const receiveCreateItemFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_ITEM_FAILURE);
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');
Api.createChildEpic({
confidential: state.parentItem.confidential,
groupId: state.parentItem.fullPath,
parentEpicIid: state.parentItem.iid,
groupId: groupFullPath || state.parentItem.fullPath,
parentEpicId: Number(state.parentItem.id.match(/\d.*/)),
title: itemTitle,
})
.then(({ data }) => {
......@@ -404,6 +404,9 @@ export const createItem = ({ state, dispatch }, { itemTitle }) => {
});
dispatch('receiveCreateItemSuccess', { rawItem: data });
dispatch('fetchItems', {
parentItem: state.parentItem,
});
})
.catch(() => {
dispatch('receiveCreateItemFailure');
......@@ -590,3 +593,15 @@ export const fetchProjects = ({ state, dispatch }, searchKey = '') => {
})
.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';
export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
export const RECIEVE_PROJECTS_SUCCESS = 'RECIEVE_PROJECTS_SUCCESS';
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 {
[types.RECIEVE_PROJECTS_FAILURE](state) {
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 () => ({
},
projects: [],
descendantGroups: [],
descendantGroupsFetchInProgress: false,
});
......@@ -39,7 +39,9 @@
.row
%section.col-md-12
#js-tree{ data: { id: @epic.to_global_id,
numerical_id: @epic.id,
iid: @epic.iid,
group_id: @group.id,
full_path: @group.full_path,
auto_complete_epics: 'true',
auto_complete_issues: 'true',
......
---
title: Specify group when creating epic
merge_request: 45741
author:
type: added
......@@ -66,22 +66,22 @@ describe('Api', () => {
describe('createChildEpic', () => {
it('calls `axios.post` using params `groupId`, `parentEpicIid` and title', done => {
const groupId = 'gitlab-org';
const parentEpicIid = 1;
const parentEpicId = 1;
const title = 'Sample epic';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics/${parentEpicIid}/epics`;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics`;
const expectedRes = {
title,
id: 20,
iid: 5,
parentId: 5,
};
mock.onPost(expectedUrl).reply(httpStatus.OK, expectedRes);
Api.createChildEpic({ groupId, parentEpicIid, title })
Api.createChildEpic({ groupId, parentEpicId, title })
.then(({ data }) => {
expect(data.title).toBe(expectedRes.title);
expect(data.id).toBe(expectedRes.id);
expect(data.iid).toBe(expectedRes.iid);
expect(data.parentId).toBe(expectedRes.parentId);
})
.then(done)
.catch(done.fail);
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
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 createDefaultStore from 'ee/related_items_tree/store';
......@@ -74,18 +74,29 @@ describe('RelatedItemsTree', () => {
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('onFormSubmit', () => {
it('emits `createEpicFormSubmit` event on component with input value as param', () => {
const value = 'foo';
wrapper.find('input.form-control').setValue(value);
wrapper.find(GlFormInput).vm.$emit('input', value);
wrapper.vm.onFormSubmit();
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', () => {
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', () => {
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');
});
......
......@@ -1082,6 +1082,14 @@ describe('RelatedItemTree', () => {
rawItem: { ...mockEpic1, path: '', state: ChildState.Open, created_at: '' },
},
},
{
type: 'fetchItems',
payload: {
parentItem: {
...mockParentItem,
},
},
},
],
);
});
......
......@@ -23315,6 +23315,9 @@ msgstr ""
msgid "Search Jira issues"
msgstr ""
msgid "Search a group"
msgstr ""
msgid "Search an environment spec"
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