Commit b5a490db authored by Florie Guibert's avatar Florie Guibert

Swimlanes - Drag & Drop issue between lists

VueX action and GraphQL mutation to move issue between lists
parent 8ef94193
import { sortBy } from 'lodash';
import ListIssue from 'ee_else_ce/boards/models/issue';
import { ListType } from './constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export function getMilestone() {
return null;
}
export function formatIssue(issue) {
return new ListIssue({
...issue,
labels: issue.labels?.nodes || [],
assignees: issue.assignees?.nodes || [],
});
}
export function formatListIssues(listIssues) {
const issues = {};
const listData = listIssues.nodes.reduce((map, list) => {
const sortedIssues = sortBy(list.issues.nodes, 'relativePosition');
return {
...map,
[list.id]: list.issues.nodes.map(i => {
[list.id]: sortedIssues.map(i => {
const id = getIdFromGraphQLId(i.id);
const listIssue = new ListIssue({
......@@ -35,7 +46,27 @@ export function fullBoardId(boardId) {
return `gid://gitlab/Board/${boardId}`;
}
export function moveIssueListHelper(issue, fromList, toList) {
if (toList.type === ListType.label) {
issue.addLabel(toList.label);
}
if (fromList && fromList.type === ListType.label) {
issue.removeLabel(fromList.label);
}
if (toList.type === ListType.assignee) {
issue.addAssignee(toList.assignee);
}
if (fromList && fromList.type === ListType.assignee) {
issue.removeAssignee(fromList.assignee);
}
return issue;
}
export default {
getMilestone,
formatIssue,
formatListIssues,
fullBoardId,
};
......@@ -95,6 +95,8 @@ export default {
}"
:index="index"
:data-issue-id="issue.id"
:data-issue-iid="issue.iid"
:data-issue-path="issue.referencePath"
data-testid="board_card"
class="board-card p-3 rounded"
@mousedown="mouseDown"
......
......@@ -15,7 +15,7 @@ class ListIssue {
this.labels = [];
this.assignees = [];
this.selected = false;
this.position = obj.position || obj.relative_position || Infinity;
this.position = obj.position || obj.relative_position || obj.relativePosition || Infinity;
this.isFetching = {
subscriptions: true,
};
......
......@@ -12,6 +12,7 @@ fragment IssueNode on Issue {
webUrl
subscribed
blocked
relativePosition
epic {
id
}
......
#import "./issue.fragment.graphql"
mutation IssueMoveList(
$projectPath: ID!
$iid: String!
$boardId: ID!
$fromListId: ID
$toListId: ID
$moveBeforeId: ID
$moveAfterId: ID
) {
issueMoveList(
input: {
projectPath: $projectPath
iid: $iid
boardId: $boardId
fromListId: $fromListId
toListId: $toListId
moveBeforeId: $moveBeforeId
moveAfterId: $moveAfterId
}
) {
issue {
...IssueNode
}
errors
}
}
......@@ -15,6 +15,7 @@ import projectBoardQuery from '../queries/project_board.query.graphql';
import groupBoardQuery from '../queries/group_board.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -227,8 +228,42 @@ export default {
.catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE));
},
moveIssue: () => {
notImplemented();
moveIssue: (
{ state, commit },
{ issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
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 });
const { boardId } = state.endpoints;
const [groupPath, project] = issuePath.split(/[/#]/);
gqlClient
.mutate({
mutation: issueMoveListMutation,
variables: {
projectPath: `${groupPath}/${project}`,
boardId: fullBoardId(boardId),
iid: issueIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
moveAfterId,
},
})
.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 }),
);
},
createNewIssue: () => {
......
......@@ -18,9 +18,9 @@ export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LIST
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR';
export const REQUEST_MOVE_ISSUE = 'REQUEST_MOVE_ISSUE';
export const RECEIVE_MOVE_ISSUE_SUCCESS = 'RECEIVE_MOVE_ISSUE_SUCCESS';
export const RECEIVE_MOVE_ISSUE_ERROR = 'RECEIVE_MOVE_ISSUE_ERROR';
export const MOVE_ISSUE = 'MOVE_ISSUE';
export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE';
export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE';
export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS';
export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR';
......
import Vue from 'vue';
import { sortBy, pull } from 'lodash';
import { formatIssue, moveIssueListHelper } from '../boards_util';
import * as mutationTypes from './mutation_types';
import { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -12,6 +14,18 @@ const removeIssueFromList = (state, listId, issueId) => {
Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
};
const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
const listIssues = state.issuesByListId[listId];
let newIndex = atIndex || 0;
if (moveBeforeId) {
newIndex = listIssues.indexOf(moveBeforeId) + 1;
} else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId);
}
listIssues.splice(newIndex, 0, issueId);
Vue.set(state.issuesByListId, listId, listIssues);
};
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
const { boardType, disabled, showPromotion, ...endpoints } = data;
......@@ -111,16 +125,38 @@ export default {
notImplemented();
},
[mutationTypes.REQUEST_MOVE_ISSUE]: () => {
notImplemented();
[mutationTypes.MOVE_ISSUE]: (
state,
{ originalIssue, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
const fromList = state.boardLists.find(l => l.id === fromListId);
const toList = state.boardLists.find(l => l.id === toListId);
const issue = moveIssueListHelper(originalIssue, fromList, toList);
Vue.set(state.issues, issue.id, issue);
removeIssueFromList(state, fromListId, issue.id);
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
},
[mutationTypes.RECEIVE_MOVE_ISSUE_SUCCESS]: () => {
notImplemented();
[mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
const issueId = getIdFromGraphQLId(issue.id);
Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId }));
},
[mutationTypes.RECEIVE_MOVE_ISSUE_ERROR]: () => {
notImplemented();
[mutationTypes.MOVE_ISSUE_FAILURE]: (
state,
{ originalIssue, fromListId, toListId, originalIndex },
) => {
state.error = __('An error occurred while moving the issue. Please try again.');
Vue.set(state.issues, originalIssue.id, originalIssue);
removeIssueFromList(state, toListId, originalIssue.id);
addIssueToList({
state,
listId: fromListId,
issueId: originalIssue.id,
atIndex: originalIndex,
});
},
[mutationTypes.REQUEST_UPDATE_ISSUE]: () => {
......
......@@ -41,6 +41,11 @@ export default {
type: String,
required: true,
},
canAdminList: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -155,6 +160,7 @@ export default {
:root-path="rootPath"
:epic-id="epic.id"
:epic-is-confidential="epic.confidential"
:can-admin-list="canAdminList"
/>
</div>
</div>
......
......@@ -139,6 +139,7 @@ export default {
:is-loading-issues="isLoadingIssues"
:disabled="disabled"
:root-path="rootPath"
:can-admin-list="canAdminList"
/>
<div class="board-lane-unassigned-issues-title gl-sticky gl-display-inline-block gl-left-0">
<div class="gl-left-0 gl-py-5 gl-px-3 gl-display-flex gl-align-items-center">
......@@ -171,6 +172,7 @@ export default {
:is-loading="isLoadingIssues"
:disabled="disabled"
:root-path="rootPath"
:can-admin-list="canAdminList"
/>
</div>
</div>
......
<script>
import Draggable from 'vuedraggable';
import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import defaultSortableConfig from '~/sortable/sortable_config';
import BoardCardLayout from '~/boards/components/board_card_layout.vue';
import eventHub from '~/boards/eventhub';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
......@@ -45,6 +47,11 @@ export default {
type: String,
required: true,
},
canAdminList: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -53,6 +60,22 @@ export default {
},
computed: {
...mapState(['activeId']),
treeRootWrapper() {
return this.canAdminList ? Draggable : 'ul';
},
treeRootOptions() {
const options = {
...defaultSortableConfig,
fallbackOnBody: false,
group: 'board-epics-swimlanes',
tag: 'ul',
'ghost-class': 'board-card-drag-active',
'data-list-id': this.list.id,
value: this.issues,
};
return this.canAdminList ? options : {};
},
},
created() {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
......@@ -61,7 +84,7 @@ export default {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
},
methods: {
...mapActions(['setActiveId']),
...mapActions(['setActiveId', 'moveIssue']),
toggleForm() {
this.showIssueForm = !this.showIssueForm;
if (this.showIssueForm && this.isUnassignedIssuesLane) {
......@@ -74,6 +97,46 @@ export default {
showIssue(issue) {
this.setActiveId({ id: issue.id, sidebarType: ISSUABLE });
},
handleDragOnEnd(params) {
const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
// If issue is being moved within the same list
if (from === to) {
if (newIndex > oldIndex) {
// If issue is being moved down we look for the issue that ends up before
moveBeforeId = Number(children[newIndex].dataset.issueId);
} else if (newIndex < oldIndex) {
// If issue is being moved up we look for the issue that ends up after
moveAfterId = Number(children[newIndex].dataset.issueId);
} else {
// If issue remains in the same list at the same position we do nothing
return;
}
} else {
// We look for the issue that ends up before the moved issue if it exists
if (children[newIndex - 1]) {
moveBeforeId = Number(children[newIndex - 1].dataset.issueId);
}
// We look for the issue that ends up after the moved issue if it exists
if (children[newIndex]) {
moveAfterId = Number(children[newIndex].dataset.issueId);
}
}
this.moveIssue({
issueId,
issueIid,
issuePath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
moveAfterId,
});
},
},
};
</script>
......@@ -90,7 +153,13 @@ export default {
:group-id="groupId"
:list="list"
/>
<ul v-if="list.isExpanded" class="gl-p-2 gl-m-0">
<component
:is="treeRootWrapper"
v-if="list.isExpanded"
v-bind="treeRootOptions"
class="gl-p-2 gl-m-0"
@end="handleDragOnEnd"
>
<board-card-layout
v-for="(issue, index) in issues"
ref="issue"
......@@ -102,7 +171,7 @@ export default {
:is-active="isActiveIssue(issue)"
@show="showIssue(issue)"
/>
</ul>
</component>
</div>
</div>
</template>
......@@ -2828,6 +2828,9 @@ msgstr ""
msgid "An error occurred while moving the issue."
msgstr ""
msgid "An error occurred while moving the issue. Please try again."
msgstr ""
msgid "An error occurred while parsing recent searches"
msgstr ""
......
/* global ListIssue */
/* global List */
import Vue from 'vue';
import List from '~/boards/models/list';
import '~/boards/models/list';
import '~/boards/models/issue';
import boardsStore from '~/boards/stores/boards_store';
export const boardObj = {
......@@ -94,11 +98,40 @@ export const mockMilestone = {
due_date: '2019-12-31',
};
export const rawIssue = {
title: 'Testing',
id: 'gid://gitlab/Issue/1',
iid: 1,
confidential: false,
referencePath: 'gitlab-org/gitlab-test#1',
labels: {
nodes: [
{
id: 1,
title: 'test',
color: 'red',
description: 'testing',
},
],
},
assignees: {
nodes: [
{
id: 1,
name: 'name',
username: 'username',
avatar_url: 'http://avatar_url',
},
],
},
};
export const mockIssue = {
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
referencePath: 'gitlab-org/gitlab-test#1',
labels: [
{
id: 1,
......@@ -117,11 +150,14 @@ export const mockIssue = {
],
};
export const mockIssueWithModel = new ListIssue(mockIssue);
export const mockIssue2 = {
title: 'Planning',
id: 2,
iid: 2,
confidential: false,
referencePath: 'gitlab-org/gitlab-test#2',
labels: [
{
id: 1,
......@@ -140,6 +176,8 @@ export const mockIssue2 = {
],
};
export const mockIssue2WithModel = new ListIssue(mockIssue2);
export const BoardsMockData = {
GET: {
'/test/-/boards/1/lists/300/issues?id=300&page=1': {
......
import testAction from 'helpers/vuex_action_helper';
import { mockListsWithModel, mockLists, mockIssue } from '../mock_data';
import {
mockListsWithModel,
mockLists,
mockIssue,
mockIssue2,
mockIssueWithModel,
mockIssue2WithModel,
rawIssue,
} from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import { inactiveId, ListType } from '~/boards/constants';
......@@ -229,7 +237,107 @@ describe('fetchIssuesForList', () => {
});
describe('moveIssue', () => {
expectNotImplemented(actions.moveIssue);
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
'gid://gitlab/List/2': [],
};
const issues = {
'1': mockIssueWithModel,
'2': 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: mockIssue.id,
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
state,
[
{
type: types.MOVE_ISSUE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
},
{
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: mockIssue.id,
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
state,
[
{
type: types.MOVE_ISSUE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
},
{
type: types.MOVE_ISSUE_FAILURE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
originalIndex: 0,
},
},
],
[],
done,
);
});
});
describe('createNewIssue', () => {
......
......@@ -4,10 +4,13 @@ import defaultState from '~/boards/stores/state';
import {
listObj,
listObjDuplicate,
mockIssue,
mockIssue2,
mockListsWithModel,
mockLists,
rawIssue,
mockIssue,
mockIssue2,
mockIssueWithModel,
mockIssue2WithModel,
} from '../mock_data';
const expectNotImplemented = action => {
......@@ -247,16 +250,86 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR);
});
describe('REQUEST_MOVE_ISSUE', () => {
expectNotImplemented(mutations.REQUEST_MOVE_ISSUE);
describe('MOVE_ISSUE', () => {
it('updates issuesByListId, moving issue between lists', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
'gid://gitlab/List/2': [],
};
const issues = {
'1': mockIssueWithModel,
'2': mockIssue2WithModel,
};
state = {
...state,
issuesByListId: listIssues,
boardLists: mockListsWithModel,
issues,
};
mutations.MOVE_ISSUE(state, {
originalIssue: mockIssue2WithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
});
const updatedListIssues = {
'gid://gitlab/List/1': [mockIssue.id],
'gid://gitlab/List/2': [mockIssue2.id],
};
expect(state.issuesByListId).toEqual(updatedListIssues);
});
});
describe('MOVE_ISSUE_SUCCESS', () => {
it('updates issue in issues state', () => {
const issues = {
'1': { id: rawIssue.id },
};
state = {
...state,
issues,
};
mutations.MOVE_ISSUE_SUCCESS(state, {
issue: rawIssue,
});
describe('RECEIVE_MOVE_ISSUE_SUCCESS', () => {
expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_SUCCESS);
expect(state.issues).toEqual({ '1': { ...mockIssueWithModel, id: 1 } });
});
});
describe('RECEIVE_MOVE_ISSUE_ERROR', () => {
expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_ERROR);
describe('MOVE_ISSUE_FAILURE', () => {
it('updates issuesByListId, reverting moving issue between lists, and sets error message', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id],
'gid://gitlab/List/2': [mockIssue2.id],
};
state = {
...state,
issuesByListId: listIssues,
};
mutations.MOVE_ISSUE_FAILURE(state, {
originalIssue: mockIssue2,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
originalIndex: 1,
});
const updatedListIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
'gid://gitlab/List/2': [],
};
expect(state.issuesByListId).toEqual(updatedListIssues);
expect(state.error).toEqual('An error occurred while moving the issue. Please try again.');
});
});
describe('REQUEST_UPDATE_ISSUE', () => {
......
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