Commit a249542e authored by Florie Guibert's avatar Florie Guibert

Swimlanes - Reorder lists

Use drag & drop to reorder lists on board swimlanes
parent b221119c
<script>
import { mapActions } from 'vuex';
import {
GlButton,
GlButtonGroup,
......@@ -17,6 +18,7 @@ import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
import { ListType } from '../constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
......@@ -32,7 +34,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [isWipLimitsOn],
mixins: [isWipLimitsOn, glFeatureFlagMixin()],
props: {
list: {
type: Object,
......@@ -128,6 +130,7 @@ export default {
},
},
methods: {
...mapActions(['updateList']),
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
},
......@@ -144,8 +147,12 @@ export default {
}
if (this.isLoggedIn) {
if (this.glFeatures.boardsWithSwimlanes && this.isSwimlanesHeader) {
this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded });
} else {
this.list.update();
}
}
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
// Close all tooltips manually to prevent dangling tooltips.
......
......@@ -47,7 +47,7 @@ class List {
this.loading = true;
this.loadingMore = false;
this.issues = obj.issues || [];
this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
this.issuesSize = obj.issuesSize || obj.issuesCount || 0;
this.maxIssueCount = obj.maxIssueCount || obj.max_issue_count || 0;
if (obj.label) {
......
......@@ -4,6 +4,7 @@ fragment BoardListShared on BoardList {
position
listType
collapsed
issuesCount
label {
id
title
......
#import "./board_list.fragment.graphql"
mutation UpdateBoardList($listId: ID!, $position: Int, $collapsed: Boolean) {
updateBoardList(input: { listId: $listId, position: $position, collapsed: $collapsed }) {
list {
...BoardListFragment
}
errors
}
}
......@@ -15,6 +15,7 @@ import projectListsIssuesQuery from '../queries/project_lists_issues.query.graph
import projectBoardQuery from '../queries/project_board.query.graphql';
import groupBoardQuery from '../queries/group_board.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -147,8 +148,40 @@ export default {
notImplemented();
},
updateList: () => {
notImplemented();
moveList: ({ state, commit, dispatch }, { listId, position, moveNewIndexListUp }) => {
const { boardLists } = state;
const movedList = boardLists.find(({ id }) => id === listId);
const listAtNewIndex = boardLists[position + 1];
movedList.position = position;
listAtNewIndex.position = moveNewIndexListUp
? listAtNewIndex.position + 1
: listAtNewIndex.position - 1;
commit(types.MOVE_LIST, {
movedList,
listAtNewIndex,
});
dispatch('updateList', { listId, position });
},
updateList: ({ commit }, { listId, position, collapsed }) => {
gqlClient
.mutate({
mutation: updateBoardListMutation,
variables: {
listId,
position,
collapsed,
},
})
.then(({ data }) => {
if (data?.updateBoardList?.errors.length) {
commit(types.UPDATE_LIST_FAILURE);
}
})
.catch(() => {
commit(types.UPDATE_LIST_FAILURE);
});
},
deleteList: () => {
......
......@@ -858,21 +858,6 @@ const boardsStore = {
},
refreshIssueData(issue, obj) {
// issue.id = obj.id;
// issue.iid = obj.iid;
// issue.title = obj.title;
// issue.confidential = obj.confidential;
// issue.dueDate = obj.due_date || obj.dueDate;
// issue.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
// issue.referencePath = obj.reference_path || obj.referencePath;
// issue.path = obj.real_path || obj.webUrl;
// issue.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
// issue.project_id = obj.project_id;
// issue.timeEstimate = obj.time_estimate || obj.timeEstimate;
// issue.assignableLabelsEndpoint = obj.assignable_labels_endpoint;
// issue.blocked = obj.blocked;
// issue.epic = obj.epic;
const convertedObj = convertObjectPropsToCamelCase(obj, {
dropKeys: ['issue_sidebar_endpoint', 'real_path', 'webUrl'],
});
......
......@@ -7,9 +7,8 @@ export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST';
export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST';
export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
export const REQUEST_UPDATE_LIST = 'REQUEST_UPDATE_LIST';
export const RECEIVE_UPDATE_LIST_SUCCESS = 'RECEIVE_UPDATE_LIST_SUCCESS';
export const RECEIVE_UPDATE_LIST_ERROR = 'RECEIVE_UPDATE_LIST_ERROR';
export const MOVE_LIST = 'MOVE_LIST';
export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST';
export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS';
export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR';
......
import Vue from 'vue';
import { sortBy } from 'lodash';
import * as mutationTypes from './mutation_types';
import { __ } from '~/locale';
......@@ -44,16 +46,18 @@ export default {
notImplemented();
},
[mutationTypes.REQUEST_UPDATE_LIST]: () => {
notImplemented();
[mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => {
const { boardLists } = state;
state.boardListsPreviousState = boardLists;
const movedListIndex = state.boardLists.findIndex(l => l.id === movedList.id);
Vue.set(boardLists, movedListIndex, movedList);
Vue.set(boardLists, movedListIndex.position + 1, listAtNewIndex);
state.boardLists = sortBy(boardLists, 'position');
},
[mutationTypes.RECEIVE_UPDATE_LIST_SUCCESS]: () => {
notImplemented();
},
[mutationTypes.RECEIVE_UPDATE_LIST_ERROR]: () => {
notImplemented();
[mutationTypes.UPDATE_LIST_FAILURE]: state => {
state.boardLists = state.boardListsPreviousState;
state.error = __('An error occurred while updating the list. Please try again.');
},
[mutationTypes.REQUEST_REMOVE_LIST]: () => {
......
......@@ -9,6 +9,7 @@ export default () => ({
activeId: inactiveId,
sidebarType: '',
boardLists: [],
boardListsPreviousState: [],
issuesByListId: {},
issues: {},
isLoadingIssues: false,
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { draggableTag } from '../constants';
import defaultSortableConfig from '~/sortable/sortable_config';
import { n__ } from '~/locale';
import EpicLane from './epic_lane.vue';
import IssuesLaneList from './issues_lane_list.vue';
......@@ -53,12 +56,44 @@ export default {
unassignedIssuesCountTooltipText() {
return n__(`%d unassigned issue`, `%d unassigned issues`, this.unassignedIssuesCount);
},
treeRootWrapper() {
return this.canAdminList ? Draggable : draggableTag;
},
treeRootOptions() {
const options = {
...defaultSortableConfig,
fallbackOnBody: false,
group: 'board-swimlanes',
tag: draggableTag,
draggable: '.is-draggable',
'ghost-class': 'swimlane-header-drag-active',
value: this.lists,
};
return this.canAdminList ? options : {};
},
},
mounted() {
this.fetchIssuesForAllLists();
},
methods: {
...mapActions(['fetchIssuesForAllLists']),
...mapActions(['fetchIssuesForAllLists', 'moveList']),
handleDragOnEnd(params) {
const { newIndex, oldIndex, item } = params;
const { listId } = item.dataset;
const newPosition = newIndex - 1;
let moveNewIndexListUp = false;
if (newIndex < oldIndex) {
moveNewIndexListUp = true;
}
this.moveList({
listId,
position: newPosition,
moveNewIndexListUp,
});
},
},
};
</script>
......@@ -68,16 +103,22 @@ export default {
class="board-swimlanes gl-white-space-nowrap gl-pb-5 gl-px-3"
data_qa_selector="board_epics_swimlanes"
>
<div
<component
:is="treeRootWrapper"
v-bind="treeRootOptions"
class="board-swimlanes-headers gl-display-table gl-sticky gl-pt-5 gl-bg-white gl-top-0 gl-z-index-3"
@end="handleDragOnEnd"
>
<div
v-for="list in lists"
:key="list.id"
:class="{
'is-collapsed': !list.isExpanded,
'is-draggable': !list.preset,
}"
class="board gl-px-3 gl-vertical-align-top gl-white-space-normal"
:data-list-id="list.id"
data-testid="board-header-container"
>
<board-list-header
:can-admin-list="canAdminList"
......@@ -87,7 +128,7 @@ export default {
:is-swimlanes-header="true"
/>
</div>
</div>
</component>
<div class="board-epics-swimlanes gl-display-table">
<epic-lane
v-for="epic in epics"
......
export const draggableTag = 'div';
export default {
draggableTag,
};
......@@ -14,7 +14,7 @@ const EE_TYPES = {
class ListEE extends List {
constructor(...args) {
super(...args);
this.totalWeight = 0;
this.totalWeight = args[0]?.totalWeight || 0;
}
getTypeInfo(type) {
......
......@@ -3,6 +3,7 @@
fragment BoardListFragment on BoardList {
...BoardListShared
maxIssueCount
totalWeight
assignee {
id
name
......
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Draggable from 'vuedraggable';
import EpicsSwimlanes from 'ee/boards/components/epics_swimlanes.vue';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import EpicLane from 'ee/boards/components/epic_lane.vue';
import IssueLaneList from 'ee/boards/components/issues_lane_list.vue';
import getters from 'ee/boards/stores/getters';
import { draggableTag } from 'ee/boards/constants';
import { GlIcon } from '@gitlab/ui';
import { mockListsWithModel, mockEpics, mockIssuesByListId, issues } from '../mock_data';
......@@ -29,7 +31,7 @@ describe('EpicsSwimlanes', () => {
});
};
const createComponent = () => {
const createComponent = (props = {}) => {
const store = createStore();
const defaultProps = {
lists: mockListsWithModel,
......@@ -40,7 +42,7 @@ describe('EpicsSwimlanes', () => {
wrapper = shallowMount(EpicsSwimlanes, {
localVue,
propsData: defaultProps,
propsData: { ...defaultProps, ...props },
store,
});
};
......@@ -49,6 +51,49 @@ describe('EpicsSwimlanes', () => {
wrapper.destroy();
});
describe('computed', () => {
beforeEach(() => {
createComponent();
});
describe('treeRootWrapper', () => {
it('should return Draggable reference when canAdminList prop is true', () => {
wrapper.setProps({ canAdminList: true });
expect(wrapper.vm.treeRootWrapper).toBe(Draggable);
});
it('should return string "div" when canAdminList prop is false', () => {
expect(wrapper.vm.treeRootWrapper).toBe(draggableTag);
});
});
describe('treeRootOptions', () => {
it('should return object containing Vue.Draggable config extended from `defaultSortableConfig` when canAdminList prop is true', () => {
wrapper.setProps({ canAdminList: true });
expect(wrapper.vm.treeRootOptions).toEqual(
expect.objectContaining({
animation: 200,
forceFallback: true,
fallbackClass: 'is-dragging',
fallbackOnBody: false,
ghostClass: 'is-ghost',
group: 'board-swimlanes',
tag: draggableTag,
draggable: '.is-draggable',
'ghost-class': 'swimlane-header-drag-active',
value: mockListsWithModel,
}),
);
});
it('should return an empty object when canAdminList prop is false', () => {
expect(wrapper.vm.treeRootOptions).toEqual(expect.objectContaining({}));
});
});
});
describe('template', () => {
beforeEach(() => {
createComponent();
......@@ -68,7 +113,25 @@ describe('EpicsSwimlanes', () => {
it('displays issues icon and count for unassigned issue', () => {
expect(wrapper.find(GlIcon).props('name')).toEqual('issues');
expect(wrapper.find('[data-testid="issues-lane-issue-count"').text()).toEqual('2');
expect(wrapper.find('[data-testid="issues-lane-issue-count"]').text()).toEqual('2');
});
it('makes non preset lists draggable', () => {
expect(
wrapper
.findAll('[data-testid="board-header-container"]')
.at(1)
.classes(),
).toContain('is-draggable');
});
it('does not make preset lists draggable', () => {
expect(
wrapper
.findAll('[data-testid="board-header-container"]')
.at(0)
.classes(),
).not.toContain('is-draggable');
});
});
});
......@@ -12,6 +12,7 @@ export const mockLists = [
maxIssueCount: 0,
assignee: null,
milestone: null,
preset: true,
},
{
id: 'gid://gitlab/List/2',
......@@ -29,6 +30,7 @@ export const mockLists = [
maxIssueCount: 0,
assignee: null,
milestone: null,
preset: false,
},
];
......
......@@ -2870,6 +2870,9 @@ msgstr ""
msgid "An error occurred while updating the comment"
msgstr ""
msgid "An error occurred while updating the list. Please try again."
msgstr ""
msgid "An error occurred while validating group path"
msgstr ""
......
import testAction from 'helpers/vuex_action_helper';
import { mockListsWithModel } from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import { inactiveId, ListType } from '~/boards/constants';
......@@ -160,8 +161,58 @@ describe('createList', () => {
});
});
describe('moveList', () => {
it('should commit MOVE_LIST mutation and dispatch updateList action', done => {
const state = {
endpoints: { fullPath: 'gitlab-org', boardId: '1' },
boardType: 'group',
disabled: false,
boardLists: mockListsWithModel,
};
testAction(
actions.moveList,
{ listId: 'gid://gitlab/List/1', position: 0, moveNewIndexListUp: true },
state,
[
{
type: types.MOVE_LIST,
payload: { movedList: mockListsWithModel[0], listAtNewIndex: mockListsWithModel[1] },
},
],
[{ type: 'updateList', payload: { listId: 'gid://gitlab/List/1', position: 0 } }],
done,
);
});
});
describe('updateList', () => {
expectNotImplemented(actions.updateList);
it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateBoardList: {
list: {},
errors: [{ foo: 'bar' }],
},
},
});
const state = {
endpoints: { fullPath: 'gitlab-org', boardId: '1' },
boardType: 'group',
disabled: false,
boardLists: [{ type: 'closed' }],
};
testAction(
actions.updateList,
{ listId: 'gid://gitlab/List/1', position: 1 },
state,
[{ type: types.UPDATE_LIST_FAILURE }],
[],
done,
);
});
});
describe('deleteList', () => {
......
import mutations from '~/boards/stores/mutations';
import * as types from '~/boards/stores/mutation_types';
import defaultState from '~/boards/stores/state';
import { listObj, listObjDuplicate, mockIssue } from '../mock_data';
import { listObj, listObjDuplicate, mockIssue, mockListsWithModel } from '../mock_data';
const expectNotImplemented = action => {
it('is not implemented', () => {
......@@ -92,16 +92,37 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_ADD_LIST_ERROR);
});
describe('REQUEST_UPDATE_LIST', () => {
expectNotImplemented(mutations.REQUEST_UPDATE_LIST);
describe('MOVE_LIST', () => {
it('updates boardLists state with reordered lists and saves previous order', () => {
state = {
...state,
boardLists: mockListsWithModel,
};
mutations.MOVE_LIST(state, {
movedList: mockListsWithModel[0],
listAtNewIndex: mockListsWithModel[1],
});
describe('RECEIVE_UPDATE_LIST_SUCCESS', () => {
expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_SUCCESS);
expect(state.boardLists).toEqual([mockListsWithModel[1], mockListsWithModel[0]]);
expect(state.boardListsPreviousState).toEqual(mockListsWithModel);
});
});
describe('RECEIVE_UPDATE_LIST_ERROR', () => {
expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_ERROR);
describe('UPDATE_LIST_FAILURE', () => {
it('updates boardLists state with previous order and sets error message', () => {
state = {
...state,
boardLists: [mockListsWithModel[1], mockListsWithModel[0]],
boardListsPreviousState: mockListsWithModel,
error: undefined,
};
mutations.UPDATE_LIST_FAILURE(state);
expect(state.boardLists).toEqual(mockListsWithModel);
expect(state.error).toEqual('An error occurred while updating the list. Please try again.');
});
});
describe('REQUEST_REMOVE_LIST', () => {
......
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