Commit 67ac24fb authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '218164-drag-and-drop-between-lanes' into 'master'

Swimlanes - Move issue between epics

See merge request gitlab-org/gitlab!43803
parents 68ae71ff 274083a5
...@@ -10,11 +10,19 @@ const notImplemented = () => { ...@@ -10,11 +10,19 @@ const notImplemented = () => {
throw new Error('Not implemented!'); throw new Error('Not implemented!');
}; };
const removeIssueFromList = (state, listId, issueId) => { const getListById = ({ state, listId }) => {
const listIndex = state.boardLists.findIndex(l => l.id === listId);
const list = state.boardLists[listIndex];
return { listIndex, list };
};
export const removeIssueFromList = ({ state, listId, issueId }) => {
Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
const { listIndex, list } = getListById({ state, listId });
Vue.set(state.boardLists, listIndex, { ...list, issuesSize: list.issuesSize - 1 });
}; };
const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
const listIssues = state.issuesByListId[listId]; const listIssues = state.issuesByListId[listId];
let newIndex = atIndex || 0; let newIndex = atIndex || 0;
if (moveBeforeId) { if (moveBeforeId) {
...@@ -24,6 +32,8 @@ const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atI ...@@ -24,6 +32,8 @@ const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atI
} }
listIssues.splice(newIndex, 0, issueId); listIssues.splice(newIndex, 0, issueId);
Vue.set(state.issuesByListId, listId, listIssues); Vue.set(state.issuesByListId, listId, listIssues);
const { listIndex, list } = getListById({ state, listId });
Vue.set(state.boardLists, listIndex, { ...list, issuesSize: list.issuesSize + 1 });
}; };
export default { export default {
...@@ -142,7 +152,7 @@ export default { ...@@ -142,7 +152,7 @@ export default {
const issue = moveIssueListHelper(originalIssue, fromList, toList); const issue = moveIssueListHelper(originalIssue, fromList, toList);
Vue.set(state.issues, issue.id, issue); Vue.set(state.issues, issue.id, issue);
removeIssueFromList(state, fromListId, issue.id); removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
}, },
...@@ -157,7 +167,7 @@ export default { ...@@ -157,7 +167,7 @@ export default {
) => { ) => {
state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
Vue.set(state.issues, originalIssue.id, originalIssue); Vue.set(state.issues, originalIssue.id, originalIssue);
removeIssueFromList(state, toListId, originalIssue.id); removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id });
addIssueToList({ addIssueToList({
state, state,
listId: fromListId, listId: fromListId,
...@@ -187,7 +197,7 @@ export default { ...@@ -187,7 +197,7 @@ export default {
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => { [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
removeIssueFromList(state, list.id, issue.id); removeIssueFromList({ state, listId: list.id, issueId: issue.id });
}, },
[mutationTypes.SET_CURRENT_PAGE]: () => { [mutationTypes.SET_CURRENT_PAGE]: () => {
......
...@@ -143,7 +143,7 @@ export default function simulateDrag(options) { ...@@ -143,7 +143,7 @@ export default function simulateDrag(options) {
const dragInterval = setInterval(() => { const dragInterval = setInterval(() => {
const progress = (new Date().getTime() - startTime) / duration; const progress = (new Date().getTime() - startTime) / duration;
const x = fromRect.cx + (toRect.cx - fromRect.cx) * progress; const x = fromRect.cx + (toRect.cx - fromRect.cx) * progress;
const y = fromRect.cy + (toRect.cy - fromRect.cy) * progress; const y = fromRect.cy + (toRect.cy - fromRect.cy + options.extraHeight) * progress;
const overEl = fromEl.ownerDocument.elementFromPoint(x, y); const overEl = fromEl.ownerDocument.elementFromPoint(x, y);
simulateEvent(overEl, 'pointermove', { simulateEvent(overEl, 'pointermove', {
......
...@@ -146,7 +146,7 @@ export default { ...@@ -146,7 +146,7 @@ export default {
<gl-loading-icon v-if="isLoading" class="gl-p-2" /> <gl-loading-icon v-if="isLoading" class="gl-p-2" />
</div> </div>
</div> </div>
<div v-if="isExpanded" class="gl-display-flex"> <div v-if="isExpanded" class="gl-display-flex" data-testid="board-epic-lane-issues">
<issues-lane-list <issues-lane-list
v-for="list in lists" v-for="list in lists"
:key="`${list.id}-issues`" :key="`${list.id}-issues`"
......
...@@ -38,6 +38,11 @@ export default { ...@@ -38,6 +38,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
epicId: {
type: String,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
...@@ -45,9 +50,9 @@ export default { ...@@ -45,9 +50,9 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['activeId', 'filterParams']), ...mapState(['activeId', 'filterParams', 'canAdminEpic']),
treeRootWrapper() { treeRootWrapper() {
return this.canAdminList ? Draggable : 'ul'; return this.canAdminList && this.canAdminEpic ? Draggable : 'ul';
}, },
treeRootOptions() { treeRootOptions() {
const options = { const options = {
...@@ -56,6 +61,7 @@ export default { ...@@ -56,6 +61,7 @@ export default {
group: 'board-epics-swimlanes', group: 'board-epics-swimlanes',
tag: 'ul', tag: 'ul',
'ghost-class': 'board-card-drag-active', 'ghost-class': 'board-card-drag-active',
'data-epic-id': this.epicId,
'data-list-id': this.list.id, 'data-list-id': this.list.id,
value: this.issues, value: this.issues,
}; };
...@@ -81,7 +87,7 @@ export default { ...@@ -81,7 +87,7 @@ export default {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
}, },
methods: { methods: {
...mapActions(['setActiveId', 'moveIssue', 'fetchIssuesForList']), ...mapActions(['setActiveId', 'moveIssue', 'moveIssueEpic', 'fetchIssuesForList']),
toggleForm() { toggleForm() {
this.showIssueForm = !this.showIssueForm; this.showIssueForm = !this.showIssueForm;
if (this.showIssueForm && this.isUnassignedIssuesLane) { if (this.showIssueForm && this.isUnassignedIssuesLane) {
...@@ -103,10 +109,10 @@ export default { ...@@ -103,10 +109,10 @@ export default {
// If issue is being moved within the same list // If issue is being moved within the same list
if (from === to) { if (from === to) {
if (newIndex > oldIndex) { if (newIndex > oldIndex && children.length > 1) {
// If issue is being moved down we look for the issue that ends up before // If issue is being moved down we look for the issue that ends up before
moveBeforeId = Number(children[newIndex].dataset.issueId); moveBeforeId = Number(children[newIndex].dataset.issueId);
} else if (newIndex < oldIndex) { } else if (newIndex < oldIndex && children.length > 1) {
// If issue is being moved up we look for the issue that ends up after // If issue is being moved up we look for the issue that ends up after
moveAfterId = Number(children[newIndex].dataset.issueId); moveAfterId = Number(children[newIndex].dataset.issueId);
} else { } else {
...@@ -132,6 +138,7 @@ export default { ...@@ -132,6 +138,7 @@ export default {
toListId: to.dataset.listId, toListId: to.dataset.listId,
moveBeforeId, moveBeforeId,
moveAfterId, moveAfterId,
epicId: from.dataset.epicId !== to.dataset.epicId ? to.dataset.epicId || null : undefined,
}); });
}, },
}, },
...@@ -152,7 +159,7 @@ export default { ...@@ -152,7 +159,7 @@ export default {
:is="treeRootWrapper" :is="treeRootWrapper"
v-if="list.isExpanded" v-if="list.isExpanded"
v-bind="treeRootOptions" v-bind="treeRootOptions"
class="gl-p-2 gl-m-0" class="board-cell gl-p-2 gl-m-0 gl-h-full"
@end="handleDragOnEnd" @end="handleDragOnEnd"
> >
<board-card-layout <board-card-layout
...@@ -162,6 +169,7 @@ export default { ...@@ -162,6 +169,7 @@ export default {
:index="index" :index="index"
:list="list" :list="list"
:issue="issue" :issue="issue"
:disabled="disabled || !canAdminEpic"
:is-active="isActiveIssue(issue)" :is-active="isActiveIssue(issue)"
@show="showIssue(issue)" @show="showIssue(issue)"
/> />
......
...@@ -58,7 +58,7 @@ export default { ...@@ -58,7 +58,7 @@ export default {
const epic = await this.setActiveIssueEpic(input); const epic = await this.setActiveIssueEpic(input);
if (epic && !this.getEpicById(epic.id)) { if (epic && !this.getEpicById(epic.id)) {
this.receiveEpicsSuccess([epic, ...this.epics]); this.receiveEpicsSuccess({ epics: [epic, ...this.epics] });
} }
debounceByAnimationFrame(() => { debounceByAnimationFrame(() => {
......
...@@ -7,4 +7,7 @@ fragment BoardEpicNode on BoardEpic { ...@@ -7,4 +7,7 @@ fragment BoardEpicNode on BoardEpic {
webUrl webUrl
createdAt createdAt
closedAt closedAt
userPermissions {
adminEpic
}
} }
#import "ee_else_ce/boards/queries/issue.fragment.graphql"
mutation IssueMoveList(
$projectPath: ID!
$iid: String!
$boardId: ID!
$fromListId: ID
$toListId: ID
$moveBeforeId: ID
$moveAfterId: ID
$epicId: EpicID
) {
issueMoveList(
input: {
projectPath: $projectPath
iid: $iid
boardId: $boardId
fromListId: $fromListId
toListId: $toListId
moveBeforeId: $moveBeforeId
moveAfterId: $moveAfterId
epicId: $epicId
}
) {
issue {
...IssueNode
}
errors
}
}
...@@ -11,12 +11,14 @@ import boardsStoreEE from './boards_store_ee'; ...@@ -11,12 +11,14 @@ import boardsStoreEE from './boards_store_ee';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { fullEpicId } from '../boards_util'; import { fullEpicId } from '../boards_util';
import { formatListIssues, fullBoardId } from '~/boards/boards_util'; import { formatListIssues, fullBoardId } from '~/boards/boards_util';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql';
import issueSetEpic from '../queries/issue_set_epic.mutation.graphql'; import issueSetEpic from '../queries/issue_set_epic.mutation.graphql';
import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql'; import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
const notImplemented = () => { const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */ /* eslint-disable-next-line @gitlab/require-i18n-strings */
...@@ -102,7 +104,7 @@ export default { ...@@ -102,7 +104,7 @@ export default {
})); }));
if (!withLists) { if (!withLists) {
commit(types.RECEIVE_EPICS_SUCCESS, epicsFormatted); commit(types.RECEIVE_EPICS_SUCCESS, { epics: epicsFormatted });
} }
if (epics.pageInfo?.hasNextPage) { if (epics.pageInfo?.hasNextPage) {
...@@ -115,6 +117,7 @@ export default { ...@@ -115,6 +117,7 @@ export default {
return { return {
epics: epicsFormatted, epics: epicsFormatted,
lists: lists?.nodes, lists: lists?.nodes,
canAdminEpic: epics.edges[0]?.node?.userPermissions?.adminEpic,
}; };
}) })
.catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE)); .catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE));
...@@ -212,7 +215,7 @@ export default { ...@@ -212,7 +215,7 @@ export default {
if (state.isShowingEpicsSwimlanes) { if (state.isShowingEpicsSwimlanes) {
dispatch('fetchEpicsSwimlanes', {}) dispatch('fetchEpicsSwimlanes', {})
.then(({ lists, epics }) => { .then(({ lists, epics, canAdminEpic }) => {
if (lists) { if (lists) {
let boardLists = lists.map(list => let boardLists = lists.map(list =>
boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }), boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }),
...@@ -222,7 +225,7 @@ export default { ...@@ -222,7 +225,7 @@ export default {
} }
if (epics) { if (epics) {
commit(types.RECEIVE_EPICS_SUCCESS, epics); commit(types.RECEIVE_EPICS_SUCCESS, { epics, canAdminEpic });
} }
}) })
.catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE)); .catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE));
...@@ -254,4 +257,50 @@ export default { ...@@ -254,4 +257,50 @@ export default {
return data.issueSetEpic.issue.epic; return data.issueSetEpic.issue.epic;
}, },
moveIssue: (
{ state, commit },
{ issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId, epicId },
) => {
const originalIssue = state.issues[issueId];
const fromList = state.issuesByListId[fromListId];
const originalIndex = fromList.indexOf(Number(issueId));
commit(types.MOVE_ISSUE, {
originalIssue,
fromListId,
toListId,
moveBeforeId,
moveAfterId,
epicId,
});
const { boardId } = state.endpoints;
const [fullProjectPath] = issuePath.split(/[#]/);
gqlClient
.mutate({
mutation: issueMoveListMutation,
variables: {
projectPath: fullProjectPath,
boardId: fullBoardId(boardId),
iid: issueIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
moveAfterId,
epicId,
},
})
.then(({ data }) => {
if (data?.issueMoveList?.errors.length) {
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex });
} else {
const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue });
}
})
.catch(() =>
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }),
);
},
}; };
...@@ -23,3 +23,6 @@ export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS'; ...@@ -23,3 +23,6 @@ export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const RESET_EPICS = 'RESET_EPICS'; export const RESET_EPICS = 'RESET_EPICS';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS'; export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
export const SET_FILTERS = 'SET_FILTERS'; export const SET_FILTERS = 'SET_FILTERS';
export const MOVE_ISSUE = 'MOVE_ISSUE';
export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE';
import Vue from 'vue'; import Vue from 'vue';
import { union } from 'lodash'; import { union } from 'lodash';
import mutationsCE from '~/boards/stores/mutations'; import mutationsCE, { addIssueToList, removeIssueFromList } from '~/boards/stores/mutations';
import { moveIssueListHelper } from '~/boards/boards_util';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import * as mutationTypes from './mutation_types'; import * as mutationTypes from './mutation_types';
...@@ -102,11 +103,31 @@ export default { ...@@ -102,11 +103,31 @@ export default {
state.epicsSwimlanesFetchInProgress = false; state.epicsSwimlanesFetchInProgress = false;
}, },
[mutationTypes.RECEIVE_EPICS_SUCCESS]: (state, epics) => { [mutationTypes.RECEIVE_EPICS_SUCCESS]: (state, { epics, canAdminEpic }) => {
Vue.set(state, 'epics', union(state.epics || [], epics)); Vue.set(state, 'epics', union(state.epics || [], epics));
state.canAdminEpic = canAdminEpic;
}, },
[mutationTypes.RESET_EPICS]: state => { [mutationTypes.RESET_EPICS]: state => {
Vue.set(state, 'epics', []); Vue.set(state, 'epics', []);
}, },
[mutationTypes.MOVE_ISSUE]: (
state,
{ originalIssue, fromListId, toListId, moveBeforeId, moveAfterId, epicId },
) => {
const fromList = state.boardLists.find(l => l.id === fromListId);
const toList = state.boardLists.find(l => l.id === toListId);
const issue = moveIssueListHelper(originalIssue, fromList, toList);
if (epicId === null) {
Vue.set(state.issues, issue.id, { ...issue, epic: null });
} else if (epicId !== undefined) {
Vue.set(state.issues, issue.id, { ...issue, epic: { id: epicId } });
}
removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
},
}; };
...@@ -3,6 +3,7 @@ import createStateCE from '~/boards/stores/state'; ...@@ -3,6 +3,7 @@ import createStateCE from '~/boards/stores/state';
export default () => ({ export default () => ({
...createStateCE(), ...createStateCE(),
canAdminEpic: false,
isShowingEpicsSwimlanes: false, isShowingEpicsSwimlanes: false,
epicsSwimlanesFetchInProgress: false, epicsSwimlanesFetchInProgress: false,
epics: [], epics: [],
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'epics swimlanes', :js do
include DragTo
include MobileHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:label) { create(:label, project: project, name: 'Label 1') }
let_it_be(:list) { create(:list, board: board, label: label, position: 0) }
let_it_be(:issue1) { create(:issue, project: project, labels: [label]) }
let_it_be(:issue2) { create(:issue, project: project) }
let_it_be(:issue3) { create(:issue, project: project, state: :closed) }
let_it_be(:issue4) { create(:issue, project: project) }
let_it_be(:epic1) { create(:epic, group: group) }
let_it_be(:epic2) { create(:epic, group: group) }
let_it_be(:epic_issue1) { create(:epic_issue, epic: epic1, issue: issue1) }
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2, issue: issue2) }
let_it_be(:epic_issue3) { create(:epic_issue, epic: epic2, issue: issue3) }
before do
project.add_maintainer(user)
group.add_maintainer(user)
stub_licensed_features(epics: true)
sign_in(user)
visit_board_page
select_epics
end
context 'drag and drop issue' do
it 'between epics' do
wait_for_board_cards(1, 2)
wait_for_board_cards_in_first_epic(0, 1)
wait_for_board_cards_in_second_epic(1, 1)
drag(list_from_index: 4, list_to_index: 1)
wait_for_board_cards_in_first_epic(1, 1)
wait_for_board_cards_in_second_epic(1, 0)
end
it 'from epic to unassigned issues lane' do
wait_for_board_cards(1, 2)
wait_for_board_cards_in_second_epic(1, 1)
drag(list_from_index: 4, list_to_index: 7)
wait_for_board_cards_in_second_epic(1, 0)
wait_for_board_cards_in_unassigned_lane(1, 1)
end
it 'from unassigned issues lane to epic' do
wait_for_board_cards(1, 2)
wait_for_board_cards_in_unassigned_lane(0, 1)
drag(list_from_index: 6, list_to_index: 3)
wait_for_board_cards_in_second_epic(0, 1)
wait_for_board_cards_in_unassigned_lane(0, 0)
end
it 'between lists within epic lane' do
wait_for_board_cards(1, 2)
wait_for_board_cards_in_first_epic(0, 1)
drag(list_from_index: 0, list_to_index: 1)
wait_for_board_cards(1, 1)
wait_for_board_cards(2, 2)
wait_for_board_cards_in_first_epic(0, 0)
wait_for_board_cards_in_first_epic(1, 1)
end
it 'between lists within unassigned lane' do
wait_for_board_cards(1, 2)
wait_for_board_cards_in_unassigned_lane(0, 1)
drag(list_from_index: 6, list_to_index: 7)
wait_for_board_cards(1, 1)
wait_for_board_cards(2, 2)
wait_for_board_cards_in_unassigned_lane(0, 0)
wait_for_board_cards_in_unassigned_lane(1, 1)
end
it 'between lists and epics' do
wait_for_board_cards(1, 2)
wait_for_board_cards_in_second_epic(1, 1)
drag(list_from_index: 4, list_to_index: 2)
wait_for_board_cards(2, 0)
wait_for_board_cards(3, 2)
wait_for_board_cards_in_first_epic(2, 2)
end
end
def visit_board_page
visit project_boards_path(project)
wait_for_requests
end
def select_epics
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
end
def drag(selector: '.board-cell', 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 boards
resize_window(2000, 1200)
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,
extra_height: 50)
end
def wait_for_board_cards(board_number, expected_cards)
page.within(find(".board-swimlanes-headers .board:nth-child(#{board_number})")) do
expect(page.find('.board-header')).to have_content(expected_cards.to_s)
end
end
def wait_for_board_cards_in_first_epic(board_number, expected_cards)
page.within(all("[data-testid='board-epic-lane-issues']")[0]) do
page.within(all(".board")[board_number]) do
expect(page).to have_selector('.board-card', count: expected_cards)
end
end
end
def wait_for_board_cards_in_second_epic(board_number, expected_cards)
page.within(all("[data-testid='board-epic-lane-issues']")[1]) do
page.within(all(".board")[board_number]) do
expect(page).to have_selector('.board-card', count: expected_cards)
end
end
end
def wait_for_board_cards_in_unassigned_lane(board_number, expected_cards)
page.within(find("[data-testid='board-lane-unassigned-issues']")) do
page.within(all(".board")[board_number]) do
expect(page).to have_selector('.board-card', count: expected_cards)
end
end
end
end
/* global ListIssue */
/* global List */
import Vue from 'vue'; import Vue from 'vue';
import List from '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/issue';
export const mockLists = [ export const mockLists = [
{ {
...@@ -62,6 +66,34 @@ const labels = [ ...@@ -62,6 +66,34 @@ const labels = [
}, },
]; ];
export const rawIssue = {
title: 'Issue 1',
id: 'gid://gitlab/Issue/436',
iid: 27,
dueDate: null,
timeEstimate: 0,
weight: null,
confidential: false,
referencePath: 'gitlab-org/test-subgroup/gitlab-test#27',
path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27',
labels: {
nodes: [
{
id: 1,
title: 'test',
color: 'red',
description: 'testing',
},
],
},
assignees: {
nodes: assignees,
},
epic: {
id: 'gid://gitlab/Epic/41',
},
};
export const mockIssue = { export const mockIssue = {
id: 'gid://gitlab/Issue/436', id: 'gid://gitlab/Issue/436',
iid: 27, iid: 27,
...@@ -79,6 +111,8 @@ export const mockIssue = { ...@@ -79,6 +111,8 @@ export const mockIssue = {
}, },
}; };
export const mockIssueWithModel = new ListIssue({ ...mockIssue, id: '436' });
export const mockIssue2 = { export const mockIssue2 = {
id: 'gid://gitlab/Issue/437', id: 'gid://gitlab/Issue/437',
iid: 28, iid: 28,
...@@ -96,6 +130,8 @@ export const mockIssue2 = { ...@@ -96,6 +130,8 @@ export const mockIssue2 = {
}, },
}; };
export const mockIssue2WithModel = new ListIssue({ ...mockIssue2, id: '437' });
export const mockIssue3 = { export const mockIssue3 = {
id: 'gid://gitlab/Issue/438', id: 'gid://gitlab/Issue/438',
iid: 29, iid: 29,
......
...@@ -6,7 +6,15 @@ import * as types from 'ee/boards/stores/mutation_types'; ...@@ -6,7 +6,15 @@ import * as types from 'ee/boards/stores/mutation_types';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import { formatListIssues } from '~/boards/boards_util'; import { formatListIssues } from '~/boards/boards_util';
import { mockLists, mockIssue, mockEpic } from '../mock_data'; import {
mockLists,
mockIssue,
mockEpic,
rawIssue,
mockIssueWithModel,
mockIssue2WithModel,
mockListsWithModel,
} from '../mock_data';
const expectNotImplemented = action => { const expectNotImplemented = action => {
it('is not implemented', () => { it('is not implemented', () => {
...@@ -96,7 +104,7 @@ describe('fetchEpicsSwimlanes', () => { ...@@ -96,7 +104,7 @@ describe('fetchEpicsSwimlanes', () => {
[ [
{ {
type: types.RECEIVE_EPICS_SUCCESS, type: types.RECEIVE_EPICS_SUCCESS,
payload: [mockEpic], payload: { epics: [mockEpic] },
}, },
], ],
[], [],
...@@ -142,7 +150,7 @@ describe('fetchEpicsSwimlanes', () => { ...@@ -142,7 +150,7 @@ describe('fetchEpicsSwimlanes', () => {
[ [
{ {
type: types.RECEIVE_EPICS_SUCCESS, type: types.RECEIVE_EPICS_SUCCESS,
payload: [mockEpic], payload: { epics: [mockEpic] },
}, },
], ],
[ [
...@@ -381,3 +389,113 @@ describe('setActiveIssueEpic', () => { ...@@ -381,3 +389,113 @@ describe('setActiveIssueEpic', () => {
await expect(actions.setActiveIssueEpic({ getters }, input)).rejects.toThrow(Error); await expect(actions.setActiveIssueEpic({ getters }, input)).rejects.toThrow(Error);
}); });
}); });
describe('moveIssue', () => {
const epicId = 'gid://gitlab/Epic/1';
const listIssues = {
'gid://gitlab/List/1': [436, 437],
'gid://gitlab/List/2': [],
};
const issues = {
'436': mockIssueWithModel,
'437': mockIssue2WithModel,
};
const state = {
endpoints: { fullPath: 'gitlab-org', boardId: '1' },
boardType: 'group',
disabled: false,
boardLists: mockListsWithModel,
issuesByListId: listIssues,
issues,
};
it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
issueMoveList: {
issue: rawIssue,
errors: [],
},
},
});
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
state,
[
{
type: types.MOVE_ISSUE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
},
{
type: types.MOVE_ISSUE_SUCCESS,
payload: { issue: rawIssue },
},
],
[],
done,
);
});
it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
issueMoveList: {
issue: {},
errors: [{ foo: 'bar' }],
},
},
});
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
state,
[
{
type: types.MOVE_ISSUE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
},
},
{
type: types.MOVE_ISSUE_FAILURE,
payload: {
originalIssue: mockIssueWithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
originalIndex: 0,
},
},
],
[],
done,
);
});
});
...@@ -6,6 +6,8 @@ import { ...@@ -6,6 +6,8 @@ import {
mockEpics, mockEpics,
mockEpic, mockEpic,
mockListsWithModel, mockListsWithModel,
mockIssueWithModel,
mockIssue2WithModel,
} from '../mock_data'; } from '../mock_data';
const expectNotImplemented = action => { const expectNotImplemented = action => {
...@@ -211,7 +213,7 @@ describe('RECEIVE_EPICS_SUCCESS', () => { ...@@ -211,7 +213,7 @@ describe('RECEIVE_EPICS_SUCCESS', () => {
epics: {}, epics: {},
}; };
mutations.RECEIVE_EPICS_SUCCESS(state, mockEpics); mutations.RECEIVE_EPICS_SUCCESS(state, { epics: mockEpics });
expect(state.epics).toEqual(mockEpics); expect(state.epics).toEqual(mockEpics);
}); });
...@@ -229,3 +231,62 @@ describe('RESET_EPICS', () => { ...@@ -229,3 +231,62 @@ describe('RESET_EPICS', () => {
expect(state.epics).toEqual([]); expect(state.epics).toEqual([]);
}); });
}); });
describe('MOVE_ISSUE', () => {
beforeEach(() => {
const listIssues = {
'gid://gitlab/List/1': [mockListsWithModel.id, mockIssue2WithModel.id],
'gid://gitlab/List/2': [],
};
const issues = {
'436': mockIssueWithModel,
'437': mockIssue2WithModel,
};
state = {
...state,
issuesByListId: listIssues,
boardLists: mockListsWithModel,
issues,
};
});
it('updates issuesByListId, moving issue between lists and updating epic id on issue', () => {
expect(state.issues['437'].epic.id).toEqual('gid://gitlab/Epic/40');
mutations.MOVE_ISSUE(state, {
originalIssue: mockIssue2WithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId,
});
const updatedListIssues = {
'gid://gitlab/List/1': [mockListsWithModel.id],
'gid://gitlab/List/2': [mockIssue2WithModel.id],
};
expect(state.issuesByListId).toEqual(updatedListIssues);
expect(state.issues['437'].epic.id).toEqual(epicId);
});
it('removes epic id from issue when epicId is null', () => {
expect(state.issues['437'].epic.id).toEqual('gid://gitlab/Epic/40');
mutations.MOVE_ISSUE(state, {
originalIssue: mockIssue2WithModel,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
epicId: null,
});
const updatedListIssues = {
'gid://gitlab/List/1': [mockListsWithModel.id],
'gid://gitlab/List/2': [mockIssue2WithModel.id],
};
expect(state.issuesByListId).toEqual(updatedListIssues);
expect(state.issues['437'].epic).toEqual(null);
});
});
...@@ -322,6 +322,7 @@ describe('Board Store Mutations', () => { ...@@ -322,6 +322,7 @@ describe('Board Store Mutations', () => {
state = { state = {
...state, ...state,
issuesByListId: listIssues, issuesByListId: listIssues,
boardLists: mockListsWithModel,
}; };
mutations.MOVE_ISSUE_FAILURE(state, { mutations.MOVE_ISSUE_FAILURE(state, {
...@@ -389,6 +390,7 @@ describe('Board Store Mutations', () => { ...@@ -389,6 +390,7 @@ describe('Board Store Mutations', () => {
...state, ...state,
issuesByListId: listIssues, issuesByListId: listIssues,
issues, issues,
boardLists: mockListsWithModel,
}; };
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 }); mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 });
......
# frozen_string_literal: true # frozen_string_literal: true
module DragTo module DragTo
def drag_to(list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0, selector: '', scrollable: 'body', duration: 1000, perform_drop: true) # rubocop:disable Metrics/ParameterLists
def drag_to(list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0, selector: '', scrollable: 'body', duration: 1000, perform_drop: true, extra_height: 0)
js = <<~JS js = <<~JS
simulateDrag({ simulateDrag({
scrollable: document.querySelector('#{scrollable}'), scrollable: document.querySelector('#{scrollable}'),
...@@ -14,7 +15,8 @@ module DragTo ...@@ -14,7 +15,8 @@ module DragTo
el: document.querySelectorAll('#{selector}')[#{list_to_index}], el: document.querySelectorAll('#{selector}')[#{list_to_index}],
index: #{to_index} index: #{to_index}
}, },
performDrop: #{perform_drop} performDrop: #{perform_drop},
extraHeight: #{extra_height}
}); });
JS JS
evaluate_script(js) evaluate_script(js)
...@@ -23,6 +25,7 @@ module DragTo ...@@ -23,6 +25,7 @@ module DragTo
loop while drag_active? loop while drag_active?
end end
end end
# rubocop:enable Metrics/ParameterLists
def drag_active? def drag_active?
page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').nonzero? page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').nonzero?
......
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