Commit 9c8f090c authored by Simon Knox's avatar Simon Knox

Merge branch 'fix-card-hightlighting-in-new-boards' into 'master'

Fix selected item not being highlighted in the new group boards

See merge request gitlab-org/gitlab!53113
parents 4fe894dc b64093f8
<script>
import sidebarEventHub from '~/sidebar/event_hub';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import BoardCardLayout from './board_card_layout.vue';
import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
import { mapActions, mapGetters, mapState } from 'vuex';
import IssueCardInner from './issue_card_inner.vue';
export default {
name: 'BoardsIssueCard',
name: 'BoardCard',
components: {
BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated,
IssueCardInner,
},
props: {
list: {
......@@ -21,29 +18,41 @@ export default {
default: () => ({}),
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
index: {
type: Number,
default: 0,
required: false,
},
},
methods: {
// These are methods instead of computed's, because boardsStore is not reactive.
computed: {
...mapState(['selectedBoardItems', 'activeId']),
...mapGetters(['isSwimlanesOn']),
isActive() {
return this.getActiveId() === this.issue.id;
return this.issue.id === this.activeId;
},
getActiveId() {
return boardsStore.detail?.issue?.id;
multiSelectVisible() {
return (
!this.activeId &&
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1
);
},
showIssue({ isMultiSelect }) {
// If no issues are opened, close all sidebars first
if (!this.getActiveId()) {
sidebarEventHub.$emit('sidebar.closeAll');
}
if (this.isActive()) {
eventHub.$emit('clearDetailIssue', isMultiSelect);
},
methods: {
...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
toggleIssue(e) {
// Don't do anything if this happened on a no trigger element
if (e.target.classList.contains('js-no-trigger')) return;
if (isMultiSelect) {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
}
const isMultiSelect = e.ctrlKey || e.metaKey;
if (isMultiSelect) {
this.toggleBoardItemMultiSelection(this.issue);
} else {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list);
this.toggleBoardItem({ boardItem: this.issue });
}
},
},
......@@ -51,12 +60,22 @@ export default {
</script>
<template>
<board-card-layout
<li
data-qa-selector="board_card"
:issue="issue"
:list="list"
:is-active="isActive()"
v-bind="$attrs"
@show="showIssue"
/>
: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"
@mouseup="toggleIssue($event)"
>
<issue-card-inner :list="list" :issue="issue" :update-filters="true" />
</li>
</template>
<script>
// This component is being replaced in favor of './board_card.vue' for GraphQL boards
import sidebarEventHub from '~/sidebar/event_hub';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
export default {
components: {
BoardCardLayout: BoardCardLayoutDeprecated,
},
props: {
list: {
type: Object,
default: () => ({}),
required: false,
},
issue: {
type: Object,
default: () => ({}),
required: false,
},
},
methods: {
// These are methods instead of computed's, because boardsStore is not reactive.
isActive() {
return this.getActiveId() === this.issue.id;
},
getActiveId() {
return boardsStore.detail?.issue?.id;
},
showIssue({ isMultiSelect }) {
// If no issues are opened, close all sidebars first
if (!this.getActiveId()) {
sidebarEventHub.$emit('sidebar.closeAll');
}
if (this.isActive()) {
eventHub.$emit('clearDetailIssue', isMultiSelect);
if (isMultiSelect) {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
}
} else {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list);
}
},
},
};
</script>
<template>
<board-card-layout
data-qa-selector="board_card"
:issue="issue"
:list="list"
:is-active="isActive()"
v-bind="$attrs"
@show="showIssue"
/>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { ISSUABLE } from '~/boards/constants';
import IssueCardInner from './issue_card_inner.vue';
export default {
name: 'BoardCardLayout',
components: {
IssueCardInner,
},
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,
};
},
computed: {
...mapState(['selectedBoardItems']),
...mapGetters(['isSwimlanesOn']),
multiSelectVisible() {
return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1;
},
},
methods: {
...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']),
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;
const isMultiSelect = e.ctrlKey || e.metaKey;
if (!isMultiSelect) {
this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
} else {
this.toggleBoardItemMultiSelection(this.issue);
}
if (this.showDetail || isMultiSelect) {
this.showDetail = false;
}
},
},
};
</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>
......@@ -230,7 +230,11 @@ export default {
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
<gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" />
<gl-loading-icon
v-if="loadingMore"
:label="$options.i18n.loadingMoreissues"
data-testid="count-loading-icon"
/>
<span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
......
......@@ -11,7 +11,7 @@ import {
sortableEnd,
} from '../mixins/sortable_default_options';
import boardsStore from '../stores/boards_store';
import boardCard from './board_card.vue';
import boardCard from './board_card_deprecated.vue';
import boardNewIssue from './board_new_issue_deprecated.vue';
// This component is being replaced in favor of './board_list.vue' for GraphQL boards
......
import { pick } from 'lodash';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
import {
BoardType,
ListType,
inactiveId,
flashAnimationDuration,
ISSUABLE,
} from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
......@@ -536,10 +542,17 @@ export default {
commit(types.SET_SELECTED_PROJECT, project);
},
toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => {
toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => {
const { selectedBoardItems } = state;
const index = selectedBoardItems.indexOf(boardItem);
// If user already selected an item (activeIssue) without using mult-select,
// include that item in the selection and unset state.ActiveId to hide the sidebar.
if (getters.activeIssue) {
commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue);
dispatch('unsetActiveId');
}
if (index === -1) {
commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem);
} else {
......@@ -551,6 +564,20 @@ export default {
commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible);
},
resetBoardItemMultiSelection: ({ commit }) => {
commit(types.RESET_BOARD_ITEM_SELECTION);
},
toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => {
dispatch('resetBoardItemMultiSelection');
if (boardItem.id === state.activeId) {
dispatch('unsetActiveId');
} else {
dispatch('setActiveId', { id: boardItem.id, sidebarType });
}
},
fetchBacklog: () => {
notImplemented();
},
......
......@@ -45,3 +45,4 @@ export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTIO
export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
......@@ -111,6 +111,7 @@ export default {
[mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listIssues, listPageInfo, listId }) => {
const { listData, issues } = listIssues;
Vue.set(state, 'issues', { ...state.issues, ...issues });
Vue.set(
state.issuesByListId,
listId,
......@@ -280,4 +281,8 @@ export default {
[mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => {
state.highlightedLists = state.highlightedLists.filter((id) => id !== listId);
},
[mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
state.selectedBoardItems = [];
},
};
......@@ -34,16 +34,22 @@ export default {
computed: {
...mapGetters(['isSidebarOpen', 'activeIssue']),
...mapState(['sidebarType']),
showSidebar() {
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
showSidebar() {
return this.isIssuableSidebar && this.isSidebarOpen;
},
},
methods: {
...mapActions(['unsetActiveId', 'setAssignees']),
...mapActions(['toggleBoardItem', 'setAssignees']),
updateAssignees(data) {
const assignees = data.issueSetAssignees?.issue?.assignees?.nodes || [];
this.setAssignees(assignees);
},
handleClose() {
this.toggleBoardItem({ boardItem: this.activeIssue, sidebarType: this.sidebarType });
},
},
};
</script>
......@@ -51,9 +57,10 @@ export default {
<template>
<gl-drawer
v-if="showSidebar"
data-testid="sidebar-drawer"
:open="isSidebarOpen"
:header-height="$options.headerHeight"
@close="unsetActiveId"
@close="handleClose"
>
<template #header>{{ __('Issue details') }}</template>
<template #default>
......
......@@ -2,14 +2,14 @@
import { GlLoadingIcon } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapState, mapActions } from 'vuex';
import BoardCardLayout from '~/boards/components/board_card_layout.vue';
import BoardCard from '~/boards/components/board_card.vue';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import eventHub from '~/boards/eventhub';
import defaultSortableConfig from '~/sortable/sortable_config';
export default {
components: {
BoardCardLayout,
BoardCard,
BoardNewIssue,
GlLoadingIcon,
},
......@@ -181,7 +181,7 @@ export default {
@start="handleDragOnStart"
@end="handleDragOnEnd"
>
<board-card-layout
<board-card
v-for="(issue, index) in issues"
ref="issue"
:key="issue.id"
......@@ -189,7 +189,6 @@ export default {
:list="list"
:issue="issue"
:disabled="disabled || !canAdminEpic"
:is-active="isActiveIssue(issue)"
/>
<gl-loading-icon v-if="isLoadingMore && isUnassignedIssuesLane" size="sm" class="gl-py-3" />
</component>
......
......@@ -25,9 +25,24 @@ RSpec.describe 'epics swimlanes sidebar', :js do
wait_for_requests
end
context 'when closing sidebar' do
let(:issue_card) { first("[data-testid='board-epic-lane-issues'] [data-testid='board_card']") }
it 'unhighlights the active issue card' do
load_epic_swimlanes
issue_card.click
find("[data-testid='sidebar-drawer'] .gl-drawer-close-button").click
expect(issue_card[:class]).not_to include('is-active')
expect(issue_card[:class]).not_to include('multi-select')
end
end
context 'notifications subscription' do
it 'displays notifications toggle' do
load_epic_boards
load_epic_swimlanes
click_first_issue_card
page.within('[data-testid="sidebar-notifications"]') do
......@@ -38,7 +53,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
end
it 'shows toggle as on then as off as user toggles to subscribe and unsubscribe' do
load_epic_boards
load_epic_swimlanes
click_first_issue_card
toggle = find('[data-testid="notification-subscribe-toggle"]')
......@@ -56,7 +71,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
before do
project.update_attribute(:emails_disabled, true)
load_epic_boards
load_epic_swimlanes
end
it 'displays a message that notifications have been disabled' do
......@@ -72,7 +87,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
context 'time tracking' do
it 'displays time tracking feature with default message' do
load_epic_boards
load_epic_swimlanes
click_first_issue_card
page.within('[data-testid="time-tracker"]') do
......@@ -85,7 +100,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
before do
issue1.timelogs.create!(time_spent: 3600, user: user)
load_epic_boards
load_epic_swimlanes
click_first_issue_card
end
......@@ -102,7 +117,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
before do
issue1.update!(time_estimate: 3600)
load_epic_boards
load_epic_swimlanes
click_first_issue_card
end
......@@ -120,7 +135,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
issue1.update!(time_estimate: 3600)
issue1.timelogs.create!(time_spent: 1800, user: user)
load_epic_boards
load_epic_swimlanes
click_first_issue_card
end
......@@ -146,7 +161,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
# 3600+3600*24 = 1d 1h or 25h
issue1.timelogs.create!(time_spent: 3600 + 3600 * 24, user: user)
load_epic_boards
load_epic_swimlanes
click_first_issue_card
end
......@@ -165,7 +180,7 @@ RSpec.describe 'epics swimlanes sidebar', :js do
end
end
def load_epic_boards
def load_epic_swimlanes
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
......
......@@ -76,6 +76,28 @@ RSpec.describe 'epics swimlanes', :js do
end
end
context 'issue cards' do
let(:issue_card) { first("[data-testid='board-epic-lane-issues'] [data-testid='board_card']") }
before do
wait_for_all_requests
issue_card.click
end
it 'highlights an issue card on click' do
expect(issue_card[:class]).to include('is-active')
expect(issue_card[:class]).not_to include('multi-select')
end
it 'unhighlights a selected issue card on click' do
issue_card.click
expect(issue_card[:class]).not_to include('is-active')
expect(issue_card[:class]).not_to include('multi-select')
end
end
context 'add issue to swimlanes list' do
it 'displays new issue button' do
expect(first('.board')).to have_selector('.issue-count-badge-add-button', count: 1)
......
import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue';
import BoardSidebarIterationSelect from 'ee_component/boards/components/sidebar/board_sidebar_iteration_select.vue';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { ISSUABLE } from '~/boards/constants';
import { createStore } from '~/boards/stores';
import { mockIssue } from '../mock_data';
describe('ee/BoardContentSidebar', () => {
let wrapper;
let store;
const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => {
store = new Vuex.Store({
state: {
sidebarType: ISSUABLE,
issues: { [mockIssue.id]: mockIssue },
activeId: mockIssue.id,
},
getters: {
activeIssue: () => mockIssue,
isSidebarOpen: () => true,
...mockGetters,
},
actions: mockActions,
});
};
const createComponent = () => {
wrapper = shallowMount(BoardContentSidebar, {
provide: {
......@@ -42,12 +58,7 @@ describe('ee/BoardContentSidebar', () => {
};
beforeEach(() => {
store = createStore();
store.state.sidebarType = ISSUABLE;
store.state.issues = { 1: { title: 'One', referencePath: 'path#2', assignees: [], iid: '2' } };
store.state.activeIssue = { title: 'One', referencePath: 'path#2', assignees: [], iid: '2' };
store.state.activeId = '1';
createStore();
createComponent();
});
......@@ -60,6 +71,13 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(GlDrawer).exists()).toBe(true);
});
it('does not render GlDrawer when isSidebarOpen is false', () => {
createStore({ mockGetters: { isSidebarOpen: () => false } });
createComponent();
expect(wrapper.find(GlDrawer).exists()).toBe(false);
});
it('applies an open attribute', () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
});
......@@ -89,14 +107,22 @@ describe('ee/BoardContentSidebar', () => {
});
describe('when we emit close', () => {
it('hides GlDrawer', async () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
let toggleBoardItem;
wrapper.find(GlDrawer).vm.$emit('close');
beforeEach(() => {
toggleBoardItem = jest.fn();
createStore({ mockActions: { toggleBoardItem } });
createComponent();
});
await waitForPromises();
it('calls toggleBoardItem with correct parameters', async () => {
wrapper.find(GlDrawer).vm.$emit('close');
expect(wrapper.find(GlDrawer).exists()).toBe(false);
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
boardItem: mockIssue,
sidebarType: ISSUABLE,
});
});
});
});
......@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import IssuesLaneList from 'ee/boards/components/issues_lane_list.vue';
import { mockList } from 'jest/boards/mock_data';
import BoardCard from '~/boards/components/board_card_layout.vue';
import BoardCard from '~/boards/components/board_card.vue';
import { ListType } from '~/boards/constants';
import { createStore } from '~/boards/stores';
import { mockIssues } from '../mock_data';
......
......@@ -96,7 +96,7 @@ export const rawIssue = {
export const mockIssue = {
id: '436',
iid: 27,
iid: '27',
title: 'Issue 1',
referencePath: '#27',
dueDate: null,
......
import { createLocalVue, mount } from '@vue/test-utils';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import BoardCard from '~/boards/components/board_card.vue';
import BoardList from '~/boards/components/board_list.vue';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import eventHub from '~/boards/eventhub';
import defaultState from '~/boards/stores/state';
import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
......@@ -38,6 +39,7 @@ const createComponent = ({
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
selectedBoardItems: [],
...state,
});
......@@ -58,7 +60,7 @@ const createComponent = ({
list.issuesCount = 1;
}
const component = mount(BoardList, {
const component = shallowMount(BoardList, {
localVue,
propsData: {
disabled: false,
......@@ -74,6 +76,10 @@ const createComponent = ({
weightFeatureAvailable: false,
boardWeight: null,
},
stubs: {
BoardCard,
BoardNewIssue,
},
});
return component;
......@@ -81,7 +87,10 @@ const createComponent = ({
describe('Board list component', () => {
let wrapper;
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]');
useFakeRequestAnimationFrame();
afterEach(() => {
......@@ -189,7 +198,8 @@ describe('Board list component', () => {
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
expect(findIssueCountLoadingIcon().exists()).toBe(true);
});
});
......
/* global List */
/* global ListAssignee */
/* global ListLabel */
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue';
import issueCardInner from '~/boards/components/issue_card_inner.vue';
import eventHub from '~/boards/eventhub';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
import sidebarEventHub from '~/sidebar/event_hub';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/list';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
describe('BoardCard', () => {
let wrapper;
let mock;
let list;
const findIssueCardInner = () => wrapper.find(issueCardInner);
const findUserAvatarLink = () => wrapper.find(userAvatarLink);
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = (propsData) => {
wrapper = mount(BoardCardDeprecated, {
stubs: {
issueCardInner,
},
store,
propsData: {
list,
issue: list.issues[0],
disabled: false,
index: 0,
...propsData,
},
provide: {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
},
});
};
const setupData = async () => {
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',
});
await waitForPromises();
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();
});
it('when details issue is empty does not show the element', () => {
mountComponent();
expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
});
it('when detailIssue is equal to card issue shows the element', () => {
[boardsStore.detail.issue] = list.issues;
mountComponent();
expect(wrapper.classes()).toContain('is-active');
});
it('when multiSelect does not contain issue removes multi select class', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('multi-select');
});
it('when multiSelect contain issue add multi select class', () => {
boardsStore.multiSelect.list = [list.issues[0]];
mountComponent();
expect(wrapper.classes()).toContain('multi-select');
});
it('adds user-can-drag class if not disabled', () => {
mountComponent();
expect(wrapper.classes()).toContain('user-can-drag');
});
it('does not add user-can-drag class disabled', () => {
mountComponent({ disabled: true });
expect(wrapper.classes()).not.toContain('user-can-drag');
});
it('does not add disabled class', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('is-disabled');
});
it('adds disabled class is disabled is true', () => {
mountComponent({ disabled: true });
expect(wrapper.classes()).toContain('is-disabled');
});
describe('mouse events', () => {
it('does not set detail issue if showDetail is false', () => {
mountComponent();
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if link is clicked', () => {
mountComponent();
findIssueCardInner().find('a').trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if img is clicked', () => {
mountComponent({
issue: {
...list.issues[0],
assignees: [
new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
avatar: 'test_image',
}),
],
},
});
findUserAvatarLink().trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if showDetail is false after mouseup', () => {
mountComponent();
wrapper.trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('sets detail issue to card issue on mouse up', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
mountComponent();
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false);
expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
});
it('resets detail issue to empty if already set', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
const [issue] = list.issues;
boardsStore.detail.issue = issue;
mountComponent();
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false);
});
});
describe('sidebarHub events', () => {
it('closes all sidebars before showing an issue if no issues are opened', () => {
jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
boardsStore.detail.issue = {};
mountComponent();
// sets conditional so that event is emitted.
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll');
});
it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
const [issue] = list.issues;
boardsStore.detail.issue = issue;
mountComponent();
wrapper.trigger('mousedown');
expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardCardLayout from '~/boards/components/board_card_layout.vue';
import IssueCardInner from '~/boards/components/issue_card_inner.vue';
import { ISSUABLE } from '~/boards/constants';
import defaultState from '~/boards/stores/state';
import { mockLabelList, mockIssue } from '../mock_data';
describe('Board card layout', () => {
let wrapper;
let store;
const localVue = createLocalVue();
localVue.use(Vuex);
const createStore = ({ getters = {}, actions = {} } = {}) => {
store = new Vuex.Store({
state: defaultState,
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: mockLabelList,
issue: mockIssue,
disabled: false,
index: 0,
...propsData,
},
provide: {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
...provide,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
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'", async () => {
const setActiveId = jest.fn();
createStore({
actions: {
setActiveId,
},
});
mountComponent();
wrapper.trigger('mouseup');
await wrapper.vm.$nextTick();
expect(setActiveId).toHaveBeenCalledTimes(1);
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
id: mockIssue.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: mockIssue.id,
sidebarType: ISSUABLE,
});
});
});
});
/* global List */
/* global ListAssignee */
/* global ListLabel */
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import BoardCard from '~/boards/components/board_card.vue';
import issueCardInner from '~/boards/components/issue_card_inner.vue';
import eventHub from '~/boards/eventhub';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
import sidebarEventHub from '~/sidebar/event_hub';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/list';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
describe('BoardCard', () => {
let wrapper;
let mock;
let list;
import IssueCardInner from '~/boards/components/issue_card_inner.vue';
import { inactiveId } from '~/boards/constants';
import { mockLabelList, mockIssue } from '../mock_data';
const findIssueCardInner = () => wrapper.find(issueCardInner);
const findUserAvatarLink = () => wrapper.find(userAvatarLink);
describe('Board card layout', () => {
let wrapper;
let store;
let mockActions;
const localVue = createLocalVue();
localVue.use(Vuex);
const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => {
mockActions = {
toggleBoardItem: jest.fn(),
toggleBoardItemMultiSelection: jest.fn(),
};
store = new Vuex.Store({
state: {
activeId: inactiveId,
selectedBoardItems: [],
...initialState,
},
actions: mockActions,
getters: {
isSwimlanesOn: () => isSwimlanesOn,
},
});
};
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = (propsData) => {
wrapper = mount(BoardCard, {
const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
wrapper = shallowMount(BoardCard, {
localVue,
stubs: {
issueCardInner,
IssueCardInner,
},
store,
propsData: {
list,
issue: list.issues[0],
list: mockLabelList,
issue: mockIssue,
disabled: false,
index: 0,
...propsData,
......@@ -46,174 +52,94 @@ describe('BoardCard', () => {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
...provide,
},
});
};
const setupData = async () => {
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',
});
await waitForPromises();
list.issues[0].labels.push(label1);
const selectCard = async () => {
wrapper.trigger('mouseup');
await wrapper.vm.$nextTick();
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
setMockEndpoints();
return setupData();
});
const multiSelectCard = async () => {
wrapper.trigger('mouseup', { ctrlKey: true });
await wrapper.vm.$nextTick();
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
list = null;
mock.restore();
});
it('when details issue is empty does not show the element', () => {
mountComponent();
expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
});
it('when detailIssue is equal to card issue shows the element', () => {
[boardsStore.detail.issue] = list.issues;
mountComponent();
expect(wrapper.classes()).toContain('is-active');
});
it('when multiSelect does not contain issue removes multi select class', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('multi-select');
});
it('when multiSelect contain issue add multi select class', () => {
boardsStore.multiSelect.list = [list.issues[0]];
mountComponent();
expect(wrapper.classes()).toContain('multi-select');
});
it('adds user-can-drag class if not disabled', () => {
mountComponent();
expect(wrapper.classes()).toContain('user-can-drag');
});
it('does not add user-can-drag class disabled', () => {
mountComponent({ disabled: true });
expect(wrapper.classes()).not.toContain('user-can-drag');
});
it('does not add disabled class', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('is-disabled');
store = null;
});
it('adds disabled class is disabled is true', () => {
mountComponent({ disabled: true });
expect(wrapper.classes()).toContain('is-disabled');
});
describe('mouse events', () => {
it('does not set detail issue if showDetail is false', () => {
describe.each`
isSwimlanesOn
${true} | ${false}
`('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => {
it('should not highlight the card by default', async () => {
createStore({ isSwimlanesOn });
mountComponent();
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if link is clicked', () => {
mountComponent();
findIssueCardInner().find('a').trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
expect(wrapper.classes()).not.toContain('is-active');
expect(wrapper.classes()).not.toContain('multi-select');
});
it('does not set detail issue if img is clicked', () => {
mountComponent({
issue: {
...list.issues[0],
assignees: [
new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
avatar: 'test_image',
}),
],
it('should highlight the card with a correct style when selected', async () => {
createStore({
initialState: {
activeId: mockIssue.id,
},
isSwimlanesOn,
});
findUserAvatarLink().trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if showDetail is false after mouseup', () => {
mountComponent();
wrapper.trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('sets detail issue to card issue on mouse up', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
mountComponent();
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false);
expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
expect(wrapper.classes()).toContain('is-active');
expect(wrapper.classes()).not.toContain('multi-select');
});
it('resets detail issue to empty if already set', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
const [issue] = list.issues;
boardsStore.detail.issue = issue;
it('should highlight the card with a correct style when multi-selected', async () => {
createStore({
initialState: {
activeId: inactiveId,
selectedBoardItems: [mockIssue],
},
isSwimlanesOn,
});
mountComponent();
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false);
expect(wrapper.classes()).toContain('multi-select');
expect(wrapper.classes()).not.toContain('is-active');
});
});
describe('sidebarHub events', () => {
it('closes all sidebars before showing an issue if no issues are opened', () => {
jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
boardsStore.detail.issue = {};
mountComponent();
// sets conditional so that event is emitted.
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
describe('when mouseup event is called on the issue card', () => {
beforeEach(() => {
createStore({ isSwimlanesOn });
mountComponent();
});
expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll');
});
describe('when not using multi-select', () => {
it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
await selectCard();
it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
const [issue] = list.issues;
boardsStore.detail.issue = issue;
mountComponent();
expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
boardItem: mockIssue,
});
});
});
wrapper.trigger('mousedown');
describe('when using multi-select', () => {
it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => {
await multiSelectCard();
expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1);
expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith(
expect.any(Object),
mockIssue,
);
});
});
});
});
});
......@@ -5,7 +5,7 @@ import {
formatBoardLists,
formatIssueInput,
} from '~/boards/boards_util';
import { inactiveId } from '~/boards/constants';
import { inactiveId, ISSUABLE } from '~/boards/constants';
import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
......@@ -1246,6 +1246,7 @@ describe('setSelectedProject', () => {
describe('toggleBoardItemMultiSelection', () => {
const boardItem = mockIssue;
const boardItem2 = mockIssue2;
it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => {
testAction(
......@@ -1276,6 +1277,66 @@ describe('toggleBoardItemMultiSelection', () => {
[],
);
});
it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => {
testAction(
actions.toggleBoardItemMultiSelection,
boardItem2,
{ activeId: mockActiveIssue.id, activeIssue: mockActiveIssue, selectedBoardItems: [] },
[
{
type: types.ADD_BOARD_ITEM_TO_SELECTION,
payload: mockActiveIssue,
},
{
type: types.ADD_BOARD_ITEM_TO_SELECTION,
payload: boardItem2,
},
],
[{ type: 'unsetActiveId' }],
);
});
});
describe('resetBoardItemMultiSelection', () => {
it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => {
testAction({
action: actions.resetBoardItemMultiSelection,
state: { selectedBoardItems: [mockIssue] },
expectedMutations: [
{
type: types.RESET_BOARD_ITEM_SELECTION,
},
],
});
});
});
describe('toggleBoardItem', () => {
it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => {
testAction({
action: actions.toggleBoardItem,
payload: { boardItem: mockIssue },
state: {
activeId: mockIssue.id,
},
expectedActions: [{ type: 'resetBoardItemMultiSelection' }, { type: 'unsetActiveId' }],
});
});
it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => {
testAction({
action: actions.toggleBoardItem,
payload: { boardItem: mockIssue },
state: {
activeId: inactiveId,
},
expectedActions: [
{ type: 'resetBoardItemMultiSelection' },
{ type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } },
],
});
});
});
describe('fetchBacklog', () => {
......
......@@ -610,14 +610,21 @@ describe('Board Store Mutations', () => {
describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => {
it('Should remove boardItem to selectedBoardItems state', () => {
state = {
...state,
selectedBoardItems: [mockIssue],
};
state.selectedBoardItems = [mockIssue];
mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue);
expect(state.selectedBoardItems).toEqual([]);
});
});
describe('RESET_BOARD_ITEM_SELECTION', () => {
it('Should reset selectedBoardItems state', () => {
state.selectedBoardItems = [mockIssue];
mutations[types.RESET_BOARD_ITEM_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