Commit dde1e598 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '233441-epic-boards-drag-drop-epic-between-lists' into 'master'

Epic Boards - Drag & Drop epic between lists

See merge request gitlab-org/gitlab!56170
parents 3b110e04 fbe226dc
......@@ -113,31 +113,31 @@ export function formatIssueInput(issueInput, boardConfig) {
};
}
export function moveIssueListHelper(issue, fromList, toList) {
const updatedIssue = issue;
export function moveItemListHelper(item, fromList, toList) {
const updatedItem = item;
if (
toList.listType === ListType.label &&
!updatedIssue.labels.find((label) => label.id === toList.label.id)
!updatedItem.labels.find((label) => label.id === toList.label.id)
) {
updatedIssue.labels.push(toList.label);
updatedItem.labels.push(toList.label);
}
if (fromList?.label && fromList.listType === ListType.label) {
updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id);
updatedItem.labels = updatedItem.labels.filter((label) => fromList.label.id !== label.id);
}
if (
toList.listType === ListType.assignee &&
!updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id)
!updatedItem.assignees.find((assignee) => assignee.id === toList.assignee.id)
) {
updatedIssue.assignees.push(toList.assignee);
updatedItem.assignees.push(toList.assignee);
}
if (fromList?.assignee && fromList.listType === ListType.assignee) {
updatedIssue.assignees = updatedIssue.assignees.filter(
updatedItem.assignees = updatedItem.assignees.filter(
(assignee) => assignee.id !== fromList.assignee.id,
);
}
return updatedIssue;
return updatedItem;
}
export function isListDraggable(list) {
......
......@@ -13,7 +13,7 @@ export default {
default: () => ({}),
required: false,
},
issue: {
item: {
type: Object,
default: () => ({}),
required: false,
......@@ -33,12 +33,12 @@ export default {
...mapState(['selectedBoardItems', 'activeId']),
...mapGetters(['isSwimlanesOn']),
isActive() {
return this.issue.id === this.activeId;
return this.item.id === this.activeId;
},
multiSelectVisible() {
return (
!this.activeId &&
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1
);
},
},
......@@ -50,9 +50,9 @@ export default {
const isMultiSelect = e.ctrlKey || e.metaKey;
if (isMultiSelect) {
this.toggleBoardItemMultiSelection(this.issue);
this.toggleBoardItemMultiSelection(this.item);
} else {
this.toggleBoardItem({ boardItem: this.issue });
this.toggleBoardItem({ boardItem: this.item });
}
},
},
......@@ -64,18 +64,18 @@ export default {
data-qa-selector="board_card"
:class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'user-can-drag': !disabled && item.id,
'is-disabled': disabled || !item.id,
'is-active': isActive,
}"
:index="index"
:data-issue-id="issue.id"
:data-issue-iid="issue.iid"
:data-issue-path="issue.referencePath"
:data-item-id="item.id"
:data-item-iid="item.iid"
:data-item-path="item.referencePath"
data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base"
@mouseup="toggleIssue($event)"
>
<board-card-inner :list="list" :item="issue" :update-filters="true" />
<board-card-inner :list="list" :item="item" :update-filters="true" />
</li>
</template>
......@@ -49,7 +49,7 @@ export default {
: this.lists;
},
canDragColumns() {
return this.glFeatures.graphqlBoardLists && this.canAdminList;
return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList;
},
boardColumnWrapper() {
return this.canDragColumns ? Draggable : 'div';
......@@ -80,6 +80,7 @@ export default {
handleDragOnEnd(params) {
sortableEnd();
if (this.isEpicBoard) return;
const { item, newIndex, oldIndex, to } = params;
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapActions, mapGetters, mapState } from 'vuex';
import { mapActions, mapState } from 'vuex';
import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
......@@ -49,7 +49,6 @@ export default {
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
...mapGetters(['isEpicBoard']),
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.boardItems.length,
......@@ -70,13 +69,13 @@ export default {
},
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList && !this.isEpicBoard ? this.$refs.list.$el : this.$refs.list;
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
},
showingAllIssues() {
return this.boardItems.length === this.list.issuesCount;
},
treeRootWrapper() {
return this.canAdminList && !this.isEpicBoard ? Draggable : 'ul';
return this.canAdminList ? Draggable : 'ul';
},
treeRootOptions() {
const options = {
......@@ -113,7 +112,7 @@ export default {
this.listRef.removeEventListener('scroll', this.onScroll);
},
methods: {
...mapActions(['fetchItemsForList', 'moveIssue']),
...mapActions(['fetchItemsForList', 'moveItem']),
listHeight() {
return this.listRef.getBoundingClientRect().height;
},
......@@ -149,40 +148,40 @@ export default {
handleDragOnEnd(params) {
sortableEnd();
const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset;
const { itemId, itemIid, itemPath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
const getIssueId = (el) => Number(el.dataset.issueId);
const getItemId = (el) => Number(el.dataset.itemId);
// If issue is being moved within the same list
// If item is being moved within the same list
if (from === to) {
if (newIndex > oldIndex && children.length > 1) {
// If issue is being moved down we look for the issue that ends up before
moveBeforeId = getIssueId(children[newIndex]);
// If item is being moved down we look for the item that ends up before
moveBeforeId = getItemId(children[newIndex]);
} else if (newIndex < oldIndex && children.length > 1) {
// If issue is being moved up we look for the issue that ends up after
moveAfterId = getIssueId(children[newIndex]);
// If item is being moved up we look for the item that ends up after
moveAfterId = getItemId(children[newIndex]);
} else {
// If issue remains in the same list at the same position we do nothing
// If item 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
// We look for the item that ends up before the moved item if it exists
if (children[newIndex - 1]) {
moveBeforeId = getIssueId(children[newIndex - 1]);
moveBeforeId = getItemId(children[newIndex - 1]);
}
// We look for the issue that ends up after the moved issue if it exists
// We look for the item that ends up after the moved item if it exists
if (children[newIndex]) {
moveAfterId = getIssueId(children[newIndex]);
moveAfterId = getItemId(children[newIndex]);
}
}
this.moveIssue({
issueId,
issueIid,
issuePath,
this.moveItem({
itemId,
itemIid,
itemPath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
......@@ -227,7 +226,7 @@ export default {
:key="item.id"
:index="index"
:list="list"
:issue="item"
:item="item"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
......
......@@ -325,17 +325,21 @@ export default {
commit(types.RESET_ISSUES);
},
moveItem: ({ dispatch }) => {
dispatch('moveIssue');
},
moveIssue: (
{ state, commit },
{ issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId },
{ itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
const originalIssue = state.boardItems[issueId];
const originalIssue = state.boardItems[itemId];
const fromList = state.boardItemsByListId[fromListId];
const originalIndex = fromList.indexOf(Number(issueId));
const originalIndex = fromList.indexOf(Number(itemId));
commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
const { boardId } = state;
const [fullProjectPath] = issuePath.split(/[#]/);
const [fullProjectPath] = itemPath.split(/[#]/);
gqlClient
.mutate({
......@@ -343,7 +347,7 @@ export default {
variables: {
projectPath: fullProjectPath,
boardId: fullBoardId(boardId),
iid: issueIid,
iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
......@@ -352,7 +356,7 @@ export default {
})
.then(({ data }) => {
if (data?.issueMoveList?.errors.length) {
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex });
throw new Error();
} else {
const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue });
......
......@@ -2,7 +2,8 @@ import { pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import { formatIssue, moveIssueListHelper } from '../boards_util';
import { formatIssue, moveItemListHelper } from '../boards_util';
import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
const notImplemented = () => {
......@@ -10,13 +11,21 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
export const removeIssueFromList = ({ state, listId, issueId }) => {
Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], issueId));
const updateListItemsCount = ({ state, listId, value }) => {
const list = state.boardLists[listId];
Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 });
if (state.issuableType === issuableTypes.epic) {
Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
} else {
Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value });
}
};
export const removeItemFromList = ({ state, listId, itemId }) => {
Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId));
updateListItemsCount({ state, listId, value: -1 });
};
export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => {
const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
if (moveBeforeId) {
......@@ -24,10 +33,9 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter
} else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId);
}
listIssues.splice(newIndex, 0, issueId);
listIssues.splice(newIndex, 0, itemId);
Vue.set(state.boardItemsByListId, listId, listIssues);
const list = state.boardLists[listId];
Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 });
updateListItemsCount({ state, listId, value: 1 });
};
export default {
......@@ -182,11 +190,11 @@ export default {
const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId];
const issue = moveIssueListHelper(originalIssue, fromList, toList);
const issue = moveItemListHelper(originalIssue, fromList, toList);
Vue.set(state.boardItems, issue.id, issue);
removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
removeItemFromList({ state, listId: fromListId, itemId: issue.id });
addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
},
[mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
......@@ -200,11 +208,11 @@ export default {
) => {
state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
Vue.set(state.boardItems, originalIssue.id, originalIssue);
removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id });
addIssueToList({
removeItemFromList({ state, listId: toListId, itemId: originalIssue.id });
addItemToList({
state,
listId: fromListId,
issueId: originalIssue.id,
itemId: originalIssue.id,
atIndex: originalIndex,
});
},
......@@ -226,10 +234,10 @@ export default {
},
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
addIssueToList({
addItemToList({
state,
listId: list.id,
issueId: issue.id,
itemId: issue.id,
atIndex: position,
});
Vue.set(state.boardItems, issue.id, issue);
......@@ -237,11 +245,11 @@ export default {
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
removeIssueFromList({ state, listId: list.id, issueId });
removeItemFromList({ state, listId: list.id, itemId: issueId });
},
[mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
removeIssueFromList({ state, listId: list.id, issueId: issue.id });
removeItemFromList({ state, listId: list.id, itemId: issue.id });
Vue.delete(state.boardItems, issue.id);
},
......
......@@ -118,7 +118,7 @@ export default {
handleDragOnEnd(params) {
document.body.classList.remove('is-dragging');
const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset;
const { itemId, itemIid, itemPath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
......@@ -127,10 +127,10 @@ export default {
if (from === to) {
if (newIndex > oldIndex && children.length > 1) {
// 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.itemId);
} else if (newIndex < oldIndex && children.length > 1) {
// 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.itemId);
} else {
// If issue remains in the same list at the same position we do nothing
return;
......@@ -138,18 +138,18 @@ export default {
} 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);
moveBeforeId = Number(children[newIndex - 1].dataset.itemId);
}
// We look for the issue that ends up after the moved issue if it exists
if (children[newIndex]) {
moveAfterId = Number(children[newIndex].dataset.issueId);
moveAfterId = Number(children[newIndex].dataset.itemId);
}
}
this.moveIssue({
issueId,
issueIid,
issuePath,
itemId,
itemIid,
itemPath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
......@@ -187,7 +187,7 @@ export default {
:key="issue.id"
:index="index"
:list="list"
:issue="issue"
:item="issue"
:disabled="disabled || !canAdminEpic"
/>
<gl-loading-icon v-if="isLoadingMore && isUnassignedIssuesLane" size="sm" class="gl-py-3" />
......
mutation EpicMoveList(
$epicId: EpicID!
$boardId: BoardsEpicBoardID!
$fromListId: BoardsEpicListID!
$toListId: BoardsEpicListID!
) {
epicMoveList(
input: { epicId: $epicId, boardId: $boardId, fromListId: $fromListId, toListId: $toListId }
) {
errors
}
}
......@@ -32,6 +32,7 @@ import { EpicFilterType, IterationFilterType, GroupByParamType } from '../consta
import epicQuery from '../graphql/epic.query.graphql';
import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql';
import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql';
import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
......@@ -484,13 +485,21 @@ export default {
});
},
moveItem: ({ getters, dispatch }, params) => {
if (!getters.isEpicBoard) {
dispatch('moveIssue', params);
} else {
dispatch('moveEpic', params);
}
},
moveIssue: (
{ state, commit },
{ issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId, epicId },
{ itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId, epicId },
) => {
const originalIssue = state.boardItems[issueId];
const originalIssue = state.boardItems[itemId];
const fromList = state.boardItemsByListId[fromListId];
const originalIndex = fromList.indexOf(Number(issueId));
const originalIndex = fromList.indexOf(Number(itemId));
commit(types.MOVE_ISSUE, {
originalIssue,
fromListId,
......@@ -501,7 +510,7 @@ export default {
});
const { boardId } = state;
const [fullProjectPath] = issuePath.split(/[#]/);
const [fullProjectPath] = itemPath.split(/[#]/);
gqlClient
.mutate({
......@@ -509,7 +518,7 @@ export default {
variables: {
projectPath: fullProjectPath,
boardId: fullBoardId(boardId),
iid: issueIid,
iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
......@@ -519,7 +528,7 @@ export default {
})
.then(({ data }) => {
if (data?.issueMoveList?.errors.length) {
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex });
throw new Error();
} else {
const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue });
......@@ -530,6 +539,40 @@ export default {
);
},
moveEpic: ({ state, commit }, { itemId, fromListId, toListId, moveBeforeId, moveAfterId }) => {
const originalEpic = state.boardItems[itemId];
const fromList = state.boardItemsByListId[fromListId];
const originalIndex = fromList.indexOf(Number(itemId));
commit(types.MOVE_EPIC, {
originalEpic,
fromListId,
toListId,
moveBeforeId,
moveAfterId,
});
const { boardId } = state;
gqlClient
.mutate({
mutation: epicMoveListMutation,
variables: {
epicId: fullEpicId(itemId),
boardId: fullEpicBoardId(boardId),
fromListId,
toListId,
},
})
.then(({ data }) => {
if (data?.epicMoveList?.errors.length) {
throw new Error();
}
})
.catch(() =>
commit(types.MOVE_EPIC_FAILURE, { originalEpic, fromListId, toListId, originalIndex }),
);
},
fetchLists: ({ getters, dispatch }) => {
if (!getters.isEpicBoard) {
dispatch('fetchIssueLists');
......
......@@ -31,6 +31,8 @@ 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';
export const MOVE_EPIC = 'MOVE_EPIC';
export const MOVE_EPIC_FAILURE = 'MOVE_EPIC_FAILURE';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES';
export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST';
......
import { union, unionBy } from 'lodash';
import Vue from 'vue';
import { moveIssueListHelper } from '~/boards/boards_util';
import { moveItemListHelper } from '~/boards/boards_util';
import { issuableTypes } from '~/boards/constants';
import mutationsCE, { addIssueToList, removeIssueFromList } from '~/boards/stores/mutations';
import mutationsCE, { addItemToList, removeItemFromList } from '~/boards/stores/mutations';
import { s__, __ } from '~/locale';
import { ErrorMessages } from '../constants';
import * as mutationTypes from './mutation_types';
......@@ -168,7 +168,7 @@ export default {
const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId];
const issue = moveIssueListHelper(originalIssue, fromList, toList);
const issue = moveItemListHelper(originalIssue, fromList, toList);
if (epicId === null) {
Vue.set(state.boardItems, issue.id, { ...issue, epic: null });
......@@ -176,8 +176,37 @@ export default {
Vue.set(state.boardItems, issue.id, { ...issue, epic: { id: epicId } });
}
removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
removeItemFromList({ state, listId: fromListId, itemId: issue.id });
addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
},
[mutationTypes.MOVE_EPIC]: (
state,
{ originalEpic, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId];
const epic = moveItemListHelper(originalEpic, fromList, toList);
Vue.set(state.boardItems, epic.id, epic);
removeItemFromList({ state, listId: fromListId, itemId: epic.id });
addItemToList({ state, listId: toListId, itemId: epic.id, moveBeforeId, moveAfterId });
},
[mutationTypes.MOVE_EPIC_FAILURE]: (
state,
{ originalEpic, fromListId, toListId, originalIndex },
) => {
state.error = s__('Boards|An error occurred while moving the epic. Please try again.');
Vue.set(state.boardItems, originalEpic.id, originalEpic);
removeItemFromList({ state, listId: toListId, itemId: originalEpic.id });
addItemToList({
state,
listId: fromListId,
itemId: originalEpic.id,
atIndex: originalIndex,
});
},
[mutationTypes.SET_BOARD_EPIC_USER_PREFERENCES]: (state, val) => {
......
......@@ -3,6 +3,9 @@
require 'spec_helper'
RSpec.describe 'epic boards', :js do
include DragTo
include MobileHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
......@@ -63,7 +66,7 @@ RSpec.describe 'epic boards', :js do
end
end
it 'creates new column for label containing labeled issue' do
it 'creates new column for label containing labeled epic' do
click_button 'Create list'
wait_for_all_requests
......@@ -77,6 +80,16 @@ RSpec.describe 'epic boards', :js do
expect(page).to have_selector('.board', text: label2.title)
expect(find('.board:nth-child(3) .board-card')).to have_content(epic3.title)
end
it 'moves epic between lists' do
expect(find('.board:nth-child(1)')).to have_content(epic3.title)
drag(list_from_index: 0, list_to_index: 1)
wait_for_all_requests
expect(find('.board:nth-child(1)')).not_to have_content(epic3.title)
expect(find('.board:nth-child(2)')).to have_content(epic3.title)
end
end
context 'when user can admin epic boards' do
......@@ -113,4 +126,17 @@ RSpec.describe 'epic boards', :js do
def list_header(list)
find(".board[data-id='gid://gitlab/Boards::EpicList/#{list.id}'] .board-header")
end
def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0, perform_drop: true)
# ensure there is enough horizontal space for four lists
resize_window(2000, 800)
drag_to(selector: selector,
scrollable: '#board-app',
list_from_index: list_from_index,
from_index: from_index,
to_index: to_index,
list_to_index: list_to_index,
perform_drop: perform_drop)
end
end
......@@ -216,6 +216,7 @@ export const mockEpic = {
closedIssues: 2,
},
issues: [mockIssue],
labels: [],
};
export const mockIssueWithEpic = { ...mockIssue3, epic: { id: mockEpic.id, iid: mockEpic.iid } };
......@@ -238,6 +239,7 @@ export const mockEpics = [
parent: {
id: '40',
},
labels: [],
},
{
id: 'gid://gitlab/Epic/40',
......@@ -252,6 +254,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/marketing/-/epics/1',
descendantCounts: defaultDescendantCounts,
hasParent: false,
labels: [],
},
{
id: 'gid://gitlab/Epic/39',
......@@ -266,6 +269,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/-/epics/12',
descendantCounts: defaultDescendantCounts,
hasParent: false,
labels: [],
},
{
id: 'gid://gitlab/Epic/38',
......@@ -280,6 +284,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/-/epics/11',
descendantCounts: defaultDescendantCounts,
hasParent: false,
labels: [],
},
{
id: 'gid://gitlab/Epic/37',
......@@ -294,6 +299,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/-/epics/10',
descendantCounts: defaultDescendantCounts,
hasParent: false,
labels: [],
},
];
......
......@@ -14,7 +14,15 @@ import { issuableTypes } from '~/boards/constants';
import * as typesCE from '~/boards/stores/mutation_types';
import * as commonUtils from '~/lib/utils/common_utils';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import { mockLists, mockIssue, mockIssue2, mockEpic, rawIssue, mockMilestones } from '../mock_data';
import {
mockLists,
mockIssue,
mockIssue2,
mockEpic,
mockEpics,
rawIssue,
mockMilestones,
} from '../mock_data';
Vue.use(Vuex);
......@@ -846,6 +854,21 @@ describe('setActiveIssueWeight', () => {
});
});
describe.each`
isEpicBoard | issuableType | dispatchedAction
${false} | ${'issuableTypes.issue'} | ${'moveIssue'}
${true} | ${'issuableTypes.epic'} | ${'moveEpic'}
`('moveItem', ({ isEpicBoard, issuableType, dispatchedAction }) => {
it(`should dispatch ${dispatchedAction} action when isEpicBoard is ${isEpicBoard}`, async () => {
await testAction({
action: actions.moveItem,
payload: { itemId: 1 },
state: { isEpicBoard, issuableType },
expectedActions: [{ type: dispatchedAction, payload: { itemId: 1 } }],
});
});
});
describe('moveIssue', () => {
const epicId = 'gid://gitlab/Epic/1';
......@@ -882,9 +905,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: '436',
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
......@@ -923,9 +946,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: '436',
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
......@@ -955,113 +978,213 @@ describe('moveIssue', () => {
done,
);
});
});
describe.each`
isEpicBoard | issuableType | dispatchedAction
${false} | ${'issuableTypes.issue'} | ${'createIssueList'}
${true} | ${'issuableTypes.epic'} | ${'createEpicList'}
`('createList', ({ isEpicBoard, issuableType, dispatchedAction }) => {
it(`should dispatch ${dispatchedAction} action when isEpicBoard is ${isEpicBoard}`, async () => {
await testAction({
action: actions.createList,
payload: { backlog: true },
state: { isEpicBoard, issuableType },
expectedActions: [{ type: dispatchedAction, payload: { backlog: true } }],
});
});
});
describe('moveEpic', () => {
const listEpics = {
'gid://gitlab/List/1': [41, 40],
'gid://gitlab/List/2': [],
};
describe('createEpicList', () => {
let commit;
let dispatch;
let getters;
beforeEach(() => {
commit = jest.fn();
dispatch = jest.fn();
getters = {
getListByLabelId: jest.fn(),
};
const epics = {
41: mockEpic,
40: mockEpics[1],
};
const state = {
fullPath: 'gitlab-org',
boardId: 1,
boardType: 'group',
disabled: false,
boardLists: mockLists,
boardItemsByListId: listEpics,
boardItems: epics,
issuableType: 'epic',
};
it('should commit MOVE_EPIC mutation mutation when successful', async () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicMoveList: {
errors: [],
},
},
});
it('should dispatch addList action when creating backlog list', async () => {
const backlogList = {
id: 'gid://gitlab/List/1',
listType: 'backlog',
title: 'Open',
position: 0,
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list: backlogList,
errors: [],
await testAction({
action: actions.moveEpic,
payload: {
itemId: '41',
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
state,
expectedMutations: [
{
type: types.MOVE_EPIC,
payload: {
originalEpic: mockEpic,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
},
});
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
],
});
});
expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
it('should commit MOVE_EPIC mutation and MOVE_EPIC_FAILURE mutation when unsuccessful', async () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicMoveList: {
errors: [{ foo: 'bar' }],
},
},
});
it('dispatches highlightList after addList has succeeded', async () => {
const list = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Open',
labelId: '4',
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list,
errors: [],
await testAction({
action: actions.moveEpic,
payload: {
itemId: '41',
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
state,
expectedMutations: [
{
type: types.MOVE_EPIC,
payload: {
originalEpic: mockEpic,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
},
});
{
type: types.MOVE_EPIC_FAILURE,
payload: {
originalEpic: mockEpic,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
originalIndex: 0,
},
},
],
});
});
});
describe.each`
isEpicBoard | issuableType | dispatchedAction
${false} | ${'issuableTypes.issue'} | ${'createIssueList'}
${true} | ${'issuableTypes.epic'} | ${'createEpicList'}
`('createList', ({ isEpicBoard, issuableType, dispatchedAction }) => {
it(`should dispatch ${dispatchedAction} action when isEpicBoard is ${isEpicBoard}`, async () => {
await testAction({
action: actions.createList,
payload: { backlog: true },
state: { isEpicBoard, issuableType },
expectedActions: [{ type: dispatchedAction, payload: { backlog: true } }],
});
});
});
await actions.createEpicList({ getters, state, commit, dispatch }, { labelId: '4' });
describe('createEpicList', () => {
let commit;
let dispatch;
let getters;
const state = {
fullPath: 'gitlab-org',
boardId: 1,
boardType: 'group',
disabled: false,
boardLists: mockLists,
};
beforeEach(() => {
commit = jest.fn();
dispatch = jest.fn();
getters = {
getListByLabelId: jest.fn(),
};
});
it('should dispatch addList action when creating backlog list', async () => {
const backlogList = {
id: 'gid://gitlab/List/1',
listType: 'backlog',
title: 'Open',
position: 0,
};
expect(dispatch).toHaveBeenCalledWith('addList', list);
expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list: backlogList,
errors: [],
},
},
});
it('should commit CREATE_LIST_FAILURE mutation when API returns an error', async () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list: {},
errors: ['foo'],
},
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
});
it('dispatches highlightList after addList has succeeded', async () => {
const list = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Open',
labelId: '4',
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list,
errors: [],
},
});
},
});
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
await actions.createEpicList({ getters, state, commit, dispatch }, { labelId: '4' });
expect(dispatch).toHaveBeenCalledWith('addList', list);
expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
});
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo');
it('should commit CREATE_LIST_FAILURE mutation when API returns an error', async () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list: {},
errors: ['foo'],
},
},
});
it('highlights list and does not re-query if it already exists', async () => {
const existingList = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Some label',
position: 1,
};
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
getters = {
getListByLabelId: jest.fn().mockReturnValue(existingList),
};
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo');
});
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
it('highlights list and does not re-query if it already exists', async () => {
const existingList = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Some label',
position: 1,
};
expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(commit).not.toHaveBeenCalled();
});
getters = {
getListByLabelId: jest.fn().mockReturnValue(existingList),
};
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(commit).not.toHaveBeenCalled();
});
});
......
......@@ -327,6 +327,40 @@ describe('MOVE_ISSUE', () => {
});
});
describe('MOVE_EPIC', () => {
it('updates boardItemsByListId, moving epic between lists', () => {
const listIssues = {
'gid://gitlab/List/1': [mockEpic.id, mockEpics[1].id],
'gid://gitlab/List/2': [],
};
const epics = {
1: mockEpic,
2: mockEpics[1],
};
state = {
...state,
boardItemsByListId: listIssues,
boardLists: initialBoardListsState,
boardItems: epics,
};
mutations.MOVE_EPIC(state, {
originalEpic: mockEpics[1],
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
});
const updatedListEpics = {
'gid://gitlab/List/1': [mockEpic.id],
'gid://gitlab/List/2': [mockEpics[1].id],
};
expect(state.boardItemsByListId).toEqual(updatedListEpics);
});
});
describe('SET_BOARD_EPIC_USER_PREFERENCES', () => {
it('should replace userPreferences on the given epic', () => {
state = {
......
......@@ -4872,6 +4872,9 @@ msgstr ""
msgid "Boards|An error occurred while generating lists. Please reload the page."
msgstr ""
msgid "Boards|An error occurred while moving the epic. Please try again."
msgstr ""
msgid "Boards|An error occurred while moving the issue. Please try again."
msgstr ""
......
......@@ -125,7 +125,7 @@ describe('Board list component', () => {
});
it('sets data attribute with issue id', () => {
expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1');
});
it('shows new issue form', async () => {
......@@ -258,7 +258,7 @@ describe('Board list component', () => {
describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => {
jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {});
document.body.classList.add('is-dragging');
findByTestId('tree-root-wrapper').vm.$emit('end', {
......@@ -266,9 +266,9 @@ describe('Board list component', () => {
newIndex: 0,
item: {
dataset: {
issueId: mockIssues[0].id,
issueIid: mockIssues[0].iid,
issuePath: mockIssues[0].referencePath,
itemId: mockIssues[0].id,
itemIid: mockIssues[0].iid,
itemPath: mockIssues[0].referencePath,
},
},
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
......
......@@ -6,7 +6,7 @@ import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { inactiveId } from '~/boards/constants';
import { mockLabelList, mockIssue } from '../mock_data';
describe('Board card layout', () => {
describe('Board card', () => {
let wrapper;
let store;
let mockActions;
......@@ -44,7 +44,7 @@ describe('Board card layout', () => {
store,
propsData: {
list: mockLabelList,
issue: mockIssue,
item: mockIssue,
disabled: false,
index: 0,
...propsData,
......@@ -113,7 +113,7 @@ describe('Board card layout', () => {
expect(wrapper.classes()).not.toContain('is-active');
});
describe('when mouseup event is called on the issue card', () => {
describe('when mouseup event is called on the card', () => {
beforeEach(() => {
createStore({ isSwimlanesOn });
mountComponent();
......
......@@ -637,6 +637,15 @@ describe('resetIssues', () => {
});
});
describe('moveItem', () => {
it('should dispatch moveIssue action', () => {
testAction({
action: actions.moveItem,
expectedActions: [{ type: 'moveIssue' }],
});
});
});
describe('moveIssue', () => {
const listIssues = {
'gid://gitlab/List/1': [436, 437],
......@@ -671,9 +680,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: '436',
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
......@@ -722,9 +731,9 @@ describe('moveIssue', () => {
actions.moveIssue(
{ state, commit: () => {} },
{
issueId: mockIssue.id,
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: mockIssue.id,
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
......@@ -746,9 +755,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: '436',
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
......
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