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