Commit 0b9519b3 authored by Florie Guibert's avatar Florie Guibert

Swimlanes - Fetch more epics button

- Improve performance for Swimlanes by paginating epics through user
action
- Fetch issues per epic
- Collapse unassigned issues lane and fetch unassigned issue when
expanding

Changelog: changed
parent 40f818e6
......@@ -73,7 +73,7 @@ export default {
},
computed: {
...mapState(['activeId']),
...mapGetters(['isEpicBoard']),
...mapGetters(['isEpicBoard', 'isSwimlanesOn']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
......@@ -169,7 +169,14 @@ export default {
},
showNewIssueForm() {
if (this.isSwimlanesOn) {
eventHub.$emit('open-unassigned-lane');
this.$nextTick(() => {
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
});
} else {
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
}
},
showNewEpicForm() {
eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`);
......
<script>
import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __, n__, sprintf } from '~/locale';
......@@ -12,6 +12,7 @@ export default {
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlPopover,
IssuesLaneList,
},
......@@ -48,7 +49,7 @@ export default {
};
},
computed: {
...mapState(['filterParams']),
...mapState(['epicsFlags', 'filterParams']),
...mapGetters(['getIssuesByEpic']),
isOpen() {
return this.epic.state === statusType.open;
......@@ -80,12 +81,30 @@ export default {
epicDateString() {
return formatDate(this.epic.createdAt);
},
isLoading() {
return Boolean(this.epicsFlags[this.epic.id]?.isLoading);
},
shouldDisplay() {
return this.issuesCount > 0;
return this.issuesCount > 0 || this.isLoading;
},
},
watch: {
filterParams: {
handler() {
if (!this.filterParams.epicId || this.filterParams.epicId === this.epic.id) {
this.fetchIssuesForEpic(this.epic.id);
}
},
deep: true,
},
},
mounted() {
if (this.issuesCount === 0) {
this.fetchIssuesForEpic(this.epic.id);
}
},
methods: {
...mapActions(['updateBoardEpicUserPreferences', 'setError']),
...mapActions(['updateBoardEpicUserPreferences', 'setError', 'fetchIssuesForEpic']),
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
......@@ -101,7 +120,7 @@ export default {
</script>
<template>
<div v-if="shouldDisplay">
<div v-if="shouldDisplay" class="board-epic-lane-container">
<div
class="board-epic-lane gl-sticky gl-left-0 gl-display-inline-block"
data-testid="board-epic-lane"
......@@ -131,6 +150,7 @@ export default {
<gl-link :href="epic.webUrl" class="gl-font-sm">{{ __('Go to epic') }}</gl-link>
</gl-popover>
<span
v-if="!isLoading"
v-gl-tooltip.hover
:title="issuesCountTooltipText"
class="gl-display-flex gl-align-items-center gl-text-gray-500"
......@@ -141,9 +161,14 @@ export default {
<gl-icon class="gl-mr-2 gl-flex-shrink-0" name="issues" />
<span aria-hidden="true">{{ issuesCount }}</span>
</span>
<gl-loading-icon v-else class="gl-p-2" />
</div>
</div>
<div v-if="!isCollapsed" class="gl-display-flex gl-pb-5" data-testid="board-epic-lane-issues">
<div
v-if="!isCollapsed && issuesCount > 0"
class="gl-display-flex gl-pb-5 board-epic-lane-issues"
data-testid="board-epic-lane-issues"
>
<issues-lane-list
v-for="list in lists"
:key="`${list.id}-issues`"
......
......@@ -6,7 +6,8 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { isListDraggable } from '~/boards/boards_util';
import { n__ } from '~/locale';
import eventHub from '~/boards/eventhub';
import { s__, n__, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { calculateSwimlanesBufferSize } from '../boards_util';
......@@ -50,6 +51,7 @@ export default {
data() {
return {
bufferSize: 0,
isUnassignedCollapsed: true,
};
},
computed: {
......@@ -60,6 +62,7 @@ export default {
'addColumnForm',
'filterParams',
'epicsSwimlanesFetchInProgress',
'hasMoreEpics',
]),
...mapGetters(['getUnassignedIssues']),
addColumnFormVisible() {
......@@ -103,15 +106,26 @@ export default {
epicLanesFetchInProgress,
listItemsFetchInProgress,
} = this.epicsSwimlanesFetchInProgress;
return epicLanesFetchInProgress && listItemsFetchInProgress;
return epicLanesFetchInProgress || listItemsFetchInProgress;
},
chevronTooltip() {
return this.isUnassignedCollapsed ? __('Expand') : __('Collapse');
},
chevronIcon() {
return this.isUnassignedCollapsed ? 'chevron-right' : 'chevron-down';
},
epicButtonLabel() {
return this.epicsSwimlanesFetchInProgress.epicLanesFetchMoreInProgress
? s__('Board|Loading epics')
: s__('Board|Load more epics');
},
},
watch: {
filterParams: {
handler() {
Promise.all(
this.lists.map((list) => {
return this.fetchItemsForList({ listId: list.id, forSwimlanes: true });
this.epics.map((epic) => {
return this.fetchIssuesForEpic(epic.id);
}),
)
.then(() => this.doneLoadingSwimlanesItems())
......@@ -124,8 +138,20 @@ export default {
mounted() {
this.bufferSize = calculateSwimlanesBufferSize(this.$el.offsetTop);
},
created() {
eventHub.$on('open-unassigned-lane', this.openUnassignedLane);
},
beforeDestroy() {
eventHub.$off('open-unassigned-lane', this.openUnassignedLane);
},
methods: {
...mapActions(['moveList', 'fetchItemsForList', 'doneLoadingSwimlanesItems']),
...mapActions([
'moveList',
'fetchEpicsSwimlanes',
'fetchIssuesForEpic',
'fetchItemsForList',
'doneLoadingSwimlanesItems',
]),
handleDragOnEnd(params) {
const { newIndex, oldIndex, item, to } = params;
const { listId } = item.dataset;
......@@ -138,6 +164,9 @@ export default {
adjustmentValue: newIndex < oldIndex ? 1 : -1,
});
},
fetchMoreEpics() {
this.fetchEpicsSwimlanes({ fetchNext: true });
},
fetchMoreUnassignedIssues() {
this.lists.forEach((list) => {
if (this.pageInfoByListId[list.id]?.hasNextPage) {
......@@ -166,6 +195,12 @@ export default {
},
};
},
toggleUnassignedLane() {
this.isUnassignedCollapsed = !this.isUnassignedCollapsed;
},
openUnassignedLane() {
this.isUnassignedCollapsed = false;
},
},
};
</script>
......@@ -228,14 +263,42 @@ export default {
:can-admin-list="canAdminList"
/>
</template>
<div v-if="hasMoreEpics" class="swimlanes-button gl-pb-3 gl-pl-3 gl-sticky gl-left-0">
<gl-button
category="tertiary"
variant="confirm"
class="gl-w-full"
:loading="epicsSwimlanesFetchInProgress.epicLanesFetchMoreInProgress"
:disabled="epicsSwimlanesFetchInProgress.epicLanesFetchMoreInProgress"
data-testid="load-more-epics"
data-track-action="click_button"
data-track-label="toggle_swimlanes"
data-track-property="click_load_more_epics"
@click="fetchMoreEpics()"
>
{{ epicButtonLabel }}
</gl-button>
</div>
<div class="board-lane-unassigned-issues-title gl-sticky gl-display-inline-block gl-left-0">
<div class="gl-left-0 gl-pb-5 gl-px-3 gl-display-flex gl-align-items-center">
<gl-button
v-gl-tooltip.hover.right
:aria-label="chevronTooltip"
:title="chevronTooltip"
:icon="chevronIcon"
class="gl-mr-2 gl-cursor-pointer"
category="tertiary"
size="small"
data-testid="unassigned-lane-toggle"
@click="toggleUnassignedLane"
/>
<span
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
>
{{ __('Issues with no epic assigned') }}
</span>
<span
v-if="unassignedIssuesCount > 0"
v-gl-tooltip.hover
:title="unassignedIssuesCountTooltipText"
class="gl-display-flex gl-align-items-center gl-text-gray-500"
......@@ -248,7 +311,7 @@ export default {
</span>
</div>
</div>
<div data-testid="board-lane-unassigned-issues">
<div v-if="!isUnassignedCollapsed" data-testid="board-lane-unassigned-issues">
<div class="gl-display-flex">
<issues-lane-list
v-for="list in lists"
......@@ -263,9 +326,8 @@ export default {
</div>
</div>
<div
v-if="hasMoreUnassignedIssues"
class="gl-p-3 gl-sticky gl-left-0"
style="max-width: 100vw"
v-if="hasMoreUnassignedIssues && !isUnassignedCollapsed"
class="swimlanes-button gl-p-3 gl-pr-0 gl-sticky gl-left-0"
>
<gl-button
category="tertiary"
......
......@@ -67,8 +67,10 @@ export default {
return this.canAdminList ? options : {};
},
isLoadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore;
isLoading() {
return (
this.listsFlags[this.list.id]?.isLoading || this.listsFlags[this.list.id]?.isLoadingMore
);
},
highlighted() {
return this.highlightedLists.includes(this.list.id);
......@@ -190,7 +192,7 @@ export default {
:item="issue"
:disabled="disabled || !canAdminEpic"
/>
<gl-loading-icon v-if="isLoadingMore && isUnassignedIssuesLane" size="sm" class="gl-py-3" />
<gl-loading-icon v-if="isLoading && isUnassignedIssuesLane" size="sm" class="gl-py-3" />
</component>
</div>
</div>
......
......@@ -10,7 +10,7 @@ query BoardEE(
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
board(id: $boardId) {
epics(first: 20, issueFilters: $issueFilters, after: $after) {
epics(first: 10, issueFilters: $issueFilters, after: $after) {
edges {
node {
...BoardEpicNode
......@@ -28,7 +28,7 @@ query BoardEE(
}
project(fullPath: $fullPath) @include(if: $isProject) {
board(id: $boardId) {
epics(first: 20, issueFilters: $issueFilters, after: $after) {
epics(first: 10, issueFilters: $issueFilters, after: $after) {
edges {
node {
...BoardEpicNode
......
......@@ -10,7 +10,6 @@ fragment IssueNode on Issue {
totalTimeSpent
humanTimeEstimate
humanTotalTimeSpent
emailsDisabled
weight
confidential
webUrl
......
......@@ -134,8 +134,12 @@ export default {
}
},
fetchEpicsSwimlanes({ state, commit, dispatch }, { endCursor = null } = {}) {
const { fullPath, boardId, boardType, filterParams } = state;
fetchEpicsSwimlanes({ state, commit }, { fetchNext = false } = {}) {
const { fullPath, boardId, boardType, filterParams, epicsEndCursor } = state;
if (fetchNext) {
commit(types.REQUEST_MORE_EPICS);
}
const variables = {
fullPath,
......@@ -143,7 +147,7 @@ export default {
issueFilters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
after: endCursor,
after: fetchNext ? epicsEndCursor : undefined,
};
return gqlClient
......@@ -161,18 +165,30 @@ export default {
commit(types.RECEIVE_EPICS_SUCCESS, {
epics: epicsFormatted,
canAdminEpic: epics.edges[0]?.node?.userPermissions?.adminEpic,
});
}
if (epics.pageInfo?.hasNextPage) {
dispatch('fetchEpicsSwimlanes', {
endCursor: epics.pageInfo.endCursor,
hasMoreEpics: epics.pageInfo?.hasNextPage,
epicsEndCursor: epics.pageInfo?.endCursor,
});
}
})
.catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE));
},
fetchIssuesForEpic: ({ state, commit }, epicId) => {
commit(types.REQUEST_ISSUES_FOR_EPIC, epicId);
const { filterParams } = state;
const variables = {
filters: { ...filterParams, epicId },
};
return fetchAndFormatListIssues(state, variables)
.then(({ listItems }) => {
commit(types.RECEIVE_ISSUES_FOR_EPIC_SUCCESS, { ...listItems, epicId });
})
.catch(() => commit(types.RECEIVE_ISSUES_FOR_EPIC_FAILURE, epicId));
},
updateBoardEpicUserPreferences({ commit, state }, { epicId, collapsed }) {
const { boardId } = state;
......@@ -244,7 +260,7 @@ export default {
fetchItemsForList: (
{ state, commit, getters },
{ listId, fetchNext = false, noEpicIssues = false, forSwimlanes = false },
{ listId, fetchNext = false, noEpicIssues = false },
) => {
if (!fetchNext && !state.isShowingEpicsSwimlanes) {
commit(types.RESET_ITEMS_FOR_LIST, listId);
......@@ -255,9 +271,6 @@ export default {
if (noEpicIssues && epicId !== undefined) {
return null;
}
if (forSwimlanes && epicId === undefined && filterParams.epicWildcardId === undefined) {
filterParams.epicWildcardId = EpicFilterType.any.toUpperCase();
}
const variables = {
id: listId,
......@@ -265,7 +278,7 @@ export default {
? { ...filterParams, epicWildcardId: EpicFilterType.none.toUpperCase() }
: { ...filterParams, epicId },
after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
first: forSwimlanes ? undefined : 10,
first: 10,
};
if (getters.isEpicBoard) {
......
......@@ -8,6 +8,7 @@ export const RECEIVE_ISSUES_FOR_EPIC_SUCCESS = 'RECEIVE_ISSUES_FOR_EPIC_SUCCESS'
export const RECEIVE_ISSUES_FOR_EPIC_FAILURE = 'RECEIVE_ISSUES_FOR_EPIC_FAILURE';
export const UPDATE_LIST_SUCCESS = 'UPDATE_LIST_SUCCESS';
export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE';
export const REQUEST_MORE_EPICS = 'REQUEST_MORE_EPICS';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const RESET_EPICS = 'RESET_EPICS';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
......
......@@ -49,6 +49,28 @@ export default {
Vue.set(state.boardItemsByListId, listId, state.backupItemsList);
},
[mutationTypes.REQUEST_ISSUES_FOR_EPIC]: (state, epicId) => {
Vue.set(state.epicsFlags, epicId, { isLoading: true });
},
[mutationTypes.RECEIVE_ISSUES_FOR_EPIC_SUCCESS]: (state, { listData, boardItems, epicId }) => {
Object.entries(listData).forEach(([listId, list]) => {
Vue.set(
state.boardItemsByListId,
listId,
union(state.boardItemsByListId[listId] || [], list),
);
});
Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems });
Vue.set(state.epicsFlags, epicId, { isLoading: false });
},
[mutationTypes.RECEIVE_ISSUES_FOR_EPIC_FAILURE]: (state, epicId) => {
state.error = s__('Boards|An error occurred while fetching issues. Please reload the page.');
Vue.set(state.epicsFlags, epicId, { isLoading: false });
},
[mutationTypes.TOGGLE_EPICS_SWIMLANES]: (state) => {
state.isShowingEpicsSwimlanes = !state.isShowingEpicsSwimlanes;
Vue.set(state, 'epicsSwimlanesFetchInProgress', {
......@@ -86,11 +108,27 @@ export default {
});
},
[mutationTypes.RECEIVE_EPICS_SUCCESS]: (state, { epics, canAdminEpic }) => {
[mutationTypes.REQUEST_MORE_EPICS]: (state) => {
Vue.set(state, 'epicsSwimlanesFetchInProgress', {
...state.epicsSwimlanesFetchInProgress,
epicLanesFetchMoreInProgress: true,
});
},
[mutationTypes.RECEIVE_EPICS_SUCCESS]: (
state,
{ epics, canAdminEpic, hasMoreEpics, epicsEndCursor },
) => {
Vue.set(state, 'epics', unionBy(state.epics || [], epics, 'id'));
Vue.set(state, 'hasMoreEpics', hasMoreEpics);
Vue.set(state, 'epicsEndCursor', epicsEndCursor);
if (canAdminEpic !== undefined) {
state.canAdminEpic = canAdminEpic;
}
Vue.set(state, 'epicsSwimlanesFetchInProgress', {
...state.epicsSwimlanesFetchInProgress,
epicLanesFetchInProgress: false,
epicLanesFetchMoreInProgress: false,
});
},
[mutationTypes.RESET_EPICS]: (state) => {
......
......@@ -8,8 +8,12 @@ export default () => ({
epicsSwimlanesFetchInProgress: {
epicLanesFetchInProgress: false,
listItemsFetchInProgress: false,
epicLanesFetchMoreInProgress: false,
},
hasMoreEpics: false,
epicsEndCursor: null,
epics: [],
epicsFlags: {},
milestones: [],
milestonesLoading: false,
iterations: [],
......
......@@ -109,9 +109,14 @@
}
}
.board-epic-lane-container:last-child .board-epic-lane-issues {
padding-bottom: $gl-spacing-scale-3;
}
$epic-icons-spacing: 40px;
.board-epic-lane {
.board-epic-lane,
.swimlanes-button {
max-width: calc(100vw - #{$contextual-sidebar-width} - #{$epic-icons-spacing});
.page-with-icon-sidebar & {
......
......@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe 'epics swimlanes', :js do
include DragTo
include MobileHelpers
include BoardHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
......@@ -33,7 +34,8 @@ RSpec.describe 'epics swimlanes', :js do
sign_in(user)
visit_board_page
select_epics
load_epic_swimlanes
load_unassigned_issues
end
context 'drag and drop issue' do
......
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'epics swimlanes filtering', :js do
include BoardHelpers
let_it_be(:user) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:group) { create(:group, :public) }
......@@ -44,10 +46,9 @@ RSpec.describe 'epics swimlanes filtering', :js do
sign_in(user)
visit_board_page
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
load_epic_swimlanes
load_unassigned_issues
wait_for_all_issues
end
......
......@@ -21,6 +21,7 @@ RSpec.describe 'Issue boards sidebar labels using epic swimlanes', :js do
before do
load_board group_board_path(group, group_board)
load_epic_swimlanes
load_unassigned_issues
end
context 'selecting an issue from a direct descendant project' do
......
......@@ -31,6 +31,8 @@ RSpec.describe 'epics swimlanes sidebar', :js do
wait_for_requests
load_epic_swimlanes
load_unassigned_issues
end
it_behaves_like 'issue boards sidebar'
......@@ -44,6 +46,8 @@ RSpec.describe 'epics swimlanes sidebar', :js do
wait_for_requests
load_epic_swimlanes
load_unassigned_issues
end
it_behaves_like 'issue boards sidebar'
......@@ -57,4 +61,14 @@ RSpec.describe 'epics swimlanes sidebar', :js do
def first_card_with_epic
find("[data-testid='board-epic-lane-issues']").first("[data-testid='board_card']")
end
def refresh_and_click_first_card
page.refresh
wait_for_requests
load_unassigned_issues
first_card.click
end
end
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'epics swimlanes', :js do
include BoardHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
......@@ -38,8 +40,12 @@ RSpec.describe 'epics swimlanes', :js do
expect(epic_lanes.length).to eq(2)
end
it 'displays issue not assigned to epic in unassigned issues lane' do
it 'displays issue not assigned to epic title and unassigned issues lane only on expand' do
page.within('.board-lane-unassigned-issues-title') do
expect(page).not_to have_selector('span[data-testid="issues-lane-issue-count"]')
load_unassigned_issues
expect(page.find('span[data-testid="issues-lane-issue-count"]')).to have_content('1')
end
end
......@@ -61,7 +67,7 @@ RSpec.describe 'epics swimlanes', :js do
stub_licensed_features(epics: true, swimlanes: true)
sign_in(user)
visit_board_page
select_epics
load_epic_swimlanes
end
context 'switch to swimlanes view' do
......@@ -72,8 +78,12 @@ RSpec.describe 'epics swimlanes', :js do
expect(epic_lanes.length).to eq(2)
end
it 'displays issue not assigned to epic in unassigned issues lane' do
it 'displays issue not assigned to epic title and unassigned issues lane only on expand' do
page.within('.board-lane-unassigned-issues-title') do
expect(page).not_to have_selector('span[data-testid="issues-lane-issue-count"]')
load_unassigned_issues
expect(page.find('span[data-testid="issues-lane-issue-count"]')).to have_content('1')
end
end
......@@ -102,6 +112,12 @@ RSpec.describe 'epics swimlanes', :js do
end
context 'add issue to swimlanes list' do
before do
wait_for_all_requests
load_unassigned_issues
end
it 'displays new issue button' do
expect(first('.board')).to have_selector('.issue-count-badge-add-button', count: 1)
end
......@@ -165,13 +181,6 @@ RSpec.describe 'epics swimlanes', :js do
wait_for_requests
end
def select_epics
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
end
def visit_epics_swimlanes_page
visit "#{project_boards_path(project)}?group_by=epic"
wait_for_requests
......
......@@ -3,6 +3,7 @@
require 'spec_helper'
RSpec.describe 'User visits issue boards', :js do
include BoardHelpers
using RSpec::Parameterized::TableSyntax
let_it_be(:group) { create_default(:group, :public) }
......@@ -34,6 +35,10 @@ RSpec.describe 'User visits issue boards', :js do
visit board_path
wait_for_requests
if board_path.include?('group_by')
load_unassigned_issues
end
end
it 'displays all issues satisfiying filter params and correctly sets url params' do
......
import { GlIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import EpicLane from 'ee/boards/components/epic_lane.vue';
import IssuesLaneList from 'ee/boards/components/issues_lane_list.vue';
import getters from 'ee/boards/stores/getters';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockEpic, mockLists, mockIssuesByListId, issues } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(Vuex);
describe('EpicLane', () => {
let wrapper;
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const updateBoardEpicUserPreferencesSpy = jest.fn();
const createStore = ({ boardItemsByListId = mockIssuesByListId }) => {
const createStore = ({ boardItemsByListId = mockIssuesByListId, isLoading = false }) => {
return new Vuex.Store({
actions: {
updateBoardEpicUserPreferences: updateBoardEpicUserPreferencesSpy,
fetchIssuesForEpic: jest.fn(),
},
state: {
boardItemsByListId,
boardItems: issues,
epicsFlags: {
[mockEpic.id]: {
isLoading,
},
},
},
getters,
});
};
const createComponent = ({ props = {}, boardItemsByListId = mockIssuesByListId } = {}) => {
const store = createStore({ boardItemsByListId });
const createComponent = ({
props = {},
boardItemsByListId = mockIssuesByListId,
isLoading = false,
} = {}) => {
const store = createStore({ boardItemsByListId, isLoading });
const defaultProps = {
epic: mockEpic,
......@@ -38,14 +47,15 @@ describe('EpicLane', () => {
disabled: false,
};
wrapper = shallowMount(EpicLane, {
localVue,
wrapper = extendedWrapper(
shallowMount(EpicLane, {
propsData: {
...defaultProps,
...props,
},
store,
});
}),
);
};
afterEach(() => {
......@@ -58,7 +68,7 @@ describe('EpicLane', () => {
});
it('displays count of issues in epic which belong to board', () => {
expect(findByTestId('epic-lane-issue-count').text()).toContain(2);
expect(wrapper.findByTestId('epic-lane-issue-count').text()).toContain(2);
});
it('displays 1 icon', () => {
......@@ -77,7 +87,7 @@ describe('EpicLane', () => {
expect(wrapper.findAll(IssuesLaneList)).toHaveLength(wrapper.props('lists').length);
expect(wrapper.vm.isCollapsed).toBe(false);
findByTestId('epic-lane-chevron').vm.$emit('click');
wrapper.findByTestId('epic-lane-chevron').vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(IssuesLaneList)).toHaveLength(0);
......@@ -85,12 +95,22 @@ describe('EpicLane', () => {
});
});
it('does not display loading icon when issues are not loading', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('displays loading icon and hides issues count when issues are loading', () => {
createComponent({ isLoading: true });
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.findByTestId('epic-lane-issue-count').exists()).toBe(false);
});
it('invokes `updateBoardEpicUserPreferences` method on collapse', () => {
const collapsedValue = false;
expect(wrapper.vm.isCollapsed).toBe(collapsedValue);
findByTestId('epic-lane-chevron').vm.$emit('click');
wrapper.findByTestId('epic-lane-chevron').vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(updateBoardEpicUserPreferencesSpy).toHaveBeenCalled();
......@@ -108,7 +128,7 @@ describe('EpicLane', () => {
it('does not render when issuesCount is 0', () => {
createComponent({ boardItemsByListId: {} });
expect(findByTestId('board-epic-lane').exists()).toBe(false);
expect(wrapper.findByTestId('board-epic-lane').exists()).toBe(false);
});
});
});
......@@ -12,6 +12,7 @@ import SwimlanesLoadingSkeleton from 'ee/boards/components/swimlanes_loading_ske
import { EPIC_LANE_BASE_HEIGHT } from 'ee/boards/constants';
import getters from 'ee/boards/stores/getters';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockLists, mockEpics, mockIssuesByListId, issues } from '../mock_data';
Vue.use(Vuex);
......@@ -20,11 +21,18 @@ jest.mock('ee/boards/boards_util');
describe('EpicsSwimlanes', () => {
let wrapper;
const findDraggable = () => wrapper.findComponent(Draggable);
const findLoadMoreEpicsButton = () => wrapper.findByTestId('load-more-epics');
const fetchItemsForListSpy = jest.fn();
const fetchIssuesForEpicSpy = jest.fn();
const fetchEpicsSwimlanesSpy = jest.fn();
const createStore = ({
epicLanesFetchInProgress = false,
listItemsFetchInProgress = false,
epicLanesFetchMoreInProgress = false,
hasMoreEpics = false,
} = {}) => {
return new Vuex.Store({
state: {
......@@ -46,11 +54,15 @@ describe('EpicsSwimlanes', () => {
epicsSwimlanesFetchInProgress: {
epicLanesFetchInProgress,
listItemsFetchInProgress,
epicLanesFetchMoreInProgress,
},
hasMoreEpics,
},
getters,
actions: {
fetchItemsForList: fetchItemsForListSpy,
fetchIssuesForEpic: fetchIssuesForEpicSpy,
fetchEpicsSwimlanes: fetchEpicsSwimlanesSpy,
},
});
};
......@@ -60,29 +72,32 @@ describe('EpicsSwimlanes', () => {
swimlanesBufferedRendering = false,
epicLanesFetchInProgress = false,
listItemsFetchInProgress = false,
hasMoreEpics = false,
} = {}) => {
const store = createStore({ epicLanesFetchInProgress, listItemsFetchInProgress });
const store = createStore({ epicLanesFetchInProgress, listItemsFetchInProgress, hasMoreEpics });
const defaultProps = {
lists: mockLists,
disabled: false,
};
wrapper = shallowMount(EpicsSwimlanes, {
wrapper = extendedWrapper(
shallowMount(EpicsSwimlanes, {
propsData: { ...defaultProps, canAdminList },
store,
provide: {
glFeatures: { swimlanesBufferedRendering },
},
});
}),
);
};
afterEach(() => {
wrapper.destroy();
});
it('calls fetchItemsForList on mounted', () => {
it('calls fetchIssuesForEpic on mounted', () => {
createComponent();
expect(fetchItemsForListSpy).toHaveBeenCalled();
expect(fetchIssuesForEpicSpy).toHaveBeenCalled();
});
describe('computed', () => {
......@@ -93,7 +108,7 @@ describe('EpicsSwimlanes', () => {
});
it('should return Draggable reference when canAdminList prop is true', () => {
expect(wrapper.find(Draggable).exists()).toBe(true);
expect(findDraggable().exists()).toBe(true);
});
});
......@@ -103,7 +118,7 @@ describe('EpicsSwimlanes', () => {
});
it('should not return Draggable reference when canAdminList prop is false', () => {
expect(wrapper.find(Draggable).exists()).toBe(false);
expect(findDraggable().exists()).toBe(false);
});
});
});
......@@ -115,20 +130,31 @@ describe('EpicsSwimlanes', () => {
});
it('displays BoardListHeader components for lists', () => {
expect(wrapper.findAll(BoardListHeader)).toHaveLength(4);
expect(wrapper.findAllComponents(BoardListHeader)).toHaveLength(4);
});
it('displays EpicLane components for epic', () => {
expect(wrapper.findAll(EpicLane)).toHaveLength(5);
expect(wrapper.findAllComponents(EpicLane)).toHaveLength(5);
});
it('does not display IssueLaneList component by default', () => {
expect(wrapper.findComponent(IssueLaneList).exists()).toBe(false);
});
it('displays IssueLaneList component', () => {
expect(wrapper.find(IssueLaneList).exists()).toBe(true);
it('does not display load more epics button if there are no more epics', () => {
expect(findLoadMoreEpicsButton().exists()).toBe(false);
});
it('displays IssueLaneList component when toggling unassigned issues lane', async () => {
wrapper.vm.toggleUnassignedLane();
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(IssueLaneList).exists()).toBe(true);
});
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.findComponent(GlIcon).props('name')).toEqual('issues');
expect(wrapper.findByTestId('issues-lane-issue-count').text()).toEqual('2');
});
it('makes non preset lists draggable', () => {
......@@ -144,19 +170,37 @@ describe('EpicsSwimlanes', () => {
});
});
describe('load more epics', () => {
beforeEach(() => {
createComponent({ hasMoreEpics: true });
});
it('displays load more epics button if there are more epics', () => {
expect(findLoadMoreEpicsButton().exists()).toBe(true);
});
it('calls fetchEpicsSwimlanes action when loading more epics', async () => {
findLoadMoreEpicsButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(fetchEpicsSwimlanesSpy).toHaveBeenCalled();
});
});
describe('Loading skeleton', () => {
it.each`
epicLanesFetchInProgress | listItemsFetchInProgress | expected
${true} | ${true} | ${true}
${false} | ${true} | ${false}
${true} | ${false} | ${false}
${false} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${false} | ${false}
`(
'loading is $expected when epicLanesFetchInProgress is $epicLanesFetchInProgress and listItemsFetchInProgress is $listItemsFetchInProgress',
({ epicLanesFetchInProgress, listItemsFetchInProgress, expected }) => {
createComponent({ epicLanesFetchInProgress, listItemsFetchInProgress });
expect(wrapper.find(SwimlanesLoadingSkeleton).exists()).toBe(expected);
expect(wrapper.findComponent(SwimlanesLoadingSkeleton).exists()).toBe(expected);
},
);
});
......
......@@ -217,6 +217,22 @@ describe('fetchEpicsSwimlanes', () => {
},
};
const queryResponseWithNextPage = {
data: {
group: {
board: {
epics: {
edges: [{ node: mockEpic }],
pageInfo: {
hasNextPage: true,
endCursor: 'ENDCURSOR',
},
},
},
},
},
};
it('should commit mutation RECEIVE_EPICS_SUCCESS on success', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
......@@ -235,35 +251,26 @@ describe('fetchEpicsSwimlanes', () => {
);
});
it('should commit mutation RECEIVE_SWIMLANES_FAILURE on failure', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
it('should commit mutation REQUEST_MORE_EPICS when fetchNext is true', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction(
actions.fetchEpicsSwimlanes,
{},
{ fetchNext: true },
state,
[{ type: types.RECEIVE_SWIMLANES_FAILURE }],
[
{ type: types.REQUEST_MORE_EPICS },
{
type: types.RECEIVE_EPICS_SUCCESS,
payload: { epics: [mockEpic] },
},
],
[],
done,
);
});
it('should dispatch fetchEpicsSwimlanes when page info hasNextPage', (done) => {
const queryResponseWithNextPage = {
data: {
group: {
board: {
epics: {
edges: [{ node: mockEpic }],
pageInfo: {
hasNextPage: true,
endCursor: 'ENDCURSOR',
},
},
},
},
},
};
it('should commit mutation RECEIVE_EPICS_SUCCESS on success with hasMoreEpics when hasNextPage', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponseWithNextPage);
testAction(
......@@ -273,15 +280,23 @@ describe('fetchEpicsSwimlanes', () => {
[
{
type: types.RECEIVE_EPICS_SUCCESS,
payload: { epics: [mockEpic] },
},
],
[
{
type: 'fetchEpicsSwimlanes',
payload: { endCursor: 'ENDCURSOR' },
payload: { epics: [mockEpic], hasMoreEpics: true, epicsEndCursor: 'ENDCURSOR' },
},
],
[],
done,
);
});
it('should commit mutation RECEIVE_SWIMLANES_FAILURE on failure', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
testAction(
actions.fetchEpicsSwimlanes,
{},
state,
[{ type: types.RECEIVE_SWIMLANES_FAILURE }],
[],
done,
);
});
......@@ -330,7 +345,7 @@ describe('fetchItemsForList', () => {
[listId]: pageInfo,
};
it('add epicWildcardId with ANY as value when forSwimlanes is true', () => {
it('add epicWildcardId with NONE as value when noEpicIssues is true', () => {
state = {
...state,
isShowingEpicsSwimlanes: true,
......@@ -339,7 +354,7 @@ describe('fetchItemsForList', () => {
testAction(
actions.fetchItemsForList,
{ listId, forSwimlanes: true },
{ listId, noEpicIssues: true },
state,
[
{
......@@ -348,7 +363,7 @@ describe('fetchItemsForList', () => {
},
{
type: types.RECEIVE_ITEMS_FOR_LIST_SUCCESS,
payload: { listItems: formattedIssues, listPageInfo, listId, noEpicIssues: false },
payload: { listItems: formattedIssues, listPageInfo, listId, noEpicIssues: true },
},
],
[],
......@@ -358,12 +373,14 @@ describe('fetchItemsForList', () => {
variables: {
boardId: 'gid://gitlab/Board/1',
filters: {
epicWildcardId: 'ANY',
epicWildcardId: 'NONE',
},
fullPath: 'gitlab-org',
id: 'gid://gitlab/List/1',
isGroup: true,
isProject: false,
after: undefined,
first: 10,
},
context: {
isSingleRequest: true,
......@@ -517,6 +534,71 @@ describe('updateListWipLimit', () => {
});
});
describe('fetchIssuesForEpic', () => {
const listId = mockLists[0].id;
const epicId = mockEpic.id;
const state = {
fullPath: 'gitlab-org',
boardId: 1,
filterParams: {},
boardType: 'group',
};
const queryResponse = {
data: {
group: {
board: {
lists: {
nodes: [
{
id: listId,
issues: {
edges: [{ node: [mockIssue] }],
},
},
],
},
},
},
},
};
const formattedIssues = formatListIssues(queryResponse.data.group.board.lists);
it('should commit mutations REQUEST_ISSUES_FOR_EPIC and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction(
actions.fetchIssuesForEpic,
epicId,
state,
[
{ type: types.REQUEST_ISSUES_FOR_EPIC, payload: epicId },
{ type: types.RECEIVE_ISSUES_FOR_EPIC_SUCCESS, payload: { ...formattedIssues, epicId } },
],
[],
done,
);
});
it('should commit mutations REQUEST_ISSUES_FOR_EPIC and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
testAction(
actions.fetchIssuesForEpic,
epicId,
state,
[
{ type: types.REQUEST_ISSUES_FOR_EPIC, payload: epicId },
{ type: types.RECEIVE_ISSUES_FOR_EPIC_FAILURE, payload: epicId },
],
[],
done,
);
});
});
describe('toggleEpicSwimlanes', () => {
it('should commit mutation TOGGLE_EPICS_SWIMLANES', () => {
const startURl = `${TEST_HOST}/groups/gitlab-org/-/boards/1?group_by=epic`;
......
import mutations from 'ee/boards/stores/mutations';
import { mockEpics, mockEpic, mockLists } from '../mock_data';
import { mockEpics, mockEpic, mockLists, mockIssue, mockIssue2 } from '../mock_data';
const initialBoardListsState = {
'gid://gitlab/List/1': mockLists[0],
'gid://gitlab/List/2': mockLists[1],
};
const epicId = mockEpic.id;
let state = {
boardItemsByListId: {},
boardItems: {},
boardLists: initialBoardListsState,
epicsFlags: {
[epicId]: { isLoading: true },
},
};
describe('SET_SHOW_LABELS', () => {
......@@ -25,6 +30,53 @@ describe('SET_SHOW_LABELS', () => {
});
});
describe('REQUEST_ISSUES_FOR_EPIC', () => {
it('sets isLoading epicsFlags in state for epicId to true', () => {
state = {
...state,
epicsFlags: {
[epicId]: { isLoading: false },
},
};
mutations.REQUEST_ISSUES_FOR_EPIC(state, epicId);
expect(state.epicsFlags[epicId].isLoading).toBe(true);
});
});
describe('RECEIVE_ISSUES_FOR_EPIC_SUCCESS', () => {
it('sets boardItemsByListId and issues state for epic issues and loading state to false', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id],
'gid://gitlab/List/2': [mockIssue2.id],
};
const issues = {
436: mockIssue,
437: mockIssue2,
};
mutations.RECEIVE_ISSUES_FOR_EPIC_SUCCESS(state, {
listData: listIssues,
boardItems: issues,
epicId,
});
expect(state.boardItemsByListId).toEqual(listIssues);
expect(state.boardItems).toEqual(issues);
expect(state.epicsFlags[epicId].isLoading).toBe(false);
});
});
describe('RECEIVE_ISSUES_FOR_EPIC_FAILURE', () => {
it('sets loading state to false for epic and error message', () => {
mutations.RECEIVE_ISSUES_FOR_EPIC_FAILURE(state, epicId);
expect(state.error).toEqual('An error occurred while fetching issues. Please reload the page.');
expect(state.epicsFlags[epicId].isLoading).toBe(false);
});
});
describe('TOGGLE_EPICS_SWIMLANES', () => {
it('toggles isShowingEpicsSwimlanes from true to false', () => {
state = {
......@@ -134,6 +186,21 @@ describe('RECEIVE_SWIMLANES_FAILURE', () => {
});
});
describe('REQUEST_MORE_EPICS', () => {
it('sets epicLanesFetchMoreInProgress to true', () => {
state = {
...state,
epicsSwimlanesFetchInProgress: {
epicLanesFetchMoreInProgress: false,
},
};
mutations.REQUEST_MORE_EPICS(state);
expect(state.epicsSwimlanesFetchInProgress.epicLanesFetchMoreInProgress).toBe(true);
});
});
describe('RECEIVE_EPICS_SUCCESS', () => {
it('populates epics and canAdminEpic with payload', () => {
state = {
......
......@@ -8,4 +8,10 @@ module BoardHelpers
wait_for_requests
end
def load_unassigned_issues
page.find("[data-testid='unassigned-lane-toggle']").click
wait_for_requests
end
end
......@@ -5343,6 +5343,9 @@ msgstr ""
msgid "Boards|An error occurred while fetching group projects. Please try again."
msgstr ""
msgid "Boards|An error occurred while fetching issues. Please reload the page."
msgstr ""
msgid "Boards|An error occurred while fetching labels. Please reload the page."
msgstr ""
......@@ -5432,9 +5435,15 @@ msgstr ""
msgid "Board|Failed to delete board. Please try again."
msgstr ""
msgid "Board|Load more epics"
msgstr ""
msgid "Board|Load more issues"
msgstr ""
msgid "Board|Loading epics"
msgstr ""
msgid "Bold text"
msgstr ""
......
......@@ -31,4 +31,12 @@ RSpec.describe 'Project issue boards sidebar', :js do
def click_first_issue_card
click_card(first_card)
end
def refresh_and_click_first_card
page.refresh
wait_for_requests
first_card.click
end
end
......@@ -175,12 +175,4 @@ RSpec.shared_examples 'issue boards sidebar' do
end
end
end
def refresh_and_click_first_card
page.refresh
wait_for_requests
first_card.click
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