Commit ac6ee712 authored by Kushal Pandya's avatar Kushal Pandya

Add sub-group select dropdown for Epic create

Adds support for selecting sub-groups while creating
epic from Group Epic Boards.

Changelod: added
EE: true
parent fb1cd811
<script>
import { mapActions, mapGetters } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardNewItem from '~/boards/components/board_new_item.vue';
import { toggleFormEventPrefix } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import { fullEpicBoardId } from '../boards_util';
import GroupSelect from './group_select.vue';
export default {
components: {
BoardNewItem,
GroupSelect,
},
inject: ['boardId'],
props: {
......@@ -18,6 +21,7 @@ export default {
},
},
computed: {
...mapState(['selectedGroup', 'fullPath']),
...mapGetters(['isGroupBoard']),
formEventPrefix() {
return toggleFormEventPrefix.epic;
......@@ -25,6 +29,9 @@ export default {
formEvent() {
return `${this.formEventPrefix}${this.list.id}`;
},
groupPath() {
return this.selectedGroup?.fullPath ?? this.fullPath;
},
},
methods: {
...mapActions(['addListNewEpic']),
......@@ -34,6 +41,7 @@ export default {
title,
boardId: fullEpicBoardId(this.boardId),
listId: this.list.id,
groupPath: this.groupPath,
},
list: this.list,
}).then(() => {
......@@ -54,5 +62,7 @@ export default {
:submit-button-title="__('Create epic')"
@form-submit="submit"
@form-cancel="cancel"
/>
>
<group-select :list="list" />
</board-new-item>
</template>
<script>
import {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { ListType } from '../constants';
export default {
name: 'GroupSelect',
i18n: {
headerTitle: s__(`BoardNewEpic|Groups`),
dropdownText: s__(`BoardNewEpic|Select a group`),
searchPlaceholder: s__(`BoardNewEpic|Search groups`),
emptySearchResult: s__(`BoardNewEpic|No matching results`),
},
defaultFetchOptions: {
with_issues_enabled: true,
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
},
components: {
GlIntersectionObserver,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
},
inject: ['groupId'],
props: {
list: {
type: Object,
required: true,
},
},
data() {
return {
initialLoading: true,
searchTerm: '',
};
},
computed: {
...mapState(['subGroupsFlags', 'subGroups', 'selectedGroup']),
selectedGroupName() {
return this.selectedGroup.name || s__('BoardNewEpic|Loading groups');
},
fetchOptions() {
const additionalAttrs = {};
if (this.list.type && this.list.type !== ListType.backlog) {
additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
}
return {
...this.$options.defaultFetchOptions,
...additionalAttrs,
};
},
isFetchResultEmpty() {
return this.subGroups.length === 0;
},
hasNextPage() {
return this.subGroupsFlags.pageInfo?.hasNextPage;
},
},
watch: {
searchTerm() {
this.fetchSubGroups({ search: this.searchTerm });
},
},
async mounted() {
await this.fetchSubGroups();
this.initialLoading = false;
},
methods: {
...mapActions(['fetchSubGroups', 'setSelectedGroup']),
selectGroup(groupId) {
this.setSelectedGroup(this.subGroups.find((group) => group.id === groupId));
},
loadMoreGroups() {
this.fetchSubGroups({ search: this.searchTerm, fetchNext: true });
},
},
};
</script>
<template>
<div>
<label
for="descendant-group-select"
class="gl-font-weight-bold gl-mt-3"
data-testid="header-label"
>{{ $options.i18n.headerTitle }}</label
>
<gl-dropdown
id="descendant-group-select"
data-testid="project-select-dropdown"
:text="selectedGroupName"
:header-text="$options.i18n.headerTitle"
block
menu-class="gl-w-full!"
:loading="initialLoading"
>
<gl-search-box-by-type
v-model.trim="searchTerm"
debounce="250"
:placeholder="$options.i18n.searchPlaceholder"
/>
<gl-dropdown-item
v-for="group in subGroups"
v-show="!subGroupsFlags.isLoading"
:key="group.id"
:name="group.name"
@click="selectGroup(group.id)"
>
{{ group.fullName }}
</gl-dropdown-item>
<gl-dropdown-text v-show="subGroupsFlags.isLoading" data-testid="dropdown-text-loading-icon">
<gl-loading-icon class="gl-mx-auto" size="sm" />
</gl-dropdown-text>
<gl-dropdown-text
v-if="isFetchResultEmpty && !subGroupsFlags.isLoading"
data-testid="empty-result-message"
>
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
</gl-dropdown-text>
<gl-intersection-observer v-if="hasNextPage" @appear="loadMoreGroups">
<gl-loading-icon v-if="subGroupsFlags.isLoadingMore" size="md" />
</gl-intersection-observer>
</gl-dropdown>
</div>
</template>
......@@ -11,5 +11,6 @@ mutation CreateEpic($input: BoardEpicCreateInput!) {
}
}
}
errors
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
fragment Group on Group {
id
name
fullName
fullPath
}
query getSubGroups($fullPath: ID!, $search: String, $after: String) {
group(fullPath: $fullPath) {
...Group
descendantGroups(search: $search, after: $after, first: 100) {
nodes {
...Group
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -31,6 +31,7 @@ import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql';
import listsEpicsQuery from '../graphql/lists_epics.query.graphql';
import subGroupsQuery from '../graphql/sub_groups.query.graphql';
import updateBoardEpicUserPreferencesMutation from '../graphql/update_board_epic_user_preferences.mutation.graphql';
import updateEpicLabelsMutation from '../graphql/update_epic_labels.mutation.graphql';
......@@ -449,6 +450,46 @@ export default {
});
},
fetchSubGroups: ({ commit, state }, { search = '', fetchNext = false } = {}) => {
commit(types.REQUEST_SUB_GROUPS, fetchNext);
const { fullPath } = state;
const variables = {
fullPath,
search: search !== '' ? search : undefined,
after: fetchNext ? state.subGroupsFlags.pageInfo.endCursor : undefined,
};
return gqlClient
.query({
query: subGroupsQuery,
variables,
})
.then(({ data }) => {
const { id, name, fullName, descendantGroups, __typename } = data.group;
const currentGroup = {
__typename,
id,
name,
fullName,
fullPath: data.group.fullPath,
};
const subGroups = [currentGroup, ...descendantGroups.nodes];
commit(types.RECEIVE_SUB_GROUPS_SUCCESS, {
subGroups,
pageInfo: descendantGroups.pageInfo,
fetchNext,
});
commit(types.SET_SELECTED_GROUP, currentGroup);
})
.catch(() => commit(types.RECEIVE_SUB_GROUPS_FAILURE));
},
setSelectedGroup: ({ commit }, group) => {
commit(types.SET_SELECTED_GROUP, group);
},
createList: (
{ getters, dispatch },
{ backlog, labelId, milestoneId, assigneeId, iterationId },
......@@ -495,14 +536,9 @@ export default {
},
addListNewEpic: (
{ state: { fullPath }, dispatch, commit },
{ dispatch, commit },
{ epicInput, list, placeholderId = `tmp-${new Date().getTime()}` },
) => {
const input = {
...epicInput,
groupPath: fullPath,
};
const placeholderEpic = {
...epicInput,
id: placeholderId,
......@@ -516,11 +552,11 @@ export default {
gqlClient
.mutate({
mutation: epicCreateMutation,
variables: { input },
variables: { input: epicInput },
})
.then(({ data }) => {
if (data.boardEpicCreate.errors?.length) {
throw new Error();
throw new Error(data.boardEpicCreate.errors[0]);
}
const rawEpic = data.boardEpicCreate?.epic;
......
......@@ -18,3 +18,7 @@ export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES'
export const RECEIVE_ASSIGNEES_REQUEST = 'RECEIVE_ASSIGNEES_REQUEST';
export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS';
export const RECEIVE_ASSIGNEES_FAILURE = 'RECEIVE_ASSIGNEES_FAILURE';
export const REQUEST_SUB_GROUPS = 'REQUEST_SUB_GROUPS';
export const RECEIVE_SUB_GROUPS_SUCCESS = 'RECEIVE_SUB_GROUPS_SUCCESS';
export const RECEIVE_SUB_GROUPS_FAILURE = 'RECEIVE_SUB_GROUPS_FAILURE';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
......@@ -184,4 +184,25 @@ export default {
state.assigneesLoading = false;
state.error = __('Failed to load assignees.');
},
[mutationTypes.REQUEST_SUB_GROUPS]: (state, fetchNext) => {
Vue.set(state, 'subGroupsFlags', {
[fetchNext ? 'isLoadingMore' : 'isLoading']: true,
pageInfo: state.subGroupsFlags.pageInfo,
});
},
[mutationTypes.RECEIVE_SUB_GROUPS_SUCCESS]: (state, { subGroups, pageInfo, fetchNext }) => {
Vue.set(state, 'subGroups', fetchNext ? [...state.subGroups, ...subGroups] : subGroups);
Vue.set(state, 'subGroupsFlags', { isLoading: false, isLoadingMore: false, pageInfo });
},
[mutationTypes.RECEIVE_SUB_GROUPS_FAILURE]: (state) => {
state.error = s__('Boards|An error occurred while fetching child groups. Please try again.');
Vue.set(state, 'subGroupsFlags', { isLoading: false, isLoadingMore: false });
},
[mutationTypes.SET_SELECTED_GROUP]: (state, group) => {
state.selectedGroup = group;
},
};
......@@ -17,4 +17,11 @@ export default () => ({
epicsFlags: {},
assignees: [],
assigneesLoading: false,
selectedGroup: {},
subGroups: [],
subGroupsFlags: {
isLoading: false,
isLoadingMore: false,
pageInfo: {},
},
});
......@@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardNewEpic from 'ee/boards/components/board_new_epic.vue';
import GroupSelect from 'ee/boards/components/group_select.vue';
import { mockList } from 'jest/boards/mock_data';
import BoardNewItem from '~/boards/components/board_new_item.vue';
......@@ -65,6 +66,13 @@ describe('Epic boards new epic form', () => {
});
});
it('renders group-select dropdown within board-new-item', () => {
const boardNewItem = findBoardNewItem();
const groupSelect = boardNewItem.findComponent(GroupSelect);
expect(groupSelect.exists()).toBe(true);
});
it('calls action `addListNewEpic` when "Create epic" button is clicked', async () => {
await submitForm(wrapper);
......
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import GroupSelect from 'ee/boards/components/group_select.vue';
import defaultState from 'ee/boards/stores/state';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockList } from 'jest/boards/mock_data';
import { mockGroup0, mockSubGroups } from '../mock_data';
describe('GroupSelect component', () => {
let wrapper;
let store;
const findLabel = () => wrapper.findByTestId('header-label');
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
const findGlDropdownLoadingIcon = () =>
findGlDropdown().find('button:first-child').findComponent(GlLoadingIcon);
const findGlSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
const findGlDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstGlDropdownItem = () => findGlDropdownItems().at(0);
const findInMenuLoadingIcon = () => wrapper.findByTestId('dropdown-text-loading-icon');
const findEmptySearchMessage = () => wrapper.findByTestId('empty-result-message');
const createStore = ({ state = {}, subGroups, selectedGroup, moreGroupsLoading = false }) => {
Vue.use(Vuex);
store = new Vuex.Store({
state: {
...state,
subGroups,
selectedGroup,
subGroupsFlags: {
isLoading: moreGroupsLoading,
pageInfo: {
hasNextPage: false,
},
},
},
actions: {
fetchSubGroups: jest.fn(),
setSelectedGroup: jest.fn(),
},
});
};
const createWrapper = ({
state = defaultState,
subGroups = [],
selectedGroup = {},
loading = false,
moreGroupsLoading = false,
} = {}) => {
createStore({
state,
subGroups,
selectedGroup,
loading,
moreGroupsLoading,
});
wrapper = extendedWrapper(
mount(GroupSelect, {
propsData: {
list: mockList,
},
data() {
return {
initialLoading: loading,
};
},
store,
provide: {
groupId: 1,
},
}),
);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays a header title', () => {
createWrapper();
expect(findLabel().text()).toBe('Groups');
});
it('renders a default dropdown text', () => {
createWrapper();
expect(findGlDropdown().exists()).toBe(true);
expect(findGlDropdown().text()).toContain('Loading groups');
});
describe('when mounted', () => {
it('displays a loading icon while descendant groups are being fetched', async () => {
createWrapper({ loading: true });
wrapper.setData({ initialLoading: true });
await wrapper.vm.$nextTick();
expect(findGlDropdownLoadingIcon().exists()).toBe(true);
});
});
describe('when dropdown menu is open', () => {
describe('by default', () => {
beforeEach(() => {
createWrapper({ subGroups: mockSubGroups });
});
it('shows GlSearchBoxByType with default attributes', () => {
expect(findGlSearchBoxByType().exists()).toBe(true);
expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({
placeholder: 'Search groups',
debounce: '250',
});
});
it("displays the fetched groups's name", () => {
expect(findFirstGlDropdownItem().exists()).toBe(true);
expect(findFirstGlDropdownItem().text()).toContain(mockGroup0.name);
});
it("doesn't render loading icon in the menu", () => {
expect(findInMenuLoadingIcon().isVisible()).toBe(false);
});
it('does not render empty search result message', () => {
expect(findEmptySearchMessage().exists()).toBe(false);
});
});
describe('when no groups are being returned', () => {
it('renders empty search result message', () => {
createWrapper();
expect(findEmptySearchMessage().exists()).toBe(true);
});
});
describe('when a group is selected', () => {
it('renders the name of the selected group', () => {
createWrapper({ subGroups: mockSubGroups, selectedGroup: mockGroup0 });
expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe(mockGroup0.name);
});
});
describe('when groups are loading', () => {
it('displays and hides gl-loading-icon while and after fetching data', () => {
createWrapper({ moreGroupsLoading: true });
expect(findInMenuLoadingIcon().isVisible()).toBe(true);
});
});
});
});
......@@ -341,3 +341,29 @@ export const issues = {
[mockIssue3.id]: mockIssue3,
[mockIssue4.id]: mockIssue4,
};
export const mockGroup0 = {
__typename: 'Group',
id: 'gid://gitlab/Group/22',
name: 'Gitlab Org',
fullName: 'Gitlab Org',
fullPath: 'gitlab-org',
};
export const mockGroup1 = {
__typename: 'Group',
id: 'gid://gitlab/Group/108',
name: 'Design',
fullName: 'Gitlab Org / Design',
fullPath: 'gitlab-org/design',
};
export const mockGroup2 = {
__typename: 'Group',
id: 'gid://gitlab/Group/109',
name: 'Database',
fullName: 'Gitlab Org / Database',
fullPath: 'gitlab-org/database',
};
export const mockSubGroups = [mockGroup0, mockGroup1, mockGroup2];
......@@ -25,6 +25,8 @@ import {
mockEpic,
mockMilestones,
mockAssignees,
mockSubGroups,
mockGroup0,
} from '../mock_data';
Vue.use(Vuex);
......@@ -952,7 +954,7 @@ describe('addListNewEpic', () => {
await actions.addListNewEpic(
{ dispatch: jest.fn(), commit: jest.fn(), state },
{ epicInput: mockEpic, list: fakeList },
{ epicInput: { ...mockEpic, groupPath: state.fullPath }, list: fakeList },
);
expect(gqlClient.mutate).toHaveBeenCalledWith({
......@@ -990,7 +992,7 @@ describe('addListNewEpic', () => {
await actions.addListNewEpic(
{ dispatch: jest.fn(), commit: jest.fn(), state },
{ epicInput: epic, list: fakeList },
{ epicInput: { ...epic, groupPath: state.fullPath }, list: fakeList },
);
expect(gqlClient.mutate).toHaveBeenCalledWith({
......@@ -1242,6 +1244,97 @@ describe('fetchAssignees', () => {
});
});
describe('fetchSubGroups', () => {
const state = {
fullPath: 'gitlab-org',
};
const pageInfo = {
endCursor: '',
hasNextPage: false,
};
const queryResponse = {
data: {
group: {
descendantGroups: {
nodes: mockSubGroups.slice(1), // First group is root group, so skip it.
pageInfo: {
endCursor: '',
hasNextPage: false,
},
},
...mockGroup0, // Add root group info
},
},
};
it('should commit mutations REQUEST_SUB_GROUPS, RECEIVE_SUB_GROUPS_SUCCESS, and SET_SELECTED_GROUP on success', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction(
actions.fetchSubGroups,
{},
state,
[
{
type: types.REQUEST_SUB_GROUPS,
payload: false,
},
{
type: types.RECEIVE_SUB_GROUPS_SUCCESS,
payload: { subGroups: mockSubGroups, pageInfo, fetchNext: false },
},
{
type: types.SET_SELECTED_GROUP,
payload: mockGroup0,
},
],
[],
done,
);
});
it('should commit mutations REQUEST_SUB_GROUPS and RECEIVE_SUB_GROUPS_FAILURE on failure', (done) => {
jest.spyOn(gqlClient, 'query').mockRejectedValue();
testAction(
actions.fetchSubGroups,
{},
state,
[
{
type: types.REQUEST_SUB_GROUPS,
payload: false,
},
{
type: types.RECEIVE_SUB_GROUPS_FAILURE,
},
],
[],
done,
);
});
});
describe('setSelectedGroup', () => {
it('should commit mutation SET_SELECTED_GROUP', (done) => {
testAction(
actions.setSelectedGroup,
mockGroup0,
{},
[
{
type: types.SET_SELECTED_GROUP,
payload: mockGroup0,
},
],
[],
done,
);
});
});
describe('setActiveEpicLabels', () => {
const state = { boardItems: { [mockEpic.id]: mockEpic } };
const getters = { activeBoardItem: mockEpic };
......
import * as types from 'ee/boards/stores/mutation_types';
import mutations from 'ee/boards/stores/mutations';
import { mockEpics, mockEpic, mockLists, mockIssue, mockIssue2 } from '../mock_data';
import { mockEpics, mockEpic, mockLists, mockIssue, mockIssue2, mockSubGroups } from '../mock_data';
const initialBoardListsState = {
'gid://gitlab/List/1': mockLists[0],
......@@ -15,6 +16,11 @@ let state = {
epicsFlags: {
[epicId]: { isLoading: true },
},
subGroupsFlags: {
isLoading: false,
isLoadingMore: false,
pageInfo: {},
},
};
describe('SET_SHOW_LABELS', () => {
......@@ -292,3 +298,61 @@ describe('SET_BOARD_EPIC_USER_PREFERENCES', () => {
expect(state.epics[0].userPreferences).toEqual(userPreferences);
});
});
describe('REQUEST_SUB_GROUPS', () => {
it('Should set isLoading in subGroupsFlags to true in state when fetchNext is false', () => {
mutations[types.REQUEST_SUB_GROUPS](state, false);
expect(state.subGroupsFlags.isLoading).toBe(true);
});
it('Should set isLoadingMore in subGroupsFlags to true in state when fetchNext is true', () => {
mutations[types.REQUEST_SUB_GROUPS](state, true);
expect(state.subGroupsFlags.isLoadingMore).toBe(true);
});
});
describe('RECEIVE_SUB_GROUPS_SUCCESS', () => {
it('Should set subGroups and pageInfo to state and isLoading in subGroupsFlags to false', () => {
mutations[types.RECEIVE_SUB_GROUPS_SUCCESS](state, {
subGroups: mockSubGroups,
pageInfo: { hasNextPage: false },
});
expect(state.subGroups).toEqual(mockSubGroups);
expect(state.subGroupsFlags.isLoading).toBe(false);
expect(state.subGroupsFlags.pageInfo).toEqual({ hasNextPage: false });
});
it('Should merge groups in subGroups in state when fetchNext is true', () => {
state = {
...state,
subGroups: [mockSubGroups[0]],
};
mutations[types.RECEIVE_SUB_GROUPS_SUCCESS](state, {
subGroups: [mockSubGroups[1]],
fetchNext: true,
});
expect(state.subGroups).toEqual([mockSubGroups[0], mockSubGroups[1]]);
});
});
describe('RECEIVE_SUB_GROUPS_FAILURE', () => {
it('Should set error in state and isLoading in subGroupsFlags to false', () => {
mutations[types.RECEIVE_SUB_GROUPS_FAILURE](state);
expect(state.error).toEqual('An error occurred while fetching child groups. Please try again.');
expect(state.subGroupsFlags.isLoading).toBe(false);
});
});
describe('SET_SELECTED_GROUP', () => {
it('Should set selectedGroup to state', () => {
mutations[types.SET_SELECTED_GROUP](state, mockSubGroups[0]);
expect(state.selectedGroup).toEqual(mockSubGroups[0]);
});
});
......@@ -5397,6 +5397,21 @@ msgstr ""
msgid "Board scope affects which issues are displayed for anyone who visits this board"
msgstr ""
msgid "BoardNewEpic|Groups"
msgstr ""
msgid "BoardNewEpic|Loading groups"
msgstr ""
msgid "BoardNewEpic|No matching results"
msgstr ""
msgid "BoardNewEpic|Search groups"
msgstr ""
msgid "BoardNewEpic|Select a group"
msgstr ""
msgid "BoardNewIssue|No matching results"
msgstr ""
......@@ -5474,6 +5489,9 @@ msgstr ""
msgid "Boards|An error occurred while creating the list. Please try again."
msgstr ""
msgid "Boards|An error occurred while fetching child groups. Please try again."
msgstr ""
msgid "Boards|An error occurred while fetching group projects. Please try again."
msgstr ""
......
......@@ -545,7 +545,7 @@ describe('Board Store Mutations', () => {
expect(state.groupProjectsFlags.isLoading).toBe(true);
});
it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is true', () => {
it('Should set isLoadingMore in groupProjectsFlags to true in state when fetchNext is true', () => {
mutations[types.REQUEST_GROUP_PROJECTS](state, true);
expect(state.groupProjectsFlags.isLoadingMore).toBe(true);
......
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