Commit 23dc9dff authored by David O'Regan's avatar David O'Regan

Merge branch '233568-create-epic-from-boards' into 'master'

Add ability to create epic from boards

See merge request gitlab-org/gitlab!63999
parents 974d2a9f ecc70809
......@@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
import Tracking from '~/tracking';
import { toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue';
......@@ -21,6 +22,7 @@ export default {
components: {
BoardCard,
BoardNewIssue,
BoardNewEpic: () => import('ee_component/boards/components/board_new_epic.vue'),
GlLoadingIcon,
GlIntersectionObserver,
},
......@@ -49,6 +51,7 @@ export default {
scrollOffset: 250,
showCount: false,
showIssueForm: false,
showEpicForm: false,
};
},
computed: {
......@@ -64,6 +67,9 @@ export default {
issuableType: this.isEpicBoard ? 'epics' : 'issues',
});
},
toggleFormEventPrefix() {
return this.isEpicBoard ? toggleFormEventPrefix.epic : toggleFormEventPrefix.issue;
},
boardItemsSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount;
},
......@@ -76,6 +82,12 @@ export default {
loadingMore() {
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() {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
......@@ -116,9 +128,10 @@ export default {
'list.id': {
handler(id, oldVal) {
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.$off(`toggle-issue-form-${oldVal}`, this.toggleForm);
eventHub.$off(`${this.toggleFormEventPrefix}${oldVal}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop);
}
},
......@@ -126,7 +139,7 @@ export default {
},
},
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);
},
methods: {
......@@ -147,7 +160,11 @@ export default {
this.fetchItemsForList({ listId: this.list.id, fetchNext: true });
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
if (this.isEpicBoard) {
this.showEpicForm = !this.showEpicForm;
} else {
this.showIssueForm = !this.showIssueForm;
}
},
onReachingListBottom() {
if (!this.loadingMore && this.hasNextPage) {
......@@ -227,7 +244,8 @@ export default {
>
<gl-loading-icon />
</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
:is="treeRootWrapper"
v-show="!loading"
......
......@@ -16,13 +16,14 @@ import { n__, s__, __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import ItemCount from './item_count.vue';
export default {
i18n: {
newIssue: __('New issue'),
newEpic: s__('Boards|New epic'),
listSettings: __('List settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
......@@ -102,7 +103,7 @@ export default {
},
showListHeaderActions() {
if (this.isLoggedIn) {
return this.isNewIssueShown || this.isSettingsShown;
return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown;
}
return false;
},
......@@ -124,6 +125,9 @@ export default {
isNewIssueShown() {
return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
},
isNewEpicShown() {
return this.isEpicBoard && this.listType !== ListType.closed;
},
isSettingsShown() {
return (
this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
......@@ -165,7 +169,10 @@ export default {
},
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() {
const collapsed = !this.list.collapsed;
......@@ -379,6 +386,17 @@ export default {
@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
v-if="isSettingsShown"
ref="settingsBtn"
......
......@@ -4,13 +4,13 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue';
import { __ } from '~/locale';
import { toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
export default {
name: 'BoardNewIssue',
i18n: {
submit: __('Create issue'),
cancel: __('Cancel'),
},
components: {
......@@ -32,7 +32,15 @@ export default {
},
computed: {
...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() {
if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name;
......@@ -50,9 +58,7 @@ export default {
},
methods: {
...mapActions(['addListNewIssue']),
submit(e) {
e.preventDefault();
submit() {
const { title } = this;
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
......@@ -76,7 +82,7 @@ export default {
},
reset() {
this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
},
},
};
......@@ -85,7 +91,7 @@ export default {
<template>
<div class="board-new-issue-form">
<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>
<input
:id="inputFieldId"
......@@ -96,7 +102,7 @@ export default {
name="issue_title"
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">
<gl-button
ref="submitButton"
......@@ -106,7 +112,7 @@ export default {
category="primary"
type="submit"
>
{{ $options.i18n.submit }}
{{ submitButtonTitle }}
</gl-button>
<gl-button
ref="cancelButton"
......
......@@ -45,6 +45,11 @@ export const formType = {
edit: 'edit',
};
export const toggleFormEventPrefix = {
epic: 'toggle-epic-form-',
issue: 'toggle-issue-form-',
};
export const inactiveId = 0;
export const ISSUABLE = 'issuable';
......
......@@ -37,6 +37,18 @@ export function calculateSwimlanesBufferSize(listTopCoordinate) {
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) {
const boardItems = {};
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
import actionsCE, { gqlClient } from '~/boards/stores/actions';
import boardsStore from '~/boards/stores/boards_store';
import * as typesCE from '~/boards/stores/mutation_types';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import {
historyPushState,
......@@ -20,9 +21,11 @@ import {
urlParamsToObject,
} from '~/lib/utils/common_utils';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import {
fullEpicId,
fullEpicBoardId,
formatEpic,
formatListEpics,
formatEpicListsPageInfo,
FiltersInfo,
......@@ -30,6 +33,7 @@ import {
import { EpicFilterType, GroupByParamType, FilterFields } from '../constants';
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 epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
......@@ -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) => {
if (!getters.isEpicBoard) {
dispatch('setActiveIssueLabels', params);
......
......@@ -7,6 +7,7 @@ fragment EpicNode on Epic {
title
state
reference
referencePath: reference(full: true)
webPath
webUrl
createdAt
......
import {
formatEpic,
formatListEpics,
formatEpicListsPageInfo,
transformBoardConfig,
......@@ -7,6 +8,33 @@ import { mockLabel } from './mock_data';
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', () => {
it('formats raw response from list epics for state', () => {
const rawEpicsInLists = {
......
import { GlButton, GlButtonGroup } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
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 { ListType, inactiveId } from '~/boards/constants';
import boardsEventHub from '~/boards/eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
const localVue = createLocalVue();
......@@ -15,18 +17,6 @@ describe('Board List Header Component', () => {
let store;
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 = ({
listType = ListType.backlog,
collapsed = false,
......@@ -34,6 +24,8 @@ describe('Board List Header Component', () => {
isSwimlanesHeader = false,
weightFeatureAvailable = false,
currentUserId = 1,
state = { activeId: inactiveId },
getters = {},
} = {}) => {
const boardId = '1';
......@@ -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, {
store,
localVue,
......@@ -73,6 +75,43 @@ describe('Board List Header Component', () => {
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', () => {
const hasSettings = [ListType.assignee, ListType.milestone, ListType.iteration, ListType.label];
const hasNoSettings = [ListType.backlog, ListType.closed];
......@@ -111,8 +150,12 @@ describe('Board List Header Component', () => {
});
it('does not emit event when there is an active List', () => {
store.state.activeId = mockLabelList.id;
createComponent({ listType: hasSettings[0] });
createComponent({
listType: hasSettings[0],
state: {
activeId: mockLabelList.id,
},
});
wrapper.vm.openSidebarSettings();
expect(sidebarEventHub.$emit).not.toHaveBeenCalled();
......
import BoardNewEpic from 'ee/boards/components/board_new_epic.vue';
import createComponent from 'jest/boards/board_list_helper';
describe('BoardList Component', () => {
let mock;
let component;
beforeEach((done) => {
const listIssueProps = {
project: {
path: '/test',
},
real_path: '',
webUrl: '',
};
import BoardCard from '~/boards/components/board_card.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import createFlash from '~/flash';
const componentProps = {
groupId: undefined,
};
jest.mock('~/flash');
const listIssueProps = {
project: {
path: '/test',
},
real_path: '',
webUrl: '',
};
const componentProps = {
groupId: undefined,
};
const actions = {
addListNewEpic: jest.fn().mockResolvedValue(),
};
({ mock, component } = createComponent({
done,
componentProps,
listIssueProps,
}));
const componentConfig = {
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(() => {
mock.restore();
wrapper.destroy();
});
it('renders link properly in issue', () => {
expect(
component.$el.querySelector('.board-card .board-card-title a').getAttribute('href'),
).not.toContain(':project_path');
expect(wrapper.find('.board-card .board-card-title a').attributes('href')).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';
import Vue from 'vue';
import Vuex from 'vuex';
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 boardsStoreEE from 'ee/boards/stores/boards_store_ee';
import * as types from 'ee/boards/stores/mutation_types';
......@@ -13,6 +14,7 @@ import { mockMoveIssueParams, mockMoveData, mockMoveState } from 'jest/boards/mo
import { formatListIssues } from '~/boards/boards_util';
import listsIssuesQuery from '~/boards/graphql/lists_issues.query.graphql';
import * as typesCE from '~/boards/stores/mutation_types';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import * as commonUtils from '~/lib/utils/common_utils';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import {
......@@ -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', () => {
const queryResponse = {
data: {
......
......@@ -5247,6 +5247,9 @@ msgid_plural "Boards|+ %{displayedIssuablesCount} more %{issuableType}s"
msgstr[0] ""
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."
msgstr ""
......@@ -5306,6 +5309,9 @@ msgstr ""
msgid "Boards|Failed to fetch blocking %{issuableType}s"
msgstr ""
msgid "Boards|New epic"
msgstr ""
msgid "Boards|Retrieving blocking %{issuableType}s"
msgstr ""
......@@ -5336,6 +5342,9 @@ msgstr ""
msgid "Board|Enter board name"
msgstr ""
msgid "Board|Failed to create epic. Please try again."
msgstr ""
msgid "Board|Failed to delete board. Please try again."
msgstr ""
......
/* global List */
/* global ListIssue */
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';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
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({
done,
listIssueProps = {},
componentProps = {},
listProps = {},
}) {
const el = document.createElement('div');
actions = {},
getters = {},
provide = {},
state = defaultState,
stubs = {
BoardNewIssue,
BoardCard,
},
} = {}) {
const localVue = createLocalVue();
localVue.use(Vuex);
document.body.appendChild(el);
const mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
boardsStore.create();
const store = new Vuex.Store({
state: {
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,
},
getters: {
isGroupBoard: () => false,
isProjectBoard: () => true,
isEpicBoard: () => false,
...getters,
},
actions,
});
const BoardListComp = Vue.extend(BoardList);
const list = new List({ ...listObj, ...listProps });
const issue = new ListIssue({
const list = {
...mockList,
...listProps,
};
const issue = {
title: 'Testing',
id: 1,
iid: 1,
......@@ -36,31 +59,31 @@ export default function createComponent({
labels: [],
assignees: [],
...listIssueProps,
});
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
list.issuesSize = 1;
};
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
list.issuesCount = 1;
}
list.issues.push(issue);
const component = new BoardListComp({
el,
const component = shallowMount(BoardList, {
localVue,
store,
propsData: {
disabled: false,
list,
issues: list.issues,
loading: false,
boardItems: [issue],
canAdminList: true,
...componentProps,
},
provide: {
groupId: null,
rootPath: '/',
weightFeatureAvailable: false,
boardWeight: null,
canAdminList: true,
...provide,
},
}).$mount();
Vue.nextTick(() => {
done();
stubs,
});
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 createComponent from 'jest/boards/board_list_helper';
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 defaultState from '~/boards/stores/state';
import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
const localVue = createLocalVue();
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;
};
import { mockIssues } from './mock_data';
describe('Board list component', () => {
let wrapper;
......@@ -101,7 +15,6 @@ describe('Board list component', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('When Expanded', () => {
......@@ -176,6 +89,10 @@ describe('Board list component', () => {
});
describe('load more issues', () => {
const actions = {
fetchItemsForList: jest.fn(),
};
beforeEach(() => {
wrapper = createComponent({
listProps: { issuesCount: 25 },
......@@ -184,6 +101,7 @@ describe('Board list component', () => {
it('does not load issues if already loading', () => {
wrapper = createComponent({
actions,
state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
});
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