Commit 5da84acd authored by Etienne Baqué's avatar Etienne Baqué

Merge branch '25233-allow-dragging-for-project-reporters' into 'master'

Allow project reporters to drag issue cards in group board

See merge request gitlab-org/gitlab!68126
parents 8263433e 7c74bda4
...@@ -6,10 +6,12 @@ import { mapState, mapGetters, mapActions } from 'vuex'; ...@@ -6,10 +6,12 @@ import { mapState, mapGetters, mapActions } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DraggableItemTypes } from '../constants';
import BoardColumn from './board_column.vue'; import BoardColumn from './board_column.vue';
import BoardColumnDeprecated from './board_column_deprecated.vue'; import BoardColumnDeprecated from './board_column_deprecated.vue';
export default { export default {
draggableItemTypes: DraggableItemTypes,
components: { components: {
BoardAddNewColumn, BoardAddNewColumn,
BoardColumn, BoardColumn,
...@@ -99,6 +101,7 @@ export default { ...@@ -99,6 +101,7 @@ export default {
:key="index" :key="index"
ref="board" ref="board"
:list="list" :list="list"
:data-draggable-item-type="$options.draggableItemTypes.list"
:disabled="disabled" :disabled="disabled"
/> />
......
...@@ -6,12 +6,13 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt ...@@ -6,12 +6,13 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { toggleFormEventPrefix } from '../constants'; import { toggleFormEventPrefix, DraggableItemTypes } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import BoardCard from './board_card.vue'; import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue'; import BoardNewIssue from './board_new_issue.vue';
export default { export default {
draggableItemTypes: DraggableItemTypes,
name: 'BoardList', name: 'BoardList',
i18n: { i18n: {
loading: __('Loading'), loading: __('Loading'),
...@@ -27,11 +28,6 @@ export default { ...@@ -27,11 +28,6 @@ export default {
GlIntersectionObserver, GlIntersectionObserver,
}, },
mixins: [Tracking.mixin()], mixins: [Tracking.mixin()],
inject: {
canAdminList: {
default: false,
},
},
props: { props: {
disabled: { disabled: {
type: Boolean, type: Boolean,
...@@ -89,8 +85,8 @@ export default { ...@@ -89,8 +85,8 @@ export default {
return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm; return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm;
}, },
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.$refs.list.$el : this.$refs.list; return this.canMoveIssue ? this.$refs.list.$el : this.$refs.list;
}, },
showingAllItems() { showingAllItems() {
return this.boardItems.length === this.listItemsCount; return this.boardItems.length === this.listItemsCount;
...@@ -100,8 +96,11 @@ export default { ...@@ -100,8 +96,11 @@ export default {
? this.$options.i18n.showingAllEpics ? this.$options.i18n.showingAllEpics
: this.$options.i18n.showingAllIssues; : this.$options.i18n.showingAllIssues;
}, },
canMoveIssue() {
return !this.disabled;
},
treeRootWrapper() { treeRootWrapper() {
return this.canAdminList && !this.listsFlags[this.list.id]?.addItemToListInProgress return this.canMoveIssue && !this.listsFlags[this.list.id]?.addItemToListInProgress
? Draggable ? Draggable
: 'ul'; : 'ul';
}, },
...@@ -116,7 +115,7 @@ export default { ...@@ -116,7 +115,7 @@ export default {
value: this.boardItems, value: this.boardItems,
}; };
return this.canAdminList ? options : {}; return this.canMoveIssue ? options : {};
}, },
}, },
watch: { watch: {
...@@ -172,15 +171,33 @@ export default { ...@@ -172,15 +171,33 @@ export default {
this.loadNextPage(); this.loadNextPage();
} }
}, },
handleDragOnStart() { handleDragOnStart({
item: {
dataset: { draggableItemType },
},
}) {
if (draggableItemType !== DraggableItemTypes.card) {
return;
}
sortableStart(); sortableStart();
this.track('drag_card', { label: 'board' }); this.track('drag_card', { label: 'board' });
}, },
handleDragOnEnd(params) { handleDragOnEnd({
newIndex: originalNewIndex,
oldIndex,
from,
to,
item: {
dataset: { draggableItemType, itemId, itemIid, itemPath },
},
}) {
if (draggableItemType !== DraggableItemTypes.card) {
return;
}
sortableEnd(); sortableEnd();
const { oldIndex, from, to, item } = params; let newIndex = originalNewIndex;
let { newIndex } = params;
const { itemId, itemIid, itemPath } = item.dataset;
let { children } = to; let { children } = to;
let moveBeforeId; let moveBeforeId;
let moveAfterId; let moveAfterId;
...@@ -267,6 +284,7 @@ export default { ...@@ -267,6 +284,7 @@ export default {
:index="index" :index="index"
:list="list" :list="list"
:item="item" :item="item"
:data-draggable-item-type="$options.draggableItemTypes.card"
:disabled="disabled" :disabled="disabled"
/> />
<gl-intersection-observer @appear="onReachingListBottom"> <gl-intersection-observer @appear="onReachingListBottom">
......
...@@ -114,6 +114,11 @@ export const FilterFields = { ...@@ -114,6 +114,11 @@ export const FilterFields = {
], ],
}; };
export const DraggableItemTypes = {
card: 'card',
list: 'list',
};
export default { export default {
BoardType, BoardType,
ListType, ListType,
......
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
issuableTypes, issuableTypes,
FilterFields, FilterFields,
ListTypeTitles, ListTypeTitles,
DraggableItemTypes,
} from 'ee_else_ce/boards/constants'; } from 'ee_else_ce/boards/constants';
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
...@@ -267,12 +268,16 @@ export default { ...@@ -267,12 +268,16 @@ export default {
{ state: { boardLists }, commit, dispatch }, { state: { boardLists }, commit, dispatch },
{ {
item: { item: {
dataset: { listId: movedListId }, dataset: { listId: movedListId, draggableItemType },
}, },
newIndex, newIndex,
to: { children }, to: { children },
}, },
) => { ) => {
if (draggableItemType !== DraggableItemTypes.list) {
return;
}
const displacedListId = children[newIndex].dataset.listId; const displacedListId = children[newIndex].dataset.listId;
if (movedListId === displacedListId) { if (movedListId === displacedListId) {
return; return;
......
...@@ -5,6 +5,7 @@ require 'spec_helper' ...@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe 'Project issue boards', :js do RSpec.describe 'Project issue boards', :js do
include DragTo include DragTo
include MobileHelpers include MobileHelpers
include BoardHelpers
let_it_be(:group) { create(:group, :nested) } let_it_be(:group) { create(:group, :nested) }
let_it_be(:project) { create(:project, :public, namespace: group) } let_it_be(:project) { create(:project, :public, namespace: group) }
...@@ -546,23 +547,6 @@ RSpec.describe 'Project issue boards', :js do ...@@ -546,23 +547,6 @@ RSpec.describe 'Project issue boards', :js do
end end
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 boards
inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do
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
wait_for_requests
end
def wait_for_board_cards(board_number, expected_cards) def wait_for_board_cards(board_number, expected_cards)
page.within(find(".board:nth-child(#{board_number})")) do page.within(find(".board:nth-child(#{board_number})")) do
expect(page.find('.board-header')).to have_content(expected_cards.to_s) expect(page.find('.board-header')).to have_content(expected_cards.to_s)
......
...@@ -3,16 +3,21 @@ ...@@ -3,16 +3,21 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Group Boards' do RSpec.describe 'Group Boards' do
let(:group) { create(:group) } include DragTo
let!(:project) { create(:project_empty_repo, group: group) } include MobileHelpers
let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } include BoardHelpers
before do let_it_be(:group) { create(:group) }
sign_in(user) let_it_be(:user) { create(:user) }
end
context 'Creates an issue', :js do
let_it_be(:project) { create(:project_empty_repo, group: group) }
context 'Creates a an issue', :js do
before do before do
group.add_maintainer(user)
sign_in(user)
visit group_boards_path(group) visit group_boards_path(group)
end end
...@@ -39,4 +44,58 @@ RSpec.describe 'Group Boards' do ...@@ -39,4 +44,58 @@ RSpec.describe 'Group Boards' do
end end
end end
end end
context "when user is a Reporter in one of the group's projects", :js do
let_it_be(:board) { create(:board, group: group) }
let_it_be(:backlog_list) { create(:backlog_list, board: board) }
let_it_be(:group_label1) { create(:group_label, title: "bug", group: group) }
let_it_be(:group_label2) { create(:group_label, title: "dev", group: group) }
let_it_be(:list1) { create(:list, board: board, label: group_label1, position: 0) }
let_it_be(:list2) { create(:list, board: board, label: group_label2, position: 1) }
let_it_be(:project1) { create(:project_empty_repo, :private, group: group) }
let_it_be(:project2) { create(:project_empty_repo, :private, group: group) }
let_it_be(:issue1) { create(:issue, title: 'issue1', project: project1, labels: [group_label2]) }
let_it_be(:issue2) { create(:issue, title: 'issue2', project: project2) }
before do
project1.add_guest(user)
project2.add_reporter(user)
sign_in(user)
inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do
visit group_boards_path(group)
end
end
it 'allows user to move issue of project where they are a Reporter' do
expect(find('.board:nth-child(1)')).to have_content(issue2.title)
drag(list_from_index: 0, from_index: 0, list_to_index: 1)
expect(find('.board:nth-child(2)')).to have_content(issue2.title)
expect(issue2.reload.labels).to contain_exactly(group_label1)
end
it 'does not allow user to move issue of project where they are a Guest' do
expect(find('.board:nth-child(3)')).to have_content(issue1.title)
drag(list_from_index: 2, from_index: 0, list_to_index: 1)
expect(find('.board:nth-child(3)')).to have_content(issue1.title)
expect(issue1.reload.labels).to contain_exactly(group_label2)
expect(issue2.reload.labels).to eq([])
end
it 'does not allow user to re-position lists' do
drag(list_from_index: 1, list_to_index: 2, selector: '.board-header')
expect(find('.board:nth-child(2) [data-testid="board-list-header"]')).to have_content(group_label1.title)
expect(find('.board:nth-child(3) [data-testid="board-list-header"]')).to have_content(group_label2.title)
expect(list1.reload.position).to eq(0)
expect(list2.reload.position).to eq(1)
end
end
end end
import Draggable from 'vuedraggable';
import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import createComponent from 'jest/boards/board_list_helper'; import createComponent from 'jest/boards/board_list_helper';
import BoardCard from '~/boards/components/board_card.vue'; import BoardCard from '~/boards/components/board_card.vue';
...@@ -10,6 +12,23 @@ describe('Board list component', () => { ...@@ -10,6 +12,23 @@ describe('Board list component', () => {
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]'); const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]');
const findDraggable = () => wrapper.findComponent(Draggable);
const startDrag = (
params = {
item: {
dataset: {
draggableItemType: DraggableItemTypes.card,
},
},
},
) => {
findByTestId('tree-root-wrapper').vm.$emit('start', params);
};
const endDrag = (params) => {
findByTestId('tree-root-wrapper').vm.$emit('end', params);
};
useFakeRequestAnimationFrame(); useFakeRequestAnimationFrame();
...@@ -155,40 +174,89 @@ describe('Board list component', () => { ...@@ -155,40 +174,89 @@ describe('Board list component', () => {
}); });
describe('drag & drop issue', () => { describe('drag & drop issue', () => {
beforeEach(() => { describe('when dragging is allowed', () => {
wrapper = createComponent(); beforeEach(() => {
}); wrapper = createComponent({
componentProps: {
disabled: false,
},
});
});
describe('handleDragOnStart', () => { it('Draggable is used', () => {
it('adds a class `is-dragging` to document body', () => { expect(findDraggable().exists()).toBe(true);
expect(document.body.classList.contains('is-dragging')).toBe(false); });
describe('handleDragOnStart', () => {
it('adds a class `is-dragging` to document body', () => {
expect(document.body.classList.contains('is-dragging')).toBe(false);
findByTestId('tree-root-wrapper').vm.$emit('start'); startDrag();
expect(document.body.classList.contains('is-dragging')).toBe(true); expect(document.body.classList.contains('is-dragging')).toBe(true);
});
}); });
});
describe('handleDragOnEnd', () => { describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => { beforeEach(() => {
jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {});
document.body.classList.add('is-dragging');
startDrag();
});
it('removes class `is-dragging` from document body', () => {
document.body.classList.add('is-dragging');
endDrag({
oldIndex: 1,
newIndex: 0,
item: {
dataset: {
draggableItemType: DraggableItemTypes.card,
itemId: mockIssues[0].id,
itemIid: mockIssues[0].iid,
itemPath: mockIssues[0].referencePath,
},
},
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
from: { dataset: { listId: 'gid://gitlab/List/2' } },
});
findByTestId('tree-root-wrapper').vm.$emit('end', { expect(document.body.classList.contains('is-dragging')).toBe(false);
oldIndex: 1, });
newIndex: 0,
item: { it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.card}"`, () => {
dataset: { endDrag({
itemId: mockIssues[0].id, oldIndex: 1,
itemIid: mockIssues[0].iid, newIndex: 0,
itemPath: mockIssues[0].referencePath, item: {
dataset: {
draggableItemType: DraggableItemTypes.list,
itemId: mockIssues[0].id,
itemIid: mockIssues[0].iid,
itemPath: mockIssues[0].referencePath,
},
}, },
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
from: { dataset: { listId: 'gid://gitlab/List/2' } },
});
expect(document.body.classList.contains('is-dragging')).toBe(true);
});
});
});
describe('when dragging is not allowed', () => {
beforeEach(() => {
wrapper = createComponent({
componentProps: {
disabled: true,
}, },
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
from: { dataset: { listId: 'gid://gitlab/List/2' } },
}); });
});
expect(document.body.classList.contains('is-dragging')).toBe(false); it('Draggable is not used', () => {
expect(findDraggable().exists()).toBe(false);
}); });
}); });
}); });
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
issuableTypes, issuableTypes,
BoardType, BoardType,
listsQuery, listsQuery,
DraggableItemTypes,
} from 'ee_else_ce/boards/constants'; } from 'ee_else_ce/boards/constants';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
...@@ -525,6 +526,21 @@ describe('moveList', () => { ...@@ -525,6 +526,21 @@ describe('moveList', () => {
const movableListsOrder = ['gid://3', 'gid://4', 'gid://5']; const movableListsOrder = ['gid://3', 'gid://4', 'gid://5'];
const allListsOrder = [backlogListId, ...movableListsOrder, closedListId]; const allListsOrder = [backlogListId, ...movableListsOrder, closedListId];
it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.list}"`, () => {
return testAction({
action: actions.moveList,
payload: {
item: { dataset: { listId: '', draggableItemType: DraggableItemTypes.card } },
to: {
children: [],
},
},
state: {},
expectedMutations: [],
expectedActions: [],
});
});
describe.each` describe.each`
draggableFrom | draggableTo | boardLists | boardListsOrder | expectedMovableListsOrder draggableFrom | draggableTo | boardLists | boardListsOrder | expectedMovableListsOrder
${0} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://5', 'gid://3']} ${0} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://5', 'gid://3']}
...@@ -544,7 +560,12 @@ describe('moveList', () => { ...@@ -544,7 +560,12 @@ describe('moveList', () => {
const displacedListId = boardListsOrder[draggableTo]; const displacedListId = boardListsOrder[draggableTo];
const buildDraggablePayload = () => { const buildDraggablePayload = () => {
return { return {
item: { dataset: { listId: boardListsOrder[draggableFrom] } }, item: {
dataset: {
listId: boardListsOrder[draggableFrom],
draggableItemType: DraggableItemTypes.list,
},
},
newIndex: draggableTo, newIndex: draggableTo,
to: { to: {
children: boardListsOrder.map((listId) => ({ dataset: { listId } })), children: boardListsOrder.map((listId) => ({ dataset: { listId } })),
...@@ -584,7 +605,7 @@ describe('moveList', () => { ...@@ -584,7 +605,7 @@ describe('moveList', () => {
return testAction({ return testAction({
action: actions.moveList, action: actions.moveList,
payload: { payload: {
item: { dataset: { listId } }, item: { dataset: { listId, draggbaleItemType: DraggableItemTypes.list } },
newIndex: 0, newIndex: 0,
to: { to: {
children: [{ dataset: { listId } }], children: [{ dataset: { listId } }],
......
...@@ -23,4 +23,21 @@ module BoardHelpers ...@@ -23,4 +23,21 @@ module BoardHelpers
wait_for_requests wait_for_requests
end end
end end
def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0, perform_drop: true)
inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do
# ensure there is enough horizontal space for four board 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
wait_for_requests
end
end end
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