Commit 511ff0a8 authored by Florie Guibert's avatar Florie Guibert

Swimlanes - Move issue between epics

Add ability to move issue between lanes
parent d9cacdc2
...@@ -10,11 +10,19 @@ const notImplemented = () => { ...@@ -10,11 +10,19 @@ const notImplemented = () => {
throw new Error('Not implemented!'); throw new Error('Not implemented!');
}; };
const removeIssueFromList = (state, listId, issueId) => { const getListById = ({ state, listId }) => {
const listIndex = state.boardLists.findIndex(l => l.id === listId);
const list = state.boardLists[listIndex];
return { listIndex, list };
};
export const removeIssueFromList = ({ state, listId, issueId }) => {
Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
const { listIndex, list } = getListById({ state, listId });
Vue.set(state.boardLists, listIndex, { ...list, issuesSize: list.issuesSize - 1 });
}; };
const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
const listIssues = state.issuesByListId[listId]; const listIssues = state.issuesByListId[listId];
let newIndex = atIndex || 0; let newIndex = atIndex || 0;
if (moveBeforeId) { if (moveBeforeId) {
...@@ -24,6 +32,8 @@ const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atI ...@@ -24,6 +32,8 @@ const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atI
} }
listIssues.splice(newIndex, 0, issueId); listIssues.splice(newIndex, 0, issueId);
Vue.set(state.issuesByListId, listId, listIssues); Vue.set(state.issuesByListId, listId, listIssues);
const { listIndex, list } = getListById({ state, listId });
Vue.set(state.boardLists, listIndex, { ...list, issuesSize: list.issuesSize + 1 });
}; };
export default { export default {
...@@ -142,7 +152,7 @@ export default { ...@@ -142,7 +152,7 @@ export default {
const issue = moveIssueListHelper(originalIssue, fromList, toList); const issue = moveIssueListHelper(originalIssue, fromList, toList);
Vue.set(state.issues, issue.id, issue); Vue.set(state.issues, issue.id, issue);
removeIssueFromList(state, fromListId, issue.id); removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
}, },
...@@ -157,7 +167,7 @@ export default { ...@@ -157,7 +167,7 @@ export default {
) => { ) => {
state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
Vue.set(state.issues, originalIssue.id, originalIssue); Vue.set(state.issues, originalIssue.id, originalIssue);
removeIssueFromList(state, toListId, originalIssue.id); removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id });
addIssueToList({ addIssueToList({
state, state,
listId: fromListId, listId: fromListId,
...@@ -187,7 +197,7 @@ export default { ...@@ -187,7 +197,7 @@ export default {
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => { [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
removeIssueFromList(state, list.id, issue.id); removeIssueFromList({ state, listId: list.id, issueId: issue.id });
}, },
[mutationTypes.SET_CURRENT_PAGE]: () => { [mutationTypes.SET_CURRENT_PAGE]: () => {
......
...@@ -146,7 +146,7 @@ export default { ...@@ -146,7 +146,7 @@ export default {
<gl-loading-icon v-if="isLoading" class="gl-p-2" /> <gl-loading-icon v-if="isLoading" class="gl-p-2" />
</div> </div>
</div> </div>
<div v-if="isExpanded" class="gl-display-flex"> <div v-if="isExpanded" class="gl-display-flex" data-testid="board-epic-lane-issues">
<issues-lane-list <issues-lane-list
v-for="list in lists" v-for="list in lists"
:key="`${list.id}-issues`" :key="`${list.id}-issues`"
......
...@@ -38,6 +38,11 @@ export default { ...@@ -38,6 +38,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
epicId: {
type: String,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
...@@ -56,6 +61,7 @@ export default { ...@@ -56,6 +61,7 @@ export default {
group: 'board-epics-swimlanes', group: 'board-epics-swimlanes',
tag: 'ul', tag: 'ul',
'ghost-class': 'board-card-drag-active', 'ghost-class': 'board-card-drag-active',
'data-epic-id': this.epicId,
'data-list-id': this.list.id, 'data-list-id': this.list.id,
value: this.issues, value: this.issues,
}; };
...@@ -81,7 +87,7 @@ export default { ...@@ -81,7 +87,7 @@ export default {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
}, },
methods: { methods: {
...mapActions(['setActiveId', 'moveIssue', 'fetchIssuesForList']), ...mapActions(['setActiveId', 'moveIssue', 'moveIssueEpic', 'fetchIssuesForList']),
toggleForm() { toggleForm() {
this.showIssueForm = !this.showIssueForm; this.showIssueForm = !this.showIssueForm;
if (this.showIssueForm && this.isUnassignedIssuesLane) { if (this.showIssueForm && this.isUnassignedIssuesLane) {
...@@ -103,10 +109,10 @@ export default { ...@@ -103,10 +109,10 @@ export default {
// If issue is being moved within the same list // If issue is being moved within the same list
if (from === to) { if (from === to) {
if (newIndex > oldIndex) { if (newIndex > oldIndex && children.length > 1) {
// If issue is being moved down we look for the issue that ends up before // If issue is being moved down we look for the issue that ends up before
moveBeforeId = Number(children[newIndex].dataset.issueId); moveBeforeId = Number(children[newIndex].dataset.issueId);
} else if (newIndex < oldIndex) { } else if (newIndex < oldIndex && children.length > 1) {
// If issue is being moved up we look for the issue that ends up after // If issue is being moved up we look for the issue that ends up after
moveAfterId = Number(children[newIndex].dataset.issueId); moveAfterId = Number(children[newIndex].dataset.issueId);
} else { } else {
...@@ -132,6 +138,7 @@ export default { ...@@ -132,6 +138,7 @@ export default {
toListId: to.dataset.listId, toListId: to.dataset.listId,
moveBeforeId, moveBeforeId,
moveAfterId, moveAfterId,
epicId: from.dataset.epicId !== to.dataset.epicId ? to.dataset.epicId || null : undefined,
}); });
}, },
}, },
...@@ -152,7 +159,7 @@ export default { ...@@ -152,7 +159,7 @@ export default {
:is="treeRootWrapper" :is="treeRootWrapper"
v-if="list.isExpanded" v-if="list.isExpanded"
v-bind="treeRootOptions" v-bind="treeRootOptions"
class="gl-p-2 gl-m-0" class="board-cell gl-p-2 gl-m-0 gl-h-full"
@end="handleDragOnEnd" @end="handleDragOnEnd"
> >
<board-card-layout <board-card-layout
......
#import "ee_else_ce/boards/queries/issue.fragment.graphql"
mutation IssueMoveList(
$projectPath: ID!
$iid: String!
$boardId: ID!
$fromListId: ID
$toListId: ID
$moveBeforeId: ID
$moveAfterId: ID
$epicId: EpicID
) {
issueMoveList(
input: {
projectPath: $projectPath
iid: $iid
boardId: $boardId
fromListId: $fromListId
toListId: $toListId
moveBeforeId: $moveBeforeId
moveAfterId: $moveAfterId
epicId: $epicId
}
) {
issue {
...IssueNode
}
errors
}
}
...@@ -11,12 +11,14 @@ import boardsStoreEE from './boards_store_ee'; ...@@ -11,12 +11,14 @@ import boardsStoreEE from './boards_store_ee';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { fullEpicId } from '../boards_util'; import { fullEpicId } from '../boards_util';
import { formatListIssues, fullBoardId } from '~/boards/boards_util'; import { formatListIssues, fullBoardId } from '~/boards/boards_util';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql';
import issueSetEpic from '../queries/issue_set_epic.mutation.graphql'; import issueSetEpic from '../queries/issue_set_epic.mutation.graphql';
import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql'; import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
const notImplemented = () => { const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */ /* eslint-disable-next-line @gitlab/require-i18n-strings */
...@@ -254,4 +256,50 @@ export default { ...@@ -254,4 +256,50 @@ export default {
return data.issueSetEpic.issue.epic; return data.issueSetEpic.issue.epic;
}, },
moveIssue: (
{ state, commit },
{ issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId, epicId },
) => {
const originalIssue = state.issues[issueId];
const fromList = state.issuesByListId[fromListId];
const originalIndex = fromList.indexOf(Number(issueId));
commit(types.MOVE_ISSUE, {
originalIssue,
fromListId,
toListId,
moveBeforeId,
moveAfterId,
epicId,
});
const { boardId } = state.endpoints;
const [fullProjectPath] = issuePath.split(/[#]/);
gqlClient
.mutate({
mutation: issueMoveListMutation,
variables: {
projectPath: fullProjectPath,
boardId: fullBoardId(boardId),
iid: issueIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
moveAfterId,
epicId,
},
})
.then(({ data }) => {
if (data?.issueMoveList?.errors.length) {
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex });
} else {
const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue });
}
})
.catch(() =>
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }),
);
},
}; };
...@@ -23,3 +23,6 @@ export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS'; ...@@ -23,3 +23,6 @@ export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const RESET_EPICS = 'RESET_EPICS'; export const RESET_EPICS = 'RESET_EPICS';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS'; export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
export const SET_FILTERS = 'SET_FILTERS'; export const SET_FILTERS = 'SET_FILTERS';
export const MOVE_ISSUE = 'MOVE_ISSUE';
export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE';
import Vue from 'vue'; import Vue from 'vue';
import { union } from 'lodash'; import { union } from 'lodash';
import mutationsCE from '~/boards/stores/mutations'; import mutationsCE, { addIssueToList, removeIssueFromList } from '~/boards/stores/mutations';
import { moveIssueListHelper } from '~/boards/boards_util';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import * as mutationTypes from './mutation_types'; import * as mutationTypes from './mutation_types';
...@@ -109,4 +110,23 @@ export default { ...@@ -109,4 +110,23 @@ export default {
[mutationTypes.RESET_EPICS]: state => { [mutationTypes.RESET_EPICS]: state => {
Vue.set(state, 'epics', []); Vue.set(state, 'epics', []);
}, },
[mutationTypes.MOVE_ISSUE]: (
state,
{ originalIssue, fromListId, toListId, moveBeforeId, moveAfterId, epicId },
) => {
const fromList = state.boardLists.find(l => l.id === fromListId);
const toList = state.boardLists.find(l => l.id === toListId);
const issue = moveIssueListHelper(originalIssue, fromList, toList);
if (epicId === null) {
Vue.set(state.issues, issue.id, { ...issue, epic: null });
} else if (epicId !== undefined) {
Vue.set(state.issues, issue.id, { ...issue, epic: { id: epicId } });
}
removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
},
}; };
/* global ListIssue */
/* global List */
import Vue from 'vue'; import Vue from 'vue';
import List from '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/issue';
export const mockLists = [ export const mockLists = [
{ {
...@@ -62,6 +66,34 @@ const labels = [ ...@@ -62,6 +66,34 @@ const labels = [
}, },
]; ];
export const rawIssue = {
title: 'Issue 1',
id: 'gid://gitlab/Issue/436',
iid: 27,
dueDate: null,
timeEstimate: 0,
weight: null,
confidential: false,
referencePath: 'gitlab-org/test-subgroup/gitlab-test#27',
path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27',
labels: {
nodes: [
{
id: 1,
title: 'test',
color: 'red',
description: 'testing',
},
],
},
assignees: {
nodes: assignees,
},
epic: {
id: 'gid://gitlab/Epic/41',
},
};
export const mockIssue = { export const mockIssue = {
id: 'gid://gitlab/Issue/436', id: 'gid://gitlab/Issue/436',
iid: 27, iid: 27,
...@@ -79,6 +111,8 @@ export const mockIssue = { ...@@ -79,6 +111,8 @@ export const mockIssue = {
}, },
}; };
export const mockIssueWithModel = new ListIssue({ ...mockIssue, id: '436' });
export const mockIssue2 = { export const mockIssue2 = {
id: 'gid://gitlab/Issue/437', id: 'gid://gitlab/Issue/437',
iid: 28, iid: 28,
...@@ -96,6 +130,8 @@ export const mockIssue2 = { ...@@ -96,6 +130,8 @@ export const mockIssue2 = {
}, },
}; };
export const mockIssue2WithModel = new ListIssue({ ...mockIssue2, id: '437' });
export const mockIssue3 = { export const mockIssue3 = {
id: 'gid://gitlab/Issue/438', id: 'gid://gitlab/Issue/438',
iid: 29, iid: 29,
......
...@@ -6,7 +6,15 @@ import * as types from 'ee/boards/stores/mutation_types'; ...@@ -6,7 +6,15 @@ import * as types from 'ee/boards/stores/mutation_types';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import { formatListIssues } from '~/boards/boards_util'; import { formatListIssues } from '~/boards/boards_util';
import { mockLists, mockIssue, mockEpic } from '../mock_data'; import {
mockLists,
mockIssue,
mockEpic,
rawIssue,
mockIssueWithModel,
mockIssue2WithModel,
mockListsWithModel,
} from '../mock_data';
const expectNotImplemented = action => { const expectNotImplemented = action => {
it('is not implemented', () => { it('is not implemented', () => {
...@@ -381,3 +389,113 @@ describe('setActiveIssueEpic', () => { ...@@ -381,3 +389,113 @@ describe('setActiveIssueEpic', () => {
await expect(actions.setActiveIssueEpic({ getters }, input)).rejects.toThrow(Error); await expect(actions.setActiveIssueEpic({ getters }, input)).rejects.toThrow(Error);
}); });
}); });
describe('moveIssue', () => {
const epicId = 'gid://gitlab/Epic/1';
const listIssues = {
'gid://gitlab/List/1': [436, 437],
'gid://gitlab/List/2': [],
};
const issues = {
'436': mockIssueWithModel,
'437': mockIssue2WithModel,
};
const state = {
endpoints: { fullPath: 'gitlab-org', boardId: '1' },
boardType: 'group',
disabled: false,
boardLists: mockListsWithModel,
issuesByListId: listIssues,
issues,
};
it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
issueMoveList: {
issue: rawIssue,
errors: [],
},
},
});
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
state,
[
{
type: types.MOVE_ISSUE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
},
{
type: types.MOVE_ISSUE_SUCCESS,
payload: { issue: rawIssue },
},
],
[],
done,
);
});
it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
issueMoveList: {
issue: {},
errors: [{ foo: 'bar' }],
},
},
});
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
state,
[
{
type: types.MOVE_ISSUE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
},
{
type: types.MOVE_ISSUE_FAILURE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
originalIndex: 0,
},
},
],
[],
done,
);
});
});
...@@ -6,6 +6,8 @@ import { ...@@ -6,6 +6,8 @@ import {
mockEpics, mockEpics,
mockEpic, mockEpic,
mockListsWithModel, mockListsWithModel,
mockIssueWithModel,
mockIssue2WithModel,
} from '../mock_data'; } from '../mock_data';
const expectNotImplemented = action => { const expectNotImplemented = action => {
...@@ -229,3 +231,62 @@ describe('RESET_EPICS', () => { ...@@ -229,3 +231,62 @@ describe('RESET_EPICS', () => {
expect(state.epics).toEqual([]); expect(state.epics).toEqual([]);
}); });
}); });
describe('MOVE_ISSUE', () => {
beforeEach(() => {
const listIssues = {
'gid://gitlab/List/1': [mockListsWithModel.id, mockIssue2WithModel.id],
'gid://gitlab/List/2': [],
};
const issues = {
'436': mockIssueWithModel,
'437': mockIssue2WithModel,
};
state = {
...state,
issuesByListId: listIssues,
boardLists: mockListsWithModel,
issues,
};
});
it('updates issuesByListId, moving issue between lists and updating epic id on issue', () => {
expect(state.issues['437'].epic.id).toEqual('gid://gitlab/Epic/40');
mutations.MOVE_ISSUE(state, {
originalIssue: mockIssue2WithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
});
const updatedListIssues = {
'gid://gitlab/List/1': [mockListsWithModel.id],
'gid://gitlab/List/2': [mockIssue2WithModel.id],
};
expect(state.issuesByListId).toEqual(updatedListIssues);
expect(state.issues['437'].epic.id).toEqual(epicId);
});
it('removes epic id from issue when epicId is null', () => {
expect(state.issues['437'].epic.id).toEqual('gid://gitlab/Epic/40');
mutations.MOVE_ISSUE(state, {
originalIssue: mockIssue2WithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId: null,
});
const updatedListIssues = {
'gid://gitlab/List/1': [mockListsWithModel.id],
'gid://gitlab/List/2': [mockIssue2WithModel.id],
};
expect(state.issuesByListId).toEqual(updatedListIssues);
expect(state.issues['437'].epic).toEqual(null);
});
});
...@@ -322,6 +322,7 @@ describe('Board Store Mutations', () => { ...@@ -322,6 +322,7 @@ describe('Board Store Mutations', () => {
state = { state = {
...state, ...state,
issuesByListId: listIssues, issuesByListId: listIssues,
boardLists: mockListsWithModel,
}; };
mutations.MOVE_ISSUE_FAILURE(state, { mutations.MOVE_ISSUE_FAILURE(state, {
...@@ -389,6 +390,7 @@ describe('Board Store Mutations', () => { ...@@ -389,6 +390,7 @@ describe('Board Store Mutations', () => {
...state, ...state,
issuesByListId: listIssues, issuesByListId: listIssues,
issues, issues,
boardLists: mockListsWithModel,
}; };
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 }); mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 });
......
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