Commit ecc70809 authored by Kushal Pandya's avatar Kushal Pandya

Add ability to create epic from boards

Adds ability to create epic from within Epic Boards.
parent 5dea2af8
...@@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt ...@@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import BoardCard from './board_card.vue'; import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue'; import BoardNewIssue from './board_new_issue.vue';
...@@ -21,6 +22,7 @@ export default { ...@@ -21,6 +22,7 @@ export default {
components: { components: {
BoardCard, BoardCard,
BoardNewIssue, BoardNewIssue,
BoardNewEpic: () => import('ee_component/boards/components/board_new_epic.vue'),
GlLoadingIcon, GlLoadingIcon,
GlIntersectionObserver, GlIntersectionObserver,
}, },
...@@ -49,6 +51,7 @@ export default { ...@@ -49,6 +51,7 @@ export default {
scrollOffset: 250, scrollOffset: 250,
showCount: false, showCount: false,
showIssueForm: false, showIssueForm: false,
showEpicForm: false,
}; };
}, },
computed: { computed: {
...@@ -64,6 +67,9 @@ export default { ...@@ -64,6 +67,9 @@ export default {
issuableType: this.isEpicBoard ? 'epics' : 'issues', issuableType: this.isEpicBoard ? 'epics' : 'issues',
}); });
}, },
toggleFormEventPrefix() {
return this.isEpicBoard ? toggleFormEventPrefix.epic : toggleFormEventPrefix.issue;
},
boardItemsSizeExceedsMax() { boardItemsSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount;
}, },
...@@ -76,6 +82,12 @@ export default { ...@@ -76,6 +82,12 @@ export default {
loadingMore() { loadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore; return this.listsFlags[this.list.id]?.isLoadingMore;
}, },
epicCreateFormVisible() {
return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm;
},
issueCreateFormVisible() {
return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm;
},
listRef() { listRef() {
// When list is draggable, the reference to the list needs to be accessed differently // When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list; return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
...@@ -116,9 +128,10 @@ export default { ...@@ -116,9 +128,10 @@ export default {
'list.id': { 'list.id': {
handler(id, oldVal) { handler(id, oldVal) {
if (id) { if (id) {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$on(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm);
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
eventHub.$off(`toggle-issue-form-${oldVal}`, this.toggleForm);
eventHub.$off(`${this.toggleFormEventPrefix}${oldVal}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop); eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop);
} }
}, },
...@@ -126,7 +139,7 @@ export default { ...@@ -126,7 +139,7 @@ export default {
}, },
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
}, },
methods: { methods: {
...@@ -147,7 +160,11 @@ export default { ...@@ -147,7 +160,11 @@ export default {
this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); this.fetchItemsForList({ listId: this.list.id, fetchNext: true });
}, },
toggleForm() { toggleForm() {
if (this.isEpicBoard) {
this.showEpicForm = !this.showEpicForm;
} else {
this.showIssueForm = !this.showIssueForm; this.showIssueForm = !this.showIssueForm;
}
}, },
onReachingListBottom() { onReachingListBottom() {
if (!this.loadingMore && this.hasNextPage) { if (!this.loadingMore && this.hasNextPage) {
...@@ -227,7 +244,8 @@ export default { ...@@ -227,7 +244,8 @@ export default {
> >
<gl-loading-icon /> <gl-loading-icon />
</div> </div>
<board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" /> <board-new-issue v-if="issueCreateFormVisible" :list="list" />
<board-new-epic v-if="epicCreateFormVisible" :list="list" />
<component <component
:is="treeRootWrapper" :is="treeRootWrapper"
v-show="!loading" v-show="!loading"
......
...@@ -16,13 +16,14 @@ import { n__, s__, __ } from '~/locale'; ...@@ -16,13 +16,14 @@ import { n__, s__, __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import AccessorUtilities from '../../lib/utils/accessor'; import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants'; import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import ItemCount from './item_count.vue'; import ItemCount from './item_count.vue';
export default { export default {
i18n: { i18n: {
newIssue: __('New issue'), newIssue: __('New issue'),
newEpic: s__('Boards|New epic'),
listSettings: __('List settings'), listSettings: __('List settings'),
expand: s__('Boards|Expand'), expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'), collapse: s__('Boards|Collapse'),
...@@ -102,7 +103,7 @@ export default { ...@@ -102,7 +103,7 @@ export default {
}, },
showListHeaderActions() { showListHeaderActions() {
if (this.isLoggedIn) { if (this.isLoggedIn) {
return this.isNewIssueShown || this.isSettingsShown; return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown;
} }
return false; return false;
}, },
...@@ -124,6 +125,9 @@ export default { ...@@ -124,6 +125,9 @@ export default {
isNewIssueShown() { isNewIssueShown() {
return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
}, },
isNewEpicShown() {
return this.isEpicBoard && this.listType !== ListType.closed;
},
isSettingsShown() { isSettingsShown() {
return ( return (
this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
...@@ -165,7 +169,10 @@ export default { ...@@ -165,7 +169,10 @@ export default {
}, },
showNewIssueForm() { showNewIssueForm() {
eventHub.$emit(`toggle-issue-form-${this.list.id}`); eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
},
showNewEpicForm() {
eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`);
}, },
toggleExpanded() { toggleExpanded() {
const collapsed = !this.list.collapsed; const collapsed = !this.list.collapsed;
...@@ -379,6 +386,17 @@ export default { ...@@ -379,6 +386,17 @@ export default {
@click="showNewIssueForm" @click="showNewIssueForm"
/> />
<gl-button
v-if="isNewEpicShown"
v-show="!list.collapsed"
v-gl-tooltip.hover
:aria-label="$options.i18n.newEpic"
:title="$options.i18n.newEpic"
class="no-drag"
icon="plus"
@click="showNewEpicForm"
/>
<gl-button <gl-button
v-if="isSettingsShown" v-if="isSettingsShown"
ref="settingsBtn" ref="settingsBtn"
......
...@@ -4,13 +4,13 @@ import { mapActions, mapGetters, mapState } from 'vuex'; ...@@ -4,13 +4,13 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util'; import { getMilestone } from 'ee_else_ce/boards/boards_util';
import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue'; import ProjectSelect from './project_select.vue';
export default { export default {
name: 'BoardNewIssue', name: 'BoardNewIssue',
i18n: { i18n: {
submit: __('Create issue'),
cancel: __('Cancel'), cancel: __('Cancel'),
}, },
components: { components: {
...@@ -32,7 +32,15 @@ export default { ...@@ -32,7 +32,15 @@ export default {
}, },
computed: { computed: {
...mapState(['selectedProject']), ...mapState(['selectedProject']),
...mapGetters(['isGroupBoard']), ...mapGetters(['isGroupBoard', 'isEpicBoard']),
/**
* We've extended this component in EE where
* submitButtonTitle returns a different string
* hence this is kept as a computed prop.
*/
submitButtonTitle() {
return __('Create issue');
},
disabled() { disabled() {
if (this.isGroupBoard) { if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name; return this.title === '' || !this.selectedProject.name;
...@@ -50,9 +58,7 @@ export default { ...@@ -50,9 +58,7 @@ export default {
}, },
methods: { methods: {
...mapActions(['addListNewIssue']), ...mapActions(['addListNewIssue']),
submit(e) { submit() {
e.preventDefault();
const { title } = this; const { title } = this;
const labels = this.list.label ? [this.list.label] : []; const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : []; const assignees = this.list.assignee ? [this.list.assignee] : [];
...@@ -76,7 +82,7 @@ export default { ...@@ -76,7 +82,7 @@ export default {
}, },
reset() { reset() {
this.title = ''; this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`); eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
}, },
}, },
}; };
...@@ -85,7 +91,7 @@ export default { ...@@ -85,7 +91,7 @@ export default {
<template> <template>
<div class="board-new-issue-form"> <div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded"> <div class="board-card position-relative p-3 rounded">
<form ref="submitForm" @submit="submit"> <form ref="submitForm" @submit.prevent="submit">
<label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
<input <input
:id="inputFieldId" :id="inputFieldId"
...@@ -96,7 +102,7 @@ export default { ...@@ -96,7 +102,7 @@ export default {
name="issue_title" name="issue_title"
autocomplete="off" autocomplete="off"
/> />
<project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> <project-select v-if="isGroupBoard && !isEpicBoard" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3"> <div class="clearfix gl-mt-3">
<gl-button <gl-button
ref="submitButton" ref="submitButton"
...@@ -106,7 +112,7 @@ export default { ...@@ -106,7 +112,7 @@ export default {
category="primary" category="primary"
type="submit" type="submit"
> >
{{ $options.i18n.submit }} {{ submitButtonTitle }}
</gl-button> </gl-button>
<gl-button <gl-button
ref="cancelButton" ref="cancelButton"
......
...@@ -45,6 +45,11 @@ export const formType = { ...@@ -45,6 +45,11 @@ export const formType = {
edit: 'edit', edit: 'edit',
}; };
export const toggleFormEventPrefix = {
epic: 'toggle-epic-form-',
issue: 'toggle-issue-form-',
};
export const inactiveId = 0; export const inactiveId = 0;
export const ISSUABLE = 'issuable'; export const ISSUABLE = 'issuable';
......
...@@ -37,6 +37,18 @@ export function calculateSwimlanesBufferSize(listTopCoordinate) { ...@@ -37,6 +37,18 @@ export function calculateSwimlanesBufferSize(listTopCoordinate) {
return Math.ceil((window.innerHeight - listTopCoordinate) / EPIC_LANE_BASE_HEIGHT); return Math.ceil((window.innerHeight - listTopCoordinate) / EPIC_LANE_BASE_HEIGHT);
} }
export function formatEpic(epic) {
return {
...epic,
labels: epic.labels?.nodes || [],
// Epics don't support assignees as of now
// but `<board-card-inner>` expects it.
// So until https://gitlab.com/gitlab-org/gitlab/-/issues/238444
// is addressed, we need to pass empty array.
assignees: [],
};
}
export function formatListEpics(listEpics) { export function formatListEpics(listEpics) {
const boardItems = {}; const boardItems = {};
let listItemsCount; let listItemsCount;
......
<script>
// This is a false violation of @gitlab/no-runtime-template-compiler, since it
// extends a valid Vue single file component.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import { mapActions, mapGetters } from 'vuex';
import BoardNewIssueFoss from '~/boards/components/board_new_issue.vue';
import { toggleFormEventPrefix } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import { fullEpicBoardId } from '../boards_util';
export default {
extends: BoardNewIssueFoss,
inject: {
boardId: {
default: '',
},
},
computed: {
...mapGetters(['isGroupBoard']),
submitButtonTitle() {
return __('Create epic');
},
disabled() {
return this.title === '';
},
},
methods: {
...mapActions(['addListNewEpic']),
submit() {
const {
title,
boardId,
list: { id },
} = this;
eventHub.$emit(`scroll-board-list-${id}`);
this.addListNewEpic({
epicInput: {
title,
boardId: fullEpicBoardId(boardId),
listId: id,
},
list: this.list,
})
.then(() => {
this.reset();
})
.catch((error) => {
createFlash({
message: s__('Board|Failed to create epic. Please try again.'),
captureError: true,
error,
});
});
},
reset() {
this.title = '';
eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`);
},
},
};
</script>
#import "ee/graphql_shared/fragments/epic.fragment.graphql"
#import "~/graphql_shared/fragments/label.fragment.graphql"
mutation CreateEpic($input: BoardEpicCreateInput!) {
boardEpicCreate(input: $input) {
epic {
...EpicNode
labels {
nodes {
...Label
}
}
}
}
}
...@@ -13,6 +13,7 @@ import projectBoardMembersQuery from '~/boards/graphql/project_board_members.que ...@@ -13,6 +13,7 @@ import projectBoardMembersQuery from '~/boards/graphql/project_board_members.que
import actionsCE, { gqlClient } from '~/boards/stores/actions'; import actionsCE, { gqlClient } from '~/boards/stores/actions';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import * as typesCE from '~/boards/stores/mutation_types'; import * as typesCE from '~/boards/stores/mutation_types';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { import {
historyPushState, historyPushState,
...@@ -20,9 +21,11 @@ import { ...@@ -20,9 +21,11 @@ import {
urlParamsToObject, urlParamsToObject,
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { import {
fullEpicId, fullEpicId,
fullEpicBoardId, fullEpicBoardId,
formatEpic,
formatListEpics, formatListEpics,
formatEpicListsPageInfo, formatEpicListsPageInfo,
FiltersInfo, FiltersInfo,
...@@ -30,6 +33,7 @@ import { ...@@ -30,6 +33,7 @@ import {
import { EpicFilterType, GroupByParamType, FilterFields } from '../constants'; import { EpicFilterType, GroupByParamType, FilterFields } from '../constants';
import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql'; import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql';
import epicCreateMutation from '../graphql/epic_create.mutation.graphql';
import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql'; import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql'; import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
...@@ -604,6 +608,49 @@ export default { ...@@ -604,6 +608,49 @@ export default {
}); });
}, },
addListNewEpic: (
{ state: { fullPath }, dispatch, commit },
{ epicInput, list, placeholderId = `tmp-${new Date().getTime()}` },
) => {
const input = {
...epicInput,
groupPath: fullPath,
};
const placeholderEpic = {
...epicInput,
id: placeholderId,
isLoading: true,
labels: [],
assignees: [],
};
dispatch('addListItem', { list, item: placeholderEpic, position: 0, inProgress: true });
gqlClient
.mutate({
mutation: epicCreateMutation,
variables: { input },
})
.then(({ data }) => {
if (data.boardEpicCreate.errors?.length) {
throw new Error();
}
const rawEpic = data.boardEpicCreate?.epic;
const formattedEpic = formatEpic({ ...rawEpic, id: getIdFromGraphQLId(rawEpic.id) });
dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
dispatch('addListItem', { list, item: formattedEpic, position: 0 });
})
.catch(() => {
dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
commit(
types.SET_ERROR,
s__('Boards|An error occurred while creating the epic. Please try again.'),
);
});
},
setActiveBoardItemLabels: ({ getters, dispatch }, params) => { setActiveBoardItemLabels: ({ getters, dispatch }, params) => {
if (!getters.isEpicBoard) { if (!getters.isEpicBoard) {
dispatch('setActiveIssueLabels', params); dispatch('setActiveIssueLabels', params);
......
...@@ -7,6 +7,7 @@ fragment EpicNode on Epic { ...@@ -7,6 +7,7 @@ fragment EpicNode on Epic {
title title
state state
reference reference
referencePath: reference(full: true)
webPath webPath
webUrl webUrl
createdAt createdAt
......
import { import {
formatEpic,
formatListEpics, formatListEpics,
formatEpicListsPageInfo, formatEpicListsPageInfo,
transformBoardConfig, transformBoardConfig,
...@@ -7,6 +8,33 @@ import { mockLabel } from './mock_data'; ...@@ -7,6 +8,33 @@ import { mockLabel } from './mock_data';
const listId = 'gid://gitlab/Boards::EpicList/3'; const listId = 'gid://gitlab/Boards::EpicList/3';
describe('formatEpic', () => {
it('formats raw epic object for state', () => {
const labels = [
{
id: 1,
title: 'bug',
},
];
const rawEpic = {
id: 1,
title: 'Foo',
labels: {
nodes: labels,
},
};
expect(formatEpic(rawEpic)).toEqual({
...rawEpic,
labels,
// Until we add support for assignees within Epics,
// we need to pass it as an empty array.
assignees: [],
});
});
});
describe('formatListEpics', () => { describe('formatListEpics', () => {
it('formats raw response from list epics for state', () => { it('formats raw response from list epics for state', () => {
const rawEpicsInLists = { const rawEpicsInLists = {
......
import { GlButton, GlButtonGroup } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import BoardListHeader from 'ee/boards/components/board_list_header.vue'; import BoardListHeader from 'ee/boards/components/board_list_header.vue';
import getters from 'ee/boards/stores/getters'; import defaultGetters from 'ee/boards/stores/getters';
import { mockLabelList } from 'jest/boards/mock_data'; import { mockLabelList } from 'jest/boards/mock_data';
import { ListType, inactiveId } from '~/boards/constants'; import { ListType, inactiveId } from '~/boards/constants';
import boardsEventHub from '~/boards/eventhub';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -15,18 +17,6 @@ describe('Board List Header Component', () => { ...@@ -15,18 +17,6 @@ describe('Board List Header Component', () => {
let store; let store;
let wrapper; let wrapper;
beforeEach(() => {
store = new Vuex.Store({ state: { activeId: inactiveId }, getters });
jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
localStorage.clear();
});
const createComponent = ({ const createComponent = ({
listType = ListType.backlog, listType = ListType.backlog,
collapsed = false, collapsed = false,
...@@ -34,6 +24,8 @@ describe('Board List Header Component', () => { ...@@ -34,6 +24,8 @@ describe('Board List Header Component', () => {
isSwimlanesHeader = false, isSwimlanesHeader = false,
weightFeatureAvailable = false, weightFeatureAvailable = false,
currentUserId = 1, currentUserId = 1,
state = { activeId: inactiveId },
getters = {},
} = {}) => { } = {}) => {
const boardId = '1'; const boardId = '1';
...@@ -55,6 +47,16 @@ describe('Board List Header Component', () => { ...@@ -55,6 +47,16 @@ describe('Board List Header Component', () => {
); );
} }
store = new Vuex.Store({
state,
getters: {
...defaultGetters,
...getters,
},
});
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(BoardListHeader, { wrapper = shallowMount(BoardListHeader, {
store, store,
localVue, localVue,
...@@ -73,6 +75,43 @@ describe('Board List Header Component', () => { ...@@ -73,6 +75,43 @@ describe('Board List Header Component', () => {
const findSettingsButton = () => wrapper.find({ ref: 'settingsBtn' }); const findSettingsButton = () => wrapper.find({ ref: 'settingsBtn' });
afterEach(() => {
wrapper.destroy();
localStorage.clear();
});
describe('New epic button', () => {
let newEpicButton;
beforeEach(() => {
jest.spyOn(boardsEventHub, '$emit');
createComponent({
getters: {
isIssueBoard: () => false,
isEpicBoard: () => true,
isGroupBoard: () => true,
},
});
newEpicButton = wrapper.findComponent(GlButtonGroup).findComponent(GlButton);
});
it('renders New epic button', () => {
expect(newEpicButton.exists()).toBe(true);
expect(newEpicButton.attributes()).toMatchObject({
title: 'New epic',
'aria-label': 'New epic',
});
});
it('emits `toggle-epic-form` event on Sidebar eventHub when clicked', async () => {
await newEpicButton.vm.$emit('click');
expect(boardsEventHub.$emit).toHaveBeenCalledWith(`toggle-epic-form-${mockLabelList.id}`);
expect(boardsEventHub.$emit).toHaveBeenCalledTimes(1);
});
});
describe('Settings Button', () => { describe('Settings Button', () => {
const hasSettings = [ListType.assignee, ListType.milestone, ListType.iteration, ListType.label]; const hasSettings = [ListType.assignee, ListType.milestone, ListType.iteration, ListType.label];
const hasNoSettings = [ListType.backlog, ListType.closed]; const hasNoSettings = [ListType.backlog, ListType.closed];
...@@ -111,8 +150,12 @@ describe('Board List Header Component', () => { ...@@ -111,8 +150,12 @@ describe('Board List Header Component', () => {
}); });
it('does not emit event when there is an active List', () => { it('does not emit event when there is an active List', () => {
store.state.activeId = mockLabelList.id; createComponent({
createComponent({ listType: hasSettings[0] }); listType: hasSettings[0],
state: {
activeId: mockLabelList.id,
},
});
wrapper.vm.openSidebarSettings(); wrapper.vm.openSidebarSettings();
expect(sidebarEventHub.$emit).not.toHaveBeenCalled(); expect(sidebarEventHub.$emit).not.toHaveBeenCalled();
......
import BoardNewEpic from 'ee/boards/components/board_new_epic.vue';
import createComponent from 'jest/boards/board_list_helper'; import createComponent from 'jest/boards/board_list_helper';
describe('BoardList Component', () => { import BoardCard from '~/boards/components/board_card.vue';
let mock; import BoardCardInner from '~/boards/components/board_card_inner.vue';
let component; import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import createFlash from '~/flash';
jest.mock('~/flash');
beforeEach((done) => { const listIssueProps = {
const listIssueProps = {
project: { project: {
path: '/test', path: '/test',
}, },
real_path: '', real_path: '',
webUrl: '', webUrl: '',
}; };
const componentProps = { const componentProps = {
groupId: undefined, groupId: undefined,
}; };
({ mock, component } = createComponent({ const actions = {
done, addListNewEpic: jest.fn().mockResolvedValue(),
componentProps, };
const componentConfig = {
listIssueProps, listIssueProps,
})); componentProps,
getters: {
isGroupBoard: () => true,
isProjectBoard: () => false,
isEpicBoard: () => true,
},
state: {
issuableType: issuableTypes.epic,
},
actions,
stubs: {
BoardCard,
BoardCardInner,
BoardNewEpic,
},
provide: {
scopedLabelsAvailable: true,
},
};
describe('BoardList Component', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent(componentConfig);
}); });
afterEach(() => { afterEach(() => {
mock.restore(); wrapper.destroy();
}); });
it('renders link properly in issue', () => { it('renders link properly in issue', () => {
expect( expect(wrapper.find('.board-card .board-card-title a').attributes('href')).not.toContain(
component.$el.querySelector('.board-card .board-card-title a').getAttribute('href'), ':project_path',
).not.toContain(':project_path'); );
});
describe('board-new-epic component', () => {
const submitForm = async (w) => {
const newEpicForm = w.findComponent(BoardNewEpic);
newEpicForm.find('input').setValue('Foo');
newEpicForm.find('form').trigger('submit');
await wrapper.vm.$nextTick();
};
beforeEach(async () => {
eventHub.$emit(`toggle-epic-form-${wrapper.vm.list.id}`);
await wrapper.vm.$nextTick();
});
it('renders component', () => {
expect(wrapper.findComponent(BoardNewEpic).exists()).toBe(true);
});
it('calls action `addListNewEpic` when "Create epic" button is clicked', async () => {
await submitForm(wrapper);
expect(actions.addListNewEpic).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
epicInput: {
title: 'Foo',
boardId: 'gid://gitlab/Boards::EpicBoard/',
listId: 'gid://gitlab/List/1',
},
}),
);
});
it('calls `createFlash` when form submission fails', async () => {
const mockActions = {
addListNewEpic: jest.fn().mockRejectedValue(),
};
wrapper = createComponent({
...componentConfig,
actions: mockActions,
});
eventHub.$emit(`toggle-epic-form-${wrapper.vm.list.id}`);
await wrapper.vm.$nextTick();
await submitForm(wrapper);
return mockActions.addListNewEpic().catch((error) => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Failed to create epic. Please try again.',
captureError: true,
error,
});
});
});
}); });
}); });
...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { BoardType, GroupByParamType, listsQuery, issuableTypes } from 'ee/boards/constants'; import { BoardType, GroupByParamType, listsQuery, issuableTypes } from 'ee/boards/constants';
import epicCreateMutation from 'ee/boards/graphql/epic_create.mutation.graphql';
import actions, { gqlClient } from 'ee/boards/stores/actions'; import actions, { gqlClient } from 'ee/boards/stores/actions';
import boardsStoreEE from 'ee/boards/stores/boards_store_ee'; import boardsStoreEE from 'ee/boards/stores/boards_store_ee';
import * as types from 'ee/boards/stores/mutation_types'; import * as types from 'ee/boards/stores/mutation_types';
...@@ -13,6 +14,7 @@ import { mockMoveIssueParams, mockMoveData, mockMoveState } from 'jest/boards/mo ...@@ -13,6 +14,7 @@ import { mockMoveIssueParams, mockMoveData, mockMoveState } from 'jest/boards/mo
import { formatListIssues } from '~/boards/boards_util'; import { formatListIssues } from '~/boards/boards_util';
import listsIssuesQuery from '~/boards/graphql/lists_issues.query.graphql'; import listsIssuesQuery from '~/boards/graphql/lists_issues.query.graphql';
import * as typesCE from '~/boards/stores/mutation_types'; import * as typesCE from '~/boards/stores/mutation_types';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import * as commonUtils from '~/lib/utils/common_utils'; import * as commonUtils from '~/lib/utils/common_utils';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import { import {
...@@ -876,6 +878,169 @@ describe('createEpicList', () => { ...@@ -876,6 +878,169 @@ describe('createEpicList', () => {
}); });
}); });
describe('addListNewEpic', () => {
const state = {
boardType: 'group',
fullPath: 'gitlab-org/gitlab',
boardConfig: {
labelIds: [],
assigneeId: null,
milestoneId: -1,
},
};
const fakeList = { id: 'gid://gitlab/List/123' };
it('should add board scope to the epic being created', async () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
boardEpicCreate: {
epic: mockEpic,
errors: [],
},
},
});
await actions.addListNewEpic(
{ dispatch: jest.fn(), commit: jest.fn(), state },
{ epicInput: mockEpic, list: fakeList },
);
expect(gqlClient.mutate).toHaveBeenCalledWith({
mutation: epicCreateMutation,
variables: {
input: {
...mockEpic,
groupPath: state.fullPath,
id: 'gid://gitlab/Epic/41',
labels: [],
},
},
});
});
it('should add board scope by merging attributes to the epic being created', async () => {
const epic = {
...mockEpic,
labelIds: ['gid://gitlab/GroupLabel/4'],
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
boardEpicCreate: {
epic,
errors: [],
},
},
});
const payload = {
...epic,
labelIds: [...epic.labelIds, 'gid://gitlab/GroupLabel/5'],
};
await actions.addListNewEpic(
{ dispatch: jest.fn(), commit: jest.fn(), state },
{ epicInput: epic, list: fakeList },
);
expect(gqlClient.mutate).toHaveBeenCalledWith({
mutation: epicCreateMutation,
variables: {
input: {
...epic,
groupPath: state.fullPath,
},
},
});
expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']);
});
describe('when issue creation mutation request succeeds', () => {
it('dispatches a correct set of mutations', () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
boardEpicCreate: {
epic: mockEpic,
errors: [],
},
},
});
testAction({
action: actions.addListNewEpic,
payload: {
epicInput: mockEpic,
list: fakeList,
placeholderId: 'tmp',
},
state,
expectedActions: [
{
type: 'addListItem',
payload: {
list: fakeList,
item: { ...mockEpic, id: 'tmp', isLoading: true, labels: [], assignees: [] },
position: 0,
inProgress: true,
},
},
{ type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } },
{
type: 'addListItem',
payload: {
list: fakeList,
item: { ...mockEpic, id: getIdFromGraphQLId(mockEpic.id), assignees: [] },
position: 0,
},
},
],
});
});
});
describe('when issue creation mutation request fails', () => {
it('dispatches a correct set of mutations', () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
boardEpicCreate: {
epic: mockEpic,
errors: [{ foo: 'bar' }],
},
},
});
testAction({
action: actions.addListNewEpic,
payload: {
epicInput: mockEpic,
list: fakeList,
placeholderId: 'tmp',
},
state,
expectedActions: [
{
type: 'addListItem',
payload: {
list: fakeList,
item: { ...mockEpic, id: 'tmp', isLoading: true, labels: [], assignees: [] },
position: 0,
inProgress: true,
},
},
{ type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } },
],
expectedMutations: [
{
type: types.SET_ERROR,
payload: 'An error occurred while creating the epic. Please try again.',
},
],
});
});
});
});
describe('fetchMilestones', () => { describe('fetchMilestones', () => {
const queryResponse = { const queryResponse = {
data: { data: {
......
...@@ -5247,6 +5247,9 @@ msgid_plural "Boards|+ %{displayedIssuablesCount} more %{issuableType}s" ...@@ -5247,6 +5247,9 @@ msgid_plural "Boards|+ %{displayedIssuablesCount} more %{issuableType}s"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Boards|An error occurred while creating the epic. Please try again."
msgstr ""
msgid "Boards|An error occurred while creating the issue. Please try again." msgid "Boards|An error occurred while creating the issue. Please try again."
msgstr "" msgstr ""
...@@ -5306,6 +5309,9 @@ msgstr "" ...@@ -5306,6 +5309,9 @@ msgstr ""
msgid "Boards|Failed to fetch blocking %{issuableType}s" msgid "Boards|Failed to fetch blocking %{issuableType}s"
msgstr "" msgstr ""
msgid "Boards|New epic"
msgstr ""
msgid "Boards|Retrieving blocking %{issuableType}s" msgid "Boards|Retrieving blocking %{issuableType}s"
msgstr "" msgstr ""
...@@ -5336,6 +5342,9 @@ msgstr "" ...@@ -5336,6 +5342,9 @@ msgstr ""
msgid "Board|Enter board name" msgid "Board|Enter board name"
msgstr "" msgstr ""
msgid "Board|Failed to create epic. Please try again."
msgstr ""
msgid "Board|Failed to delete board. Please try again." msgid "Board|Failed to delete board. Please try again."
msgstr "" msgstr ""
......
/* global List */ import { createLocalVue, shallowMount } from '@vue/test-utils';
/* global ListIssue */ import Vuex from 'vuex';
import MockAdapter from 'axios-mock-adapter';
import Sortable from 'sortablejs';
import Vue from 'vue';
import BoardList from '~/boards/components/board_list_deprecated.vue';
import '~/boards/models/issue';
import '~/boards/models/list';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
import { listObj, boardsMockInterceptor } from './mock_data';
window.Sortable = Sortable; import BoardCard from '~/boards/components/board_card.vue';
import BoardList from '~/boards/components/board_list.vue';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import defaultState from '~/boards/stores/state';
import { mockList, mockIssuesByListId, issues } from './mock_data';
export default function createComponent({ export default function createComponent({
done,
listIssueProps = {}, listIssueProps = {},
componentProps = {}, componentProps = {},
listProps = {}, listProps = {},
}) { actions = {},
const el = document.createElement('div'); getters = {},
provide = {},
state = defaultState,
stubs = {
BoardNewIssue,
BoardCard,
},
} = {}) {
const localVue = createLocalVue();
localVue.use(Vuex);
document.body.appendChild(el); const store = new Vuex.Store({
const mock = new MockAdapter(axios); state: {
mock.onAny().reply(boardsMockInterceptor); boardItemsByListId: mockIssuesByListId,
boardsStore.create(); boardItems: issues,
pageInfoByListId: {
'gid://gitlab/List/1': { hasNextPage: true },
'gid://gitlab/List/2': {},
},
listsFlags: {
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
selectedBoardItems: [],
...state,
},
getters: {
isGroupBoard: () => false,
isProjectBoard: () => true,
isEpicBoard: () => false,
...getters,
},
actions,
});
const BoardListComp = Vue.extend(BoardList); const list = {
const list = new List({ ...listObj, ...listProps }); ...mockList,
const issue = new ListIssue({ ...listProps,
};
const issue = {
title: 'Testing', title: 'Testing',
id: 1, id: 1,
iid: 1, iid: 1,
...@@ -36,31 +59,31 @@ export default function createComponent({ ...@@ -36,31 +59,31 @@ export default function createComponent({
labels: [], labels: [],
assignees: [], assignees: [],
...listIssueProps, ...listIssueProps,
}); };
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
list.issuesSize = 1; list.issuesCount = 1;
} }
list.issues.push(issue);
const component = new BoardListComp({ const component = shallowMount(BoardList, {
el, localVue,
store, store,
propsData: { propsData: {
disabled: false, disabled: false,
list, list,
issues: list.issues, boardItems: [issue],
loading: false, canAdminList: true,
...componentProps, ...componentProps,
}, },
provide: { provide: {
groupId: null, groupId: null,
rootPath: '/', rootPath: '/',
weightFeatureAvailable: false,
boardWeight: null,
canAdminList: true,
...provide,
}, },
}).$mount(); stubs,
Vue.nextTick(() => {
done();
}); });
return { component, mock }; return component;
} }
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import createComponent from 'jest/boards/board_list_helper';
import BoardCard from '~/boards/components/board_card.vue'; import BoardCard from '~/boards/components/board_card.vue';
import BoardList from '~/boards/components/board_list.vue';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import defaultState from '~/boards/stores/state';
import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
const localVue = createLocalVue(); import { mockIssues } from './mock_data';
localVue.use(Vuex);
const actions = {
fetchItemsForList: jest.fn(),
};
const createStore = (state = defaultState) => {
return new Vuex.Store({
state,
actions,
getters: {
isGroupBoard: () => false,
isProjectBoard: () => true,
isEpicBoard: () => false,
},
});
};
const createComponent = ({
listIssueProps = {},
componentProps = {},
listProps = {},
state = {},
} = {}) => {
const store = createStore({
boardItemsByListId: mockIssuesByListId,
boardItems: issues,
pageInfoByListId: {
'gid://gitlab/List/1': { hasNextPage: true },
'gid://gitlab/List/2': {},
},
listsFlags: {
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
selectedBoardItems: [],
...state,
});
const list = {
...mockList,
...listProps,
};
const issue = {
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
assignees: [],
...listIssueProps,
};
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
list.issuesCount = 1;
}
const component = shallowMount(BoardList, {
localVue,
propsData: {
disabled: false,
list,
boardItems: [issue],
canAdminList: true,
...componentProps,
},
store,
provide: {
groupId: null,
rootPath: '/',
weightFeatureAvailable: false,
boardWeight: null,
canAdminList: true,
},
stubs: {
BoardCard,
BoardNewIssue,
},
});
return component;
};
describe('Board list component', () => { describe('Board list component', () => {
let wrapper; let wrapper;
...@@ -101,7 +15,6 @@ describe('Board list component', () => { ...@@ -101,7 +15,6 @@ describe('Board list component', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('When Expanded', () => { describe('When Expanded', () => {
...@@ -176,6 +89,10 @@ describe('Board list component', () => { ...@@ -176,6 +89,10 @@ describe('Board list component', () => {
}); });
describe('load more issues', () => { describe('load more issues', () => {
const actions = {
fetchItemsForList: jest.fn(),
};
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
listProps: { issuesCount: 25 }, listProps: { issuesCount: 25 },
...@@ -184,6 +101,7 @@ describe('Board list component', () => { ...@@ -184,6 +101,7 @@ describe('Board list component', () => {
it('does not load issues if already loading', () => { it('does not load issues if already loading', () => {
wrapper = createComponent({ wrapper = createComponent({
actions,
state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
}); });
wrapper.vm.listRef.dispatchEvent(new Event('scroll')); wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
......
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