Commit 0845227d authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '289797-boards-refactor-issue-multi-select' into 'master'

Boards - Support multi select on GraphQL boards [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!52042
parents 5e633180 7b0baae0
<script> <script>
import BoardCardLayout from './board_card_layout.vue'; import BoardCardLayout from './board_card_layout.vue';
import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
import boardsStore from '../stores/boards_store'; import boardsStore from '../stores/boards_store';
...@@ -7,7 +8,7 @@ import boardsStore from '../stores/boards_store'; ...@@ -7,7 +8,7 @@ import boardsStore from '../stores/boards_store';
export default { export default {
name: 'BoardsIssueCard', name: 'BoardsIssueCard',
components: { components: {
BoardCardLayout, BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated,
}, },
props: { props: {
list: { list: {
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import IssueCardInner from './issue_card_inner.vue'; import IssueCardInner from './issue_card_inner.vue';
import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ISSUABLE } from '~/boards/constants'; import { ISSUABLE } from '~/boards/constants';
export default { export default {
name: 'BoardCardLayout', name: 'BoardCardLayout',
components: { components: {
IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated, IssueCardInner,
}, },
mixins: [glFeatureFlagMixin()],
props: { props: {
list: { list: {
type: Object, type: Object,
...@@ -42,17 +38,17 @@ export default { ...@@ -42,17 +38,17 @@ export default {
data() { data() {
return { return {
showDetail: false, showDetail: false,
multiSelect: boardsStore.multiSelect,
}; };
}, },
computed: { computed: {
...mapState(['selectedBoardItems']),
...mapGetters(['isSwimlanesOn']), ...mapGetters(['isSwimlanesOn']),
multiSelectVisible() { multiSelectVisible() {
return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1; return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1;
}, },
}, },
methods: { methods: {
...mapActions(['setActiveId']), ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']),
mouseDown() { mouseDown() {
this.showDetail = true; this.showDetail = true;
}, },
...@@ -63,16 +59,16 @@ export default { ...@@ -63,16 +59,16 @@ export default {
// Don't do anything if this happened on a no trigger element // Don't do anything if this happened on a no trigger element
if (e.target.classList.contains('js-no-trigger')) return; if (e.target.classList.contains('js-no-trigger')) return;
if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) { const isMultiSelect = e.ctrlKey || e.metaKey;
if (!isMultiSelect) {
this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
return; } else {
this.toggleBoardItemMultiSelection(this.issue);
} }
const isMultiSelect = e.ctrlKey || e.metaKey;
if (this.showDetail || isMultiSelect) { if (this.showDetail || isMultiSelect) {
this.showDetail = false; this.showDetail = false;
this.$emit('show', { event: e, isMultiSelect });
} }
}, },
}, },
......
<script>
import { mapActions, mapGetters } from 'vuex';
import IssueCardInner from './issue_card_inner.vue';
import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ISSUABLE } from '~/boards/constants';
export default {
name: 'BoardCardLayout',
components: {
IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated,
},
mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
default: () => ({}),
required: false,
},
issue: {
type: Object,
default: () => ({}),
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
index: {
type: Number,
default: 0,
required: false,
},
isActive: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
showDetail: false,
multiSelect: boardsStore.multiSelect,
};
},
computed: {
...mapGetters(['isSwimlanesOn']),
multiSelectVisible() {
return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1;
},
},
methods: {
...mapActions(['setActiveId']),
mouseDown() {
this.showDetail = true;
},
mouseMove() {
this.showDetail = false;
},
showIssue(e) {
// Don't do anything if this happened on a no trigger element
if (e.target.classList.contains('js-no-trigger')) return;
if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) {
this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
return;
}
const isMultiSelect = e.ctrlKey || e.metaKey;
if (this.showDetail || isMultiSelect) {
this.showDetail = false;
this.$emit('show', { event: e, isMultiSelect });
}
},
},
};
</script>
<template>
<li
:class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'is-active': isActive,
}"
:index="index"
:data-issue-id="issue.id"
:data-issue-iid="issue.iid"
:data-issue-path="issue.referencePath"
data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="showIssue($event)"
>
<issue-card-inner :list="list" :issue="issue" :update-filters="true" />
</li>
</template>
...@@ -534,6 +534,17 @@ export default { ...@@ -534,6 +534,17 @@ export default {
commit(types.SET_SELECTED_PROJECT, project); commit(types.SET_SELECTED_PROJECT, project);
}, },
toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => {
const { selectedBoardItems } = state;
const index = selectedBoardItems.indexOf(boardItem);
if (index === -1) {
commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem);
} else {
commit(types.REMOVE_BOARD_ITEM_FROM_SELECTION, boardItem);
}
},
fetchBacklog: () => { fetchBacklog: () => {
notImplemented(); notImplemented();
}, },
......
...@@ -40,3 +40,5 @@ export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; ...@@ -40,3 +40,5 @@ export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS'; export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS';
export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE'; export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE';
export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT'; export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
export const ADD_BOARD_ITEM_TO_SELECTION = 'ADD_BOARD_ITEM_TO_SELECTION';
export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTION';
...@@ -258,4 +258,16 @@ export default { ...@@ -258,4 +258,16 @@ export default {
[mutationTypes.SET_SELECTED_PROJECT]: (state, project) => { [mutationTypes.SET_SELECTED_PROJECT]: (state, project) => {
state.selectedProject = project; state.selectedProject = project;
}, },
[mutationTypes.ADD_BOARD_ITEM_TO_SELECTION]: (state, boardItem) => {
state.selectedBoardItems = [...state.selectedBoardItems, boardItem];
},
[mutationTypes.REMOVE_BOARD_ITEM_FROM_SELECTION]: (state, boardItem) => {
Vue.set(
state,
'selectedBoardItems',
state.selectedBoardItems.filter((obj) => obj !== boardItem),
);
},
}; };
...@@ -15,6 +15,7 @@ export default () => ({ ...@@ -15,6 +15,7 @@ export default () => ({
filterParams: {}, filterParams: {},
boardConfig: {}, boardConfig: {},
labels: [], labels: [],
selectedBoardItems: [],
groupProjects: [], groupProjects: [],
groupProjectsFlags: { groupProjectsFlags: {
isLoading: false, isLoading: false,
......
/* global List */
/* global ListLabel */
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/list';
import boardsVuexStore from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue';
import issueCardInner from '~/boards/components/issue_card_inner.vue';
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
import { ISSUABLE } from '~/boards/constants';
describe('Board card layout', () => {
let wrapper;
let mock;
let list;
let store;
const localVue = createLocalVue();
localVue.use(Vuex);
const createStore = ({ getters = {}, actions = {} } = {}) => {
store = new Vuex.Store({
...boardsVuexStore,
actions,
getters,
});
};
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
wrapper = shallowMount(BoardCardLayout, {
localVue,
stubs: {
issueCardInner,
},
store,
propsData: {
list,
issue: list.issues[0],
disabled: false,
index: 0,
...propsData,
},
provide: {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
...provide,
},
});
};
const setupData = () => {
list = new List(listObj);
boardsStore.create();
boardsStore.detail.issue = {};
const label1 = new ListLabel({
id: 3,
title: 'testing 123',
color: '#000cff',
text_color: 'white',
description: 'test',
});
return waitForPromises().then(() => {
list.issues[0].labels.push(label1);
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
setMockEndpoints();
return setupData();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
list = null;
mock.restore();
});
describe('mouse events', () => {
it('sets showDetail to true on mousedown', async () => {
createStore();
mountComponent();
wrapper.trigger('mousedown');
await wrapper.vm.$nextTick();
expect(wrapper.vm.showDetail).toBe(true);
});
it('sets showDetail to false on mousemove', async () => {
createStore();
mountComponent();
wrapper.trigger('mousedown');
await wrapper.vm.$nextTick();
expect(wrapper.vm.showDetail).toBe(true);
wrapper.trigger('mousemove');
await wrapper.vm.$nextTick();
expect(wrapper.vm.showDetail).toBe(false);
});
it("calls 'setActiveId' when 'graphqlBoardLists' feature flag is turned on", async () => {
const setActiveId = jest.fn();
createStore({
actions: {
setActiveId,
},
});
mountComponent({
provide: {
glFeatures: { graphqlBoardLists: true },
},
});
wrapper.trigger('mouseup');
await wrapper.vm.$nextTick();
expect(setActiveId).toHaveBeenCalledTimes(1);
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
id: list.issues[0].id,
sidebarType: ISSUABLE,
});
});
it("calls 'setActiveId' when epic swimlanes is active", async () => {
const setActiveId = jest.fn();
const isSwimlanesOn = () => true;
createStore({
getters: { isSwimlanesOn },
actions: {
setActiveId,
},
});
mountComponent();
wrapper.trigger('mouseup');
await wrapper.vm.$nextTick();
expect(setActiveId).toHaveBeenCalledTimes(1);
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
id: list.issues[0].id,
sidebarType: ISSUABLE,
});
});
});
});
/* global List */
/* global ListLabel */
import Vuex from 'vuex'; import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import defaultState from '~/boards/stores/state';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/list';
import boardsVuexStore from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import BoardCardLayout from '~/boards/components/board_card_layout.vue'; import BoardCardLayout from '~/boards/components/board_card_layout.vue';
import issueCardInner from '~/boards/components/issue_card_inner.vue'; import IssueCardInner from '~/boards/components/issue_card_inner.vue';
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; import { mockLabelList, mockIssue } from '../mock_data';
import { ISSUABLE } from '~/boards/constants'; import { ISSUABLE } from '~/boards/constants';
describe('Board card layout', () => { describe('Board card layout', () => {
let wrapper; let wrapper;
let mock;
let list;
let store; let store;
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -30,7 +17,7 @@ describe('Board card layout', () => { ...@@ -30,7 +17,7 @@ describe('Board card layout', () => {
const createStore = ({ getters = {}, actions = {} } = {}) => { const createStore = ({ getters = {}, actions = {} } = {}) => {
store = new Vuex.Store({ store = new Vuex.Store({
...boardsVuexStore, state: defaultState,
actions, actions,
getters, getters,
}); });
...@@ -41,12 +28,12 @@ describe('Board card layout', () => { ...@@ -41,12 +28,12 @@ describe('Board card layout', () => {
wrapper = shallowMount(BoardCardLayout, { wrapper = shallowMount(BoardCardLayout, {
localVue, localVue,
stubs: { stubs: {
issueCardInner, IssueCardInner,
}, },
store, store,
propsData: { propsData: {
list, list: mockLabelList,
issue: list.issues[0], issue: mockIssue,
disabled: false, disabled: false,
index: 0, index: 0,
...propsData, ...propsData,
...@@ -60,34 +47,9 @@ describe('Board card layout', () => { ...@@ -60,34 +47,9 @@ describe('Board card layout', () => {
}); });
}; };
const setupData = () => {
list = new List(listObj);
boardsStore.create();
boardsStore.detail.issue = {};
const label1 = new ListLabel({
id: 3,
title: 'testing 123',
color: '#000cff',
text_color: 'white',
description: 'test',
});
return waitForPromises().then(() => {
list.issues[0].labels.push(label1);
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
setMockEndpoints();
return setupData();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
list = null;
mock.restore();
}); });
describe('mouse events', () => { describe('mouse events', () => {
...@@ -112,25 +74,21 @@ describe('Board card layout', () => { ...@@ -112,25 +74,21 @@ describe('Board card layout', () => {
expect(wrapper.vm.showDetail).toBe(false); expect(wrapper.vm.showDetail).toBe(false);
}); });
it("calls 'setActiveId' when 'graphqlBoardLists' feature flag is turned on", async () => { it("calls 'setActiveId'", async () => {
const setActiveId = jest.fn(); const setActiveId = jest.fn();
createStore({ createStore({
actions: { actions: {
setActiveId, setActiveId,
}, },
}); });
mountComponent({ mountComponent();
provide: {
glFeatures: { graphqlBoardLists: true },
},
});
wrapper.trigger('mouseup'); wrapper.trigger('mouseup');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(setActiveId).toHaveBeenCalledTimes(1); expect(setActiveId).toHaveBeenCalledTimes(1);
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
id: list.issues[0].id, id: mockIssue.id,
sidebarType: ISSUABLE, sidebarType: ISSUABLE,
}); });
}); });
...@@ -151,7 +109,7 @@ describe('Board card layout', () => { ...@@ -151,7 +109,7 @@ describe('Board card layout', () => {
expect(setActiveId).toHaveBeenCalledTimes(1); expect(setActiveId).toHaveBeenCalledTimes(1);
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
id: list.issues[0].id, id: mockIssue.id,
sidebarType: ISSUABLE, sidebarType: ISSUABLE,
}); });
}); });
......
...@@ -41,7 +41,7 @@ describe('Issue model', () => { ...@@ -41,7 +41,7 @@ describe('Issue model', () => {
}); });
expect(issue.labels.length).toBe(1); expect(issue.labels.length).toBe(1);
expect(issue.labels[0].color).toBe('red'); expect(issue.labels[0].color).toBe('#F0AD4E');
}); });
it('adds other label with same title', () => { it('adds other label with same title', () => {
......
...@@ -137,7 +137,7 @@ export const rawIssue = { ...@@ -137,7 +137,7 @@ export const rawIssue = {
{ {
id: 1, id: 1,
title: 'test', title: 'test',
color: 'red', color: '#F0AD4E',
description: 'testing', description: 'testing',
}, },
], ],
...@@ -165,7 +165,7 @@ export const mockIssue = { ...@@ -165,7 +165,7 @@ export const mockIssue = {
{ {
id: 1, id: 1,
title: 'test', title: 'test',
color: 'red', color: '#F0AD4E',
description: 'testing', description: 'testing',
}, },
], ],
......
...@@ -1222,6 +1222,40 @@ describe('setSelectedProject', () => { ...@@ -1222,6 +1222,40 @@ describe('setSelectedProject', () => {
}); });
}); });
describe('toggleBoardItemMultiSelection', () => {
const boardItem = mockIssue;
it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => {
testAction(
actions.toggleBoardItemMultiSelection,
boardItem,
{ selectedBoardItems: [] },
[
{
type: types.ADD_BOARD_ITEM_TO_SELECTION,
payload: boardItem,
},
],
[],
);
});
it('should commit mutation REMOVE_BOARD_ITEM_FROM_SELECTION if item is on selection state', () => {
testAction(
actions.toggleBoardItemMultiSelection,
boardItem,
{ selectedBoardItems: [mockIssue] },
[
{
type: types.REMOVE_BOARD_ITEM_FROM_SELECTION,
payload: boardItem,
},
],
[],
);
});
});
describe('fetchBacklog', () => { describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog); expectNotImplemented(actions.fetchBacklog);
}); });
......
...@@ -594,4 +594,27 @@ describe('Board Store Mutations', () => { ...@@ -594,4 +594,27 @@ describe('Board Store Mutations', () => {
expect(state.selectedProject).toEqual(mockGroupProjects[0]); expect(state.selectedProject).toEqual(mockGroupProjects[0]);
}); });
}); });
describe('ADD_BOARD_ITEM_TO_SELECTION', () => {
it('Should add boardItem to selectedBoardItems state', () => {
expect(state.selectedBoardItems).toEqual([]);
mutations[types.ADD_BOARD_ITEM_TO_SELECTION](state, mockIssue);
expect(state.selectedBoardItems).toEqual([mockIssue]);
});
});
describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => {
it('Should remove boardItem to selectedBoardItems state', () => {
state = {
...state,
selectedBoardItems: [mockIssue],
};
mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue);
expect(state.selectedBoardItems).toEqual([]);
});
});
}); });
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