Commit 8b044cf9 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch '218163-allow-users-to-reorder-lists-when-swimlanes-are-applied' into 'master'

Swimlanes - Reorder lists

See merge request gitlab-org/gitlab!40344
parents 40692bde 72fbaf67
<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);
},
......@@ -136,20 +139,28 @@ export default {
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
toggleExpanded() {
if (this.list.isExpandable) {
this.list.isExpanded = !this.list.isExpanded;
this.list.isExpanded = !this.list.isExpanded;
if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) {
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
}
if (this.isLoggedIn) {
this.list.update();
}
if (!this.isLoggedIn) {
this.addToLocalStorage();
} else {
this.updateListFunction();
}
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
// Close all tooltips manually to prevent dangling tooltips.
this.$root.$emit('bv::hide::tooltip');
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
// Close all tooltips manually to prevent dangling tooltips.
this.$root.$emit('bv::hide::tooltip');
},
addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
}
},
updateListFunction() {
if (this.glFeatures.boardsWithSwimlanes && this.isSwimlanesHeader) {
this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded });
} else {
this.list.update();
}
},
},
......
......@@ -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,42 @@ export default {
notImplemented();
},
updateList: () => {
notImplemented();
moveList: ({ state, commit, dispatch }, { listId, newIndex, adjustmentValue }) => {
const { boardLists } = state;
const backupList = [...boardLists];
const movedList = boardLists.find(({ id }) => id === listId);
const newPosition = newIndex - 1;
const listAtNewIndex = boardLists[newIndex];
movedList.position = newPosition;
listAtNewIndex.position += adjustmentValue;
commit(types.MOVE_LIST, {
movedList,
listAtNewIndex,
});
dispatch('updateList', { listId, position: newPosition, backupList });
},
updateList: ({ commit }, { listId, position, collapsed, backupList }) => {
gqlClient
.mutate({
mutation: updateBoardListMutation,
variables: {
listId,
position,
collapsed,
},
})
.then(({ data }) => {
if (data?.updateBoardList?.errors.length) {
commit(types.UPDATE_LIST_FAILURE, backupList);
}
})
.catch(() => {
commit(types.UPDATE_LIST_FAILURE, backupList);
});
},
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,17 @@ export default {
notImplemented();
},
[mutationTypes.REQUEST_UPDATE_LIST]: () => {
notImplemented();
[mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => {
const { boardLists } = state;
const movedListIndex = state.boardLists.findIndex(l => l.id === movedList.id);
Vue.set(boardLists, movedListIndex, movedList);
Vue.set(boardLists, movedListIndex.position + 1, listAtNewIndex);
Vue.set(state, 'boardLists', sortBy(boardLists, 'position'));
},
[mutationTypes.RECEIVE_UPDATE_LIST_SUCCESS]: () => {
notImplemented();
},
[mutationTypes.RECEIVE_UPDATE_LIST_ERROR]: () => {
notImplemented();
[mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => {
state.error = __('An error occurred while updating the list. Please try again.');
Vue.set(state, 'boardLists', backupList);
},
[mutationTypes.REQUEST_REMOVE_LIST]: () => {
......
......@@ -116,7 +116,6 @@
.board-title {
flex-direction: column;
height: 100%;
}
.board-title-caret {
......
<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 { DRAGGABLE_TAG } 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,38 @@ export default {
unassignedIssuesCountTooltipText() {
return n__(`%d unassigned issue`, `%d unassigned issues`, this.unassignedIssuesCount);
},
treeRootWrapper() {
return this.canAdminList ? Draggable : DRAGGABLE_TAG;
},
treeRootOptions() {
const options = {
...defaultSortableConfig,
fallbackOnBody: false,
group: 'board-swimlanes',
tag: DRAGGABLE_TAG,
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;
this.moveList({
listId,
newIndex,
adjustmentValue: newIndex < oldIndex ? 1 : -1,
});
},
},
};
</script>
......@@ -68,16 +97,23 @@ 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"
data-testid="board-swimlanes-headers"
@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 +123,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 DRAGGABLE_TAG = 'div';
export default {
DRAGGABLE_TAG,
};
......@@ -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';
......@@ -29,7 +30,7 @@ describe('EpicsSwimlanes', () => {
});
};
const createComponent = () => {
const createComponent = (props = {}) => {
const store = createStore();
const defaultProps = {
lists: mockListsWithModel,
......@@ -40,7 +41,7 @@ describe('EpicsSwimlanes', () => {
wrapper = shallowMount(EpicsSwimlanes, {
localVue,
propsData: defaultProps,
propsData: { ...defaultProps, ...props },
store,
});
};
......@@ -49,6 +50,30 @@ describe('EpicsSwimlanes', () => {
wrapper.destroy();
});
describe('computed', () => {
describe('treeRootWrapper', () => {
describe('when canAdminList prop is true', () => {
beforeEach(() => {
createComponent({ canAdminList: true });
});
it('should return Draggable reference when canAdminList prop is true', () => {
expect(wrapper.find(Draggable).exists()).toBe(true);
});
});
describe('when canAdminList prop is false', () => {
beforeEach(() => {
createComponent();
});
it('should not return Draggable reference when canAdminList prop is false', () => {
expect(wrapper.find(Draggable).exists()).toBe(false);
});
});
});
});
describe('template', () => {
beforeEach(() => {
createComponent();
......@@ -68,7 +93,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,
},
];
......
......@@ -2873,6 +2873,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,63 @@ 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', newIndex: 1, adjustmentValue: 1 },
state,
[
{
type: types.MOVE_LIST,
payload: { movedList: mockListsWithModel[0], listAtNewIndex: mockListsWithModel[1] },
},
],
[
{
type: 'updateList',
payload: { listId: 'gid://gitlab/List/1', position: 0, backupList: mockListsWithModel },
},
],
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,35 @@ 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', () => {
state = {
...state,
boardLists: mockListsWithModel,
};
describe('RECEIVE_UPDATE_LIST_SUCCESS', () => {
expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_SUCCESS);
mutations.MOVE_LIST(state, {
movedList: mockListsWithModel[0],
listAtNewIndex: mockListsWithModel[1],
});
expect(state.boardLists).toEqual([mockListsWithModel[1], mockListsWithModel[0]]);
});
});
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]],
error: undefined,
};
mutations.UPDATE_LIST_FAILURE(state, mockListsWithModel);
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