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) { ...@@ -113,31 +113,31 @@ export function formatIssueInput(issueInput, boardConfig) {
}; };
} }
export function moveIssueListHelper(issue, fromList, toList) { export function moveItemListHelper(item, fromList, toList) {
const updatedIssue = issue; const updatedItem = item;
if ( if (
toList.listType === ListType.label && 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) { 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 ( if (
toList.listType === ListType.assignee && 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) { if (fromList?.assignee && fromList.listType === ListType.assignee) {
updatedIssue.assignees = updatedIssue.assignees.filter( updatedItem.assignees = updatedItem.assignees.filter(
(assignee) => assignee.id !== fromList.assignee.id, (assignee) => assignee.id !== fromList.assignee.id,
); );
} }
return updatedIssue; return updatedItem;
} }
export function isListDraggable(list) { export function isListDraggable(list) {
......
...@@ -13,7 +13,7 @@ export default { ...@@ -13,7 +13,7 @@ export default {
default: () => ({}), default: () => ({}),
required: false, required: false,
}, },
issue: { item: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
required: false, required: false,
...@@ -33,12 +33,12 @@ export default { ...@@ -33,12 +33,12 @@ export default {
...mapState(['selectedBoardItems', 'activeId']), ...mapState(['selectedBoardItems', 'activeId']),
...mapGetters(['isSwimlanesOn']), ...mapGetters(['isSwimlanesOn']),
isActive() { isActive() {
return this.issue.id === this.activeId; return this.item.id === this.activeId;
}, },
multiSelectVisible() { multiSelectVisible() {
return ( return (
!this.activeId && !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 { ...@@ -50,9 +50,9 @@ export default {
const isMultiSelect = e.ctrlKey || e.metaKey; const isMultiSelect = e.ctrlKey || e.metaKey;
if (isMultiSelect) { if (isMultiSelect) {
this.toggleBoardItemMultiSelection(this.issue); this.toggleBoardItemMultiSelection(this.item);
} else { } else {
this.toggleBoardItem({ boardItem: this.issue }); this.toggleBoardItem({ boardItem: this.item });
} }
}, },
}, },
...@@ -64,18 +64,18 @@ export default { ...@@ -64,18 +64,18 @@ export default {
data-qa-selector="board_card" data-qa-selector="board_card"
:class="{ :class="{
'multi-select': multiSelectVisible, 'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id, 'user-can-drag': !disabled && item.id,
'is-disabled': disabled || !issue.id, 'is-disabled': disabled || !item.id,
'is-active': isActive, 'is-active': isActive,
}" }"
:index="index" :index="index"
:data-issue-id="issue.id" :data-item-id="item.id"
:data-issue-iid="issue.iid" :data-item-iid="item.iid"
:data-issue-path="issue.referencePath" :data-item-path="item.referencePath"
data-testid="board_card" data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base" class="board-card gl-p-5 gl-rounded-base"
@mouseup="toggleIssue($event)" @mouseup="toggleIssue($event)"
> >
<board-card-inner :list="list" :item="issue" :update-filters="true" /> <board-card-inner :list="list" :item="item" :update-filters="true" />
</li> </li>
</template> </template>
...@@ -49,7 +49,7 @@ export default { ...@@ -49,7 +49,7 @@ export default {
: this.lists; : this.lists;
}, },
canDragColumns() { canDragColumns() {
return this.glFeatures.graphqlBoardLists && this.canAdminList; return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList;
}, },
boardColumnWrapper() { boardColumnWrapper() {
return this.canDragColumns ? Draggable : 'div'; return this.canDragColumns ? Draggable : 'div';
...@@ -80,6 +80,7 @@ export default { ...@@ -80,6 +80,7 @@ export default {
handleDragOnEnd(params) { handleDragOnEnd(params) {
sortableEnd(); sortableEnd();
if (this.isEpicBoard) return;
const { item, newIndex, oldIndex, to } = params; const { item, newIndex, oldIndex, to } = params;
......
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import Draggable from 'vuedraggable'; 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 { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
...@@ -49,7 +49,6 @@ export default { ...@@ -49,7 +49,6 @@ export default {
}, },
computed: { computed: {
...mapState(['pageInfoByListId', 'listsFlags']), ...mapState(['pageInfoByListId', 'listsFlags']),
...mapGetters(['isEpicBoard']),
paginatedIssueText() { paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), { return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.boardItems.length, pageSize: this.boardItems.length,
...@@ -70,13 +69,13 @@ export default { ...@@ -70,13 +69,13 @@ export default {
}, },
listRef() { listRef() {
// When list is draggable, the reference to the list needs to be accessed differently // When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList && !this.isEpicBoard ? this.$refs.list.$el : this.$refs.list; return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
}, },
showingAllIssues() { showingAllIssues() {
return this.boardItems.length === this.list.issuesCount; return this.boardItems.length === this.list.issuesCount;
}, },
treeRootWrapper() { treeRootWrapper() {
return this.canAdminList && !this.isEpicBoard ? Draggable : 'ul'; return this.canAdminList ? Draggable : 'ul';
}, },
treeRootOptions() { treeRootOptions() {
const options = { const options = {
...@@ -113,7 +112,7 @@ export default { ...@@ -113,7 +112,7 @@ export default {
this.listRef.removeEventListener('scroll', this.onScroll); this.listRef.removeEventListener('scroll', this.onScroll);
}, },
methods: { methods: {
...mapActions(['fetchItemsForList', 'moveIssue']), ...mapActions(['fetchItemsForList', 'moveItem']),
listHeight() { listHeight() {
return this.listRef.getBoundingClientRect().height; return this.listRef.getBoundingClientRect().height;
}, },
...@@ -149,40 +148,40 @@ export default { ...@@ -149,40 +148,40 @@ export default {
handleDragOnEnd(params) { handleDragOnEnd(params) {
sortableEnd(); sortableEnd();
const { newIndex, oldIndex, from, to, item } = params; const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset; const { itemId, itemIid, itemPath } = item.dataset;
const { children } = to; const { children } = to;
let moveBeforeId; let moveBeforeId;
let moveAfterId; 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 (from === to) {
if (newIndex > oldIndex && children.length > 1) { if (newIndex > oldIndex && children.length > 1) {
// If issue is being moved down we look for the issue that ends up before // If item is being moved down we look for the item that ends up before
moveBeforeId = getIssueId(children[newIndex]); moveBeforeId = getItemId(children[newIndex]);
} else if (newIndex < oldIndex && children.length > 1) { } else if (newIndex < oldIndex && children.length > 1) {
// If issue is being moved up we look for the issue that ends up after // If item is being moved up we look for the item that ends up after
moveAfterId = getIssueId(children[newIndex]); moveAfterId = getItemId(children[newIndex]);
} else { } 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; return;
} }
} else { } 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]) { 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]) { if (children[newIndex]) {
moveAfterId = getIssueId(children[newIndex]); moveAfterId = getItemId(children[newIndex]);
} }
} }
this.moveIssue({ this.moveItem({
issueId, itemId,
issueIid, itemIid,
issuePath, itemPath,
fromListId: from.dataset.listId, fromListId: from.dataset.listId,
toListId: to.dataset.listId, toListId: to.dataset.listId,
moveBeforeId, moveBeforeId,
...@@ -227,7 +226,7 @@ export default { ...@@ -227,7 +226,7 @@ export default {
:key="item.id" :key="item.id"
:index="index" :index="index"
:list="list" :list="list"
:issue="item" :item="item"
:disabled="disabled" :disabled="disabled"
/> />
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
......
...@@ -325,17 +325,21 @@ export default { ...@@ -325,17 +325,21 @@ export default {
commit(types.RESET_ISSUES); commit(types.RESET_ISSUES);
}, },
moveItem: ({ dispatch }) => {
dispatch('moveIssue');
},
moveIssue: ( moveIssue: (
{ state, commit }, { 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 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 }); commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
const { boardId } = state; const { boardId } = state;
const [fullProjectPath] = issuePath.split(/[#]/); const [fullProjectPath] = itemPath.split(/[#]/);
gqlClient gqlClient
.mutate({ .mutate({
...@@ -343,7 +347,7 @@ export default { ...@@ -343,7 +347,7 @@ export default {
variables: { variables: {
projectPath: fullProjectPath, projectPath: fullProjectPath,
boardId: fullBoardId(boardId), boardId: fullBoardId(boardId),
iid: issueIid, iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId), fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId), toListId: getIdFromGraphQLId(toListId),
moveBeforeId, moveBeforeId,
...@@ -352,7 +356,7 @@ export default { ...@@ -352,7 +356,7 @@ export default {
}) })
.then(({ data }) => { .then(({ data }) => {
if (data?.issueMoveList?.errors.length) { if (data?.issueMoveList?.errors.length) {
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }); throw new Error();
} else { } else {
const issue = data.issueMoveList?.issue; const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue }); commit(types.MOVE_ISSUE_SUCCESS, { issue });
......
...@@ -2,7 +2,8 @@ import { pull, union } from 'lodash'; ...@@ -2,7 +2,8 @@ import { pull, union } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale'; 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'; import * as mutationTypes from './mutation_types';
const notImplemented = () => { const notImplemented = () => {
...@@ -10,13 +11,21 @@ const notImplemented = () => { ...@@ -10,13 +11,21 @@ const notImplemented = () => {
throw new Error('Not implemented!'); throw new Error('Not implemented!');
}; };
export const removeIssueFromList = ({ state, listId, issueId }) => { const updateListItemsCount = ({ state, listId, value }) => {
Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], issueId));
const list = state.boardLists[listId]; 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]; const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0; let newIndex = atIndex || 0;
if (moveBeforeId) { if (moveBeforeId) {
...@@ -24,10 +33,9 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter ...@@ -24,10 +33,9 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter
} else if (moveAfterId) { } else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId); newIndex = listIssues.indexOf(moveAfterId);
} }
listIssues.splice(newIndex, 0, issueId); listIssues.splice(newIndex, 0, itemId);
Vue.set(state.boardItemsByListId, listId, listIssues); Vue.set(state.boardItemsByListId, listId, listIssues);
const list = state.boardLists[listId]; updateListItemsCount({ state, listId, value: 1 });
Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 });
}; };
export default { export default {
...@@ -182,11 +190,11 @@ export default { ...@@ -182,11 +190,11 @@ export default {
const fromList = state.boardLists[fromListId]; const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId]; const toList = state.boardLists[toListId];
const issue = moveIssueListHelper(originalIssue, fromList, toList); const issue = moveItemListHelper(originalIssue, fromList, toList);
Vue.set(state.boardItems, issue.id, issue); Vue.set(state.boardItems, issue.id, issue);
removeIssueFromList({ state, listId: fromListId, issueId: issue.id }); removeItemFromList({ state, listId: fromListId, itemId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
}, },
[mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
...@@ -200,11 +208,11 @@ export default { ...@@ -200,11 +208,11 @@ 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.boardItems, originalIssue.id, originalIssue); Vue.set(state.boardItems, originalIssue.id, originalIssue);
removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id }); removeItemFromList({ state, listId: toListId, itemId: originalIssue.id });
addIssueToList({ addItemToList({
state, state,
listId: fromListId, listId: fromListId,
issueId: originalIssue.id, itemId: originalIssue.id,
atIndex: originalIndex, atIndex: originalIndex,
}); });
}, },
...@@ -226,10 +234,10 @@ export default { ...@@ -226,10 +234,10 @@ export default {
}, },
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => { [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
addIssueToList({ addItemToList({
state, state,
listId: list.id, listId: list.id,
issueId: issue.id, itemId: issue.id,
atIndex: position, atIndex: position,
}); });
Vue.set(state.boardItems, issue.id, issue); Vue.set(state.boardItems, issue.id, issue);
...@@ -237,11 +245,11 @@ export default { ...@@ -237,11 +245,11 @@ export default {
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => { [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
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, listId: list.id, issueId }); removeItemFromList({ state, listId: list.id, itemId: issueId });
}, },
[mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { [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); Vue.delete(state.boardItems, issue.id);
}, },
......
...@@ -118,7 +118,7 @@ export default { ...@@ -118,7 +118,7 @@ export default {
handleDragOnEnd(params) { handleDragOnEnd(params) {
document.body.classList.remove('is-dragging'); document.body.classList.remove('is-dragging');
const { newIndex, oldIndex, from, to, item } = params; const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset; const { itemId, itemIid, itemPath } = item.dataset;
const { children } = to; const { children } = to;
let moveBeforeId; let moveBeforeId;
let moveAfterId; let moveAfterId;
...@@ -127,10 +127,10 @@ export default { ...@@ -127,10 +127,10 @@ export default {
if (from === to) { if (from === to) {
if (newIndex > oldIndex && children.length > 1) { 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.itemId);
} else if (newIndex < oldIndex && children.length > 1) { } 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.itemId);
} else { } else {
// If issue remains in the same list at the same position we do nothing // If issue remains in the same list at the same position we do nothing
return; return;
...@@ -138,18 +138,18 @@ export default { ...@@ -138,18 +138,18 @@ export default {
} else { } else {
// We look for the issue that ends up before the moved issue if it exists // We look for the issue that ends up before the moved issue if it exists
if (children[newIndex - 1]) { 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 // We look for the issue that ends up after the moved issue if it exists
if (children[newIndex]) { if (children[newIndex]) {
moveAfterId = Number(children[newIndex].dataset.issueId); moveAfterId = Number(children[newIndex].dataset.itemId);
} }
} }
this.moveIssue({ this.moveIssue({
issueId, itemId,
issueIid, itemIid,
issuePath, itemPath,
fromListId: from.dataset.listId, fromListId: from.dataset.listId,
toListId: to.dataset.listId, toListId: to.dataset.listId,
moveBeforeId, moveBeforeId,
...@@ -187,7 +187,7 @@ export default { ...@@ -187,7 +187,7 @@ export default {
:key="issue.id" :key="issue.id"
:index="index" :index="index"
:list="list" :list="list"
:issue="issue" :item="issue"
:disabled="disabled || !canAdminEpic" :disabled="disabled || !canAdminEpic"
/> />
<gl-loading-icon v-if="isLoadingMore && isUnassignedIssuesLane" size="sm" class="gl-py-3" /> <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 ...@@ -32,6 +32,7 @@ import { EpicFilterType, IterationFilterType, GroupByParamType } from '../consta
import epicQuery from '../graphql/epic.query.graphql'; import epicQuery from '../graphql/epic.query.graphql';
import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql'; import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql';
import epicBoardListsQuery from '../graphql/epic_board_lists.query.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 epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql'; import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
...@@ -484,13 +485,21 @@ export default { ...@@ -484,13 +485,21 @@ export default {
}); });
}, },
moveItem: ({ getters, dispatch }, params) => {
if (!getters.isEpicBoard) {
dispatch('moveIssue', params);
} else {
dispatch('moveEpic', params);
}
},
moveIssue: ( moveIssue: (
{ state, commit }, { 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 fromList = state.boardItemsByListId[fromListId];
const originalIndex = fromList.indexOf(Number(issueId)); const originalIndex = fromList.indexOf(Number(itemId));
commit(types.MOVE_ISSUE, { commit(types.MOVE_ISSUE, {
originalIssue, originalIssue,
fromListId, fromListId,
...@@ -501,7 +510,7 @@ export default { ...@@ -501,7 +510,7 @@ export default {
}); });
const { boardId } = state; const { boardId } = state;
const [fullProjectPath] = issuePath.split(/[#]/); const [fullProjectPath] = itemPath.split(/[#]/);
gqlClient gqlClient
.mutate({ .mutate({
...@@ -509,7 +518,7 @@ export default { ...@@ -509,7 +518,7 @@ export default {
variables: { variables: {
projectPath: fullProjectPath, projectPath: fullProjectPath,
boardId: fullBoardId(boardId), boardId: fullBoardId(boardId),
iid: issueIid, iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId), fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId), toListId: getIdFromGraphQLId(toListId),
moveBeforeId, moveBeforeId,
...@@ -519,7 +528,7 @@ export default { ...@@ -519,7 +528,7 @@ export default {
}) })
.then(({ data }) => { .then(({ data }) => {
if (data?.issueMoveList?.errors.length) { if (data?.issueMoveList?.errors.length) {
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }); throw new Error();
} else { } else {
const issue = data.issueMoveList?.issue; const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue }); commit(types.MOVE_ISSUE_SUCCESS, { issue });
...@@ -530,6 +539,40 @@ export default { ...@@ -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 }) => { fetchLists: ({ getters, dispatch }) => {
if (!getters.isEpicBoard) { if (!getters.isEpicBoard) {
dispatch('fetchIssueLists'); dispatch('fetchIssueLists');
......
...@@ -31,6 +31,8 @@ export const SET_FILTERS = 'SET_FILTERS'; ...@@ -31,6 +31,8 @@ export const SET_FILTERS = 'SET_FILTERS';
export const MOVE_ISSUE = 'MOVE_ISSUE'; export const MOVE_ISSUE = 'MOVE_ISSUE';
export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS'; export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE'; 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 CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES'; export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES';
export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST'; export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST';
......
import { union, unionBy } from 'lodash'; import { union, unionBy } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import { moveIssueListHelper } from '~/boards/boards_util'; import { moveItemListHelper } from '~/boards/boards_util';
import { issuableTypes } from '~/boards/constants'; 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 { s__, __ } from '~/locale';
import { ErrorMessages } from '../constants'; import { ErrorMessages } from '../constants';
import * as mutationTypes from './mutation_types'; import * as mutationTypes from './mutation_types';
...@@ -168,7 +168,7 @@ export default { ...@@ -168,7 +168,7 @@ export default {
const fromList = state.boardLists[fromListId]; const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId]; const toList = state.boardLists[toListId];
const issue = moveIssueListHelper(originalIssue, fromList, toList); const issue = moveItemListHelper(originalIssue, fromList, toList);
if (epicId === null) { if (epicId === null) {
Vue.set(state.boardItems, issue.id, { ...issue, epic: null }); Vue.set(state.boardItems, issue.id, { ...issue, epic: null });
...@@ -176,8 +176,37 @@ export default { ...@@ -176,8 +176,37 @@ export default {
Vue.set(state.boardItems, issue.id, { ...issue, epic: { id: epicId } }); Vue.set(state.boardItems, issue.id, { ...issue, epic: { id: epicId } });
} }
removeIssueFromList({ state, listId: fromListId, issueId: issue.id }); removeItemFromList({ state, listId: fromListId, itemId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); 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) => { [mutationTypes.SET_BOARD_EPIC_USER_PREFERENCES]: (state, val) => {
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'epic boards', :js do RSpec.describe 'epic boards', :js do
include DragTo
include MobileHelpers
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) } let_it_be(:group) { create(:group, :public) }
...@@ -63,7 +66,7 @@ RSpec.describe 'epic boards', :js do ...@@ -63,7 +66,7 @@ RSpec.describe 'epic boards', :js do
end end
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' click_button 'Create list'
wait_for_all_requests wait_for_all_requests
...@@ -77,6 +80,16 @@ RSpec.describe 'epic boards', :js do ...@@ -77,6 +80,16 @@ RSpec.describe 'epic boards', :js do
expect(page).to have_selector('.board', text: label2.title) expect(page).to have_selector('.board', text: label2.title)
expect(find('.board:nth-child(3) .board-card')).to have_content(epic3.title) expect(find('.board:nth-child(3) .board-card')).to have_content(epic3.title)
end 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 end
context 'when user can admin epic boards' do context 'when user can admin epic boards' do
...@@ -113,4 +126,17 @@ RSpec.describe 'epic boards', :js do ...@@ -113,4 +126,17 @@ RSpec.describe 'epic boards', :js do
def list_header(list) def list_header(list)
find(".board[data-id='gid://gitlab/Boards::EpicList/#{list.id}'] .board-header") find(".board[data-id='gid://gitlab/Boards::EpicList/#{list.id}'] .board-header")
end 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 end
...@@ -216,6 +216,7 @@ export const mockEpic = { ...@@ -216,6 +216,7 @@ export const mockEpic = {
closedIssues: 2, closedIssues: 2,
}, },
issues: [mockIssue], issues: [mockIssue],
labels: [],
}; };
export const mockIssueWithEpic = { ...mockIssue3, epic: { id: mockEpic.id, iid: mockEpic.iid } }; export const mockIssueWithEpic = { ...mockIssue3, epic: { id: mockEpic.id, iid: mockEpic.iid } };
...@@ -238,6 +239,7 @@ export const mockEpics = [ ...@@ -238,6 +239,7 @@ export const mockEpics = [
parent: { parent: {
id: '40', id: '40',
}, },
labels: [],
}, },
{ {
id: 'gid://gitlab/Epic/40', id: 'gid://gitlab/Epic/40',
...@@ -252,6 +254,7 @@ export const mockEpics = [ ...@@ -252,6 +254,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/marketing/-/epics/1', web_url: '/groups/gitlab-org/marketing/-/epics/1',
descendantCounts: defaultDescendantCounts, descendantCounts: defaultDescendantCounts,
hasParent: false, hasParent: false,
labels: [],
}, },
{ {
id: 'gid://gitlab/Epic/39', id: 'gid://gitlab/Epic/39',
...@@ -266,6 +269,7 @@ export const mockEpics = [ ...@@ -266,6 +269,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/-/epics/12', web_url: '/groups/gitlab-org/-/epics/12',
descendantCounts: defaultDescendantCounts, descendantCounts: defaultDescendantCounts,
hasParent: false, hasParent: false,
labels: [],
}, },
{ {
id: 'gid://gitlab/Epic/38', id: 'gid://gitlab/Epic/38',
...@@ -280,6 +284,7 @@ export const mockEpics = [ ...@@ -280,6 +284,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/-/epics/11', web_url: '/groups/gitlab-org/-/epics/11',
descendantCounts: defaultDescendantCounts, descendantCounts: defaultDescendantCounts,
hasParent: false, hasParent: false,
labels: [],
}, },
{ {
id: 'gid://gitlab/Epic/37', id: 'gid://gitlab/Epic/37',
...@@ -294,6 +299,7 @@ export const mockEpics = [ ...@@ -294,6 +299,7 @@ export const mockEpics = [
web_url: '/groups/gitlab-org/-/epics/10', web_url: '/groups/gitlab-org/-/epics/10',
descendantCounts: defaultDescendantCounts, descendantCounts: defaultDescendantCounts,
hasParent: false, hasParent: false,
labels: [],
}, },
]; ];
......
...@@ -327,6 +327,40 @@ describe('MOVE_ISSUE', () => { ...@@ -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', () => { describe('SET_BOARD_EPIC_USER_PREFERENCES', () => {
it('should replace userPreferences on the given epic', () => { it('should replace userPreferences on the given epic', () => {
state = { state = {
......
...@@ -4872,6 +4872,9 @@ msgstr "" ...@@ -4872,6 +4872,9 @@ msgstr ""
msgid "Boards|An error occurred while generating lists. Please reload the page." msgid "Boards|An error occurred while generating lists. Please reload the page."
msgstr "" 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." msgid "Boards|An error occurred while moving the issue. Please try again."
msgstr "" msgstr ""
......
...@@ -125,7 +125,7 @@ describe('Board list component', () => { ...@@ -125,7 +125,7 @@ describe('Board list component', () => {
}); });
it('sets data attribute with issue id', () => { 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 () => { it('shows new issue form', async () => {
...@@ -258,7 +258,7 @@ describe('Board list component', () => { ...@@ -258,7 +258,7 @@ describe('Board list component', () => {
describe('handleDragOnEnd', () => { describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => { 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'); document.body.classList.add('is-dragging');
findByTestId('tree-root-wrapper').vm.$emit('end', { findByTestId('tree-root-wrapper').vm.$emit('end', {
...@@ -266,9 +266,9 @@ describe('Board list component', () => { ...@@ -266,9 +266,9 @@ describe('Board list component', () => {
newIndex: 0, newIndex: 0,
item: { item: {
dataset: { dataset: {
issueId: mockIssues[0].id, itemId: mockIssues[0].id,
issueIid: mockIssues[0].iid, itemIid: mockIssues[0].iid,
issuePath: mockIssues[0].referencePath, itemPath: mockIssues[0].referencePath,
}, },
}, },
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
......
...@@ -6,7 +6,7 @@ import BoardCardInner from '~/boards/components/board_card_inner.vue'; ...@@ -6,7 +6,7 @@ import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { inactiveId } from '~/boards/constants'; import { inactiveId } from '~/boards/constants';
import { mockLabelList, mockIssue } from '../mock_data'; import { mockLabelList, mockIssue } from '../mock_data';
describe('Board card layout', () => { describe('Board card', () => {
let wrapper; let wrapper;
let store; let store;
let mockActions; let mockActions;
...@@ -44,7 +44,7 @@ describe('Board card layout', () => { ...@@ -44,7 +44,7 @@ describe('Board card layout', () => {
store, store,
propsData: { propsData: {
list: mockLabelList, list: mockLabelList,
issue: mockIssue, item: mockIssue,
disabled: false, disabled: false,
index: 0, index: 0,
...propsData, ...propsData,
...@@ -113,7 +113,7 @@ describe('Board card layout', () => { ...@@ -113,7 +113,7 @@ describe('Board card layout', () => {
expect(wrapper.classes()).not.toContain('is-active'); 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(() => { beforeEach(() => {
createStore({ isSwimlanesOn }); createStore({ isSwimlanesOn });
mountComponent(); mountComponent();
......
...@@ -637,6 +637,15 @@ describe('resetIssues', () => { ...@@ -637,6 +637,15 @@ describe('resetIssues', () => {
}); });
}); });
describe('moveItem', () => {
it('should dispatch moveIssue action', () => {
testAction({
action: actions.moveItem,
expectedActions: [{ type: 'moveIssue' }],
});
});
});
describe('moveIssue', () => { describe('moveIssue', () => {
const listIssues = { const listIssues = {
'gid://gitlab/List/1': [436, 437], 'gid://gitlab/List/1': [436, 437],
...@@ -671,9 +680,9 @@ describe('moveIssue', () => { ...@@ -671,9 +680,9 @@ describe('moveIssue', () => {
testAction( testAction(
actions.moveIssue, actions.moveIssue,
{ {
issueId: '436', itemId: '436',
issueIid: mockIssue.iid, itemIid: mockIssue.iid,
issuePath: mockIssue.referencePath, itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1', fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2', toListId: 'gid://gitlab/List/2',
}, },
...@@ -722,9 +731,9 @@ describe('moveIssue', () => { ...@@ -722,9 +731,9 @@ describe('moveIssue', () => {
actions.moveIssue( actions.moveIssue(
{ state, commit: () => {} }, { state, commit: () => {} },
{ {
issueId: mockIssue.id, itemId: mockIssue.id,
issueIid: mockIssue.iid, itemIid: mockIssue.iid,
issuePath: mockIssue.referencePath, itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1', fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2', toListId: 'gid://gitlab/List/2',
}, },
...@@ -746,9 +755,9 @@ describe('moveIssue', () => { ...@@ -746,9 +755,9 @@ describe('moveIssue', () => {
testAction( testAction(
actions.moveIssue, actions.moveIssue,
{ {
issueId: '436', itemId: '436',
issueIid: mockIssue.iid, itemIid: mockIssue.iid,
issuePath: mockIssue.referencePath, itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1', fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2', 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