Commit a71462e6 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '250268-paginate-list-issues' into 'master'

[RUN AS-IF-FOSS] Paginate issues within board list

See merge request gitlab-org/gitlab!42430
parents 3c5e8cb8 c05ed1d3
......@@ -17,9 +17,15 @@ export function formatIssue(issue) {
export function formatListIssues(listIssues) {
const issues = {};
let listIssuesCount;
const listData = listIssues.nodes.reduce((map, list) => {
const sortedIssues = sortBy(list.issues.nodes, 'relativePosition');
listIssuesCount = list.issues.count;
let sortedIssues = list.issues.edges.map(issueNode => ({
...issueNode.node,
}));
sortedIssues = sortBy(sortedIssues, 'relativePosition');
return {
...map,
[list.id]: sortedIssues.map(i => {
......@@ -39,7 +45,17 @@ export function formatListIssues(listIssues) {
};
}, {});
return { listData, issues };
return { listData, issues, listIssuesCount };
}
export function formatListsPageInfo(lists) {
const listData = lists.nodes.reduce((map, list) => {
return {
...map,
[list.id]: list.issues.pageInfo,
};
}, {});
return listData;
}
export function fullBoardId(boardId) {
......
......@@ -7,6 +7,7 @@ import Tooltip from '~/vue_shared/directives/tooltip';
import EmptyComponent from '~/vue_shared/components/empty_component';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardList from './board_list.vue';
import BoardListNew from './board_list_new.vue';
import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
......@@ -16,7 +17,7 @@ export default {
components: {
BoardPromotionState: EmptyComponent,
BoardListHeader,
BoardList,
BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList,
},
directives: {
Tooltip,
......@@ -72,7 +73,7 @@ export default {
filter: {
handler() {
if (this.shouldFetchIssues) {
this.fetchIssuesForList(this.list.id);
this.fetchIssuesForList({ listId: this.list.id });
} else {
this.list.page = 1;
this.list.getIssues(true).catch(() => {
......@@ -85,7 +86,7 @@ export default {
},
mounted() {
if (this.shouldFetchIssues) {
this.fetchIssuesForList(this.list.id);
this.fetchIssuesForList({ listId: this.list.id });
}
const instance = this;
......@@ -144,7 +145,6 @@ export default {
:disabled="disabled"
:issues="listIssues"
:list="list"
:loading="list.loading"
/>
<!-- Will be only available in EE -->
......
......@@ -14,6 +14,8 @@ import {
sortableEnd,
} from '../mixins/sortable_default_options';
// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards
if (gon.features && gon.features.multiSelectBoard) {
Sortable.mount(new MultiDrag());
}
......@@ -39,10 +41,6 @@ export default {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
data() {
return {
......@@ -62,6 +60,9 @@ export default {
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
},
loading() {
return this.list.loading;
},
},
watch: {
filters: {
......@@ -72,7 +73,6 @@ export default {
deep: true,
},
issues() {
if (this.glFeatures.graphqlBoardLists) return;
this.$nextTick(() => {
if (
this.scrollHeight() <= this.listHeight() &&
......@@ -98,6 +98,8 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
// TODO: Use Draggable in ./board_list_new.vue to drag & drop issue
// https://gitlab.com/gitlab-org/gitlab/-/issues/218164
const multiSelectOpts = {};
if (gon.features && gon.features.multiSelectBoard) {
multiSelectOpts.multiDrag = true;
......@@ -403,8 +405,6 @@ export default {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
if (this.glFeatures.graphqlBoardLists) return;
if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
this.loadNextPage();
}
......
......@@ -176,7 +176,6 @@ export default {
<header
:class="{
'has-border': list.label && list.label.color,
'gl-relative': list.isExpanded,
'gl-h-full': !list.isExpanded,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}"
......
<script>
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import BoardNewIssue from './board_new_issue.vue';
import BoardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'BoardList',
components: {
BoardCard,
BoardNewIssue,
GlLoadingIcon,
},
mixins: [glFeatureFlagMixin()],
props: {
disabled: {
type: Boolean,
required: true,
},
list: {
type: Object,
required: true,
},
issues: {
type: Array,
required: true,
},
},
data() {
return {
scrollOffset: 250,
filters: boardsStore.state.filters,
showCount: false,
showIssueForm: false,
};
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.issues.length,
total: this.list.issuesSize,
});
},
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
},
hasNextPage() {
return this.pageInfoByListId[this.list.id].hasNextPage;
},
loading() {
return this.listsFlags[this.list.id]?.isLoading;
},
},
watch: {
filters: {
handler() {
this.list.loadingMore = false;
this.$refs.list.scrollTop = 0;
},
deep: true,
},
issues() {
this.$nextTick(() => {
this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
});
},
},
created() {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
// Scroll event on list to load more
this.$refs.list.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
methods: {
...mapActions(['fetchIssuesForList']),
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight() {
return this.$refs.list.scrollHeight;
},
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
scrollToTop() {
this.$refs.list.scrollTop = 0;
},
loadNextPage() {
const loadingDone = () => {
this.list.loadingMore = false;
};
this.list.loadingMore = true;
this.fetchIssuesForList({ listId: this.list.id, fetchNext: true })
.then(loadingDone)
.catch(loadingDone);
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
window.requestAnimationFrame(() => {
if (
!this.list.loadingMore &&
this.scrollTop() > this.scrollHeight() - this.scrollOffset &&
this.hasNextPage
) {
this.loadNextPage();
}
});
},
},
};
</script>
<template>
<div
v-show="list.isExpanded"
class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
data-qa-selector="board_list_cards_area"
>
<div
v-if="loading"
class="gl-mt-4 gl-text-center"
:aria-label="__('Loading issues')"
data-testid="board_list_loading"
>
<gl-loading-icon />
</div>
<board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
<ul
v-show="!loading"
ref="list"
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
>
<board-card
v-for="(issue, index) in issues"
ref="issue"
:key="issue.id"
:index="index"
:list="list"
:issue="issue"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
<span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</ul>
</div>
</template>
......@@ -161,6 +161,7 @@ export default () => {
'fetchEpicsSwimlanes',
'resetIssues',
'resetEpics',
'fetchLists',
]),
initialBoardLoad() {
boardsStore
......@@ -183,7 +184,10 @@ export default () => {
this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)));
if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) {
this.resetEpics();
this.fetchEpicsSwimlanes({ withLists: false });
this.resetIssues();
this.fetchEpicsSwimlanes({});
} else if (gon.features.graphqlBoardLists && !this.isShowingEpicsSwimlanes) {
this.fetchLists();
this.resetIssues();
}
},
......
#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
query ListIssues(
$fullPath: ID!
$boardId: ID!
$filters: BoardIssueInput
$isGroup: Boolean = false
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
board(id: $boardId) {
lists(issueFilters: $filters) {
nodes {
...BoardListFragment
}
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
board(id: $boardId) {
lists(issueFilters: $filters) {
nodes {
...BoardListFragment
}
}
}
}
}
#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
query GroupBoard($fullPath: ID!, $boardId: ID!) {
group(fullPath: $fullPath) {
board(id: $boardId) {
lists {
nodes {
...BoardListFragment
}
}
}
}
}
......@@ -7,17 +7,26 @@ query ListIssues(
$filters: BoardIssueInput
$isGroup: Boolean = false
$isProject: Boolean = false
$after: String
$first: Int
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
board(id: $boardId) {
lists(id: $id) {
nodes {
id
issues(filters: $filters) {
nodes {
issues(first: $first, filters: $filters, after: $after) {
count
edges {
node {
...IssueNode
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
......@@ -27,11 +36,18 @@ query ListIssues(
lists(id: $id) {
nodes {
id
issues(filters: $filters) {
nodes {
issues(first: $first, filters: $filters, after: $after) {
count
edges {
node {
...IssueNode
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
......
#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
query ProjectBoard($fullPath: ID!, $boardId: ID!) {
project(fullPath: $fullPath) {
board(id: $boardId) {
lists {
nodes {
...BoardListFragment
}
}
}
}
}
......@@ -3,16 +3,15 @@ import { sortBy, pick } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import createDefaultClient from '~/lib/graphql';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { BoardType, ListType, inactiveId } from '~/boards/constants';
import * as types from './mutation_types';
import { formatListIssues, fullBoardId } from '../boards_util';
import { formatListIssues, fullBoardId, formatListsPageInfo } from '../boards_util';
import boardStore from '~/boards/stores/boards_store';
import listsIssuesQuery from '../queries/lists_issues.query.graphql';
import projectBoardQuery from '../queries/project_board.query.graphql';
import groupBoardQuery from '../queries/group_board.query.graphql';
import boardListsQuery from '../queries/board_lists.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
......@@ -22,7 +21,12 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
export const gqlClient = createDefaultClient();
export const gqlClient = createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
export default {
setInitialBoardData: ({ commit }, data) => {
......@@ -50,27 +54,20 @@ export default {
},
fetchLists: ({ commit, state, dispatch }) => {
const { endpoints, boardType } = state;
const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
let query;
if (boardType === BoardType.group) {
query = groupBoardQuery;
} else if (boardType === BoardType.project) {
query = projectBoardQuery;
} else {
createFlash(__('Invalid board'));
return Promise.reject();
}
const variables = {
fullPath,
boardId: fullBoardId(boardId),
filters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
};
return gqlClient
.query({
query,
query: boardListsQuery,
variables,
})
.then(({ data }) => {
......@@ -197,7 +194,9 @@ export default {
notImplemented();
},
fetchIssuesForList: ({ state, commit }, listId) => {
fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => {
commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext });
const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
......@@ -208,6 +207,8 @@ export default {
filters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
first: 20,
after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
};
return gqlClient
......@@ -221,7 +222,8 @@ export default {
.then(({ data }) => {
const { lists } = data[boardType]?.board;
const listIssues = formatListIssues(lists);
commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listId });
const listPageInfo = formatListsPageInfo(lists);
commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId });
})
.catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId));
},
......
......@@ -12,6 +12,7 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST';
export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS';
export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR';
export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST';
export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
......
import Vue from 'vue';
import { sortBy, pull } from 'lodash';
import { sortBy, pull, union } from 'lodash';
import { formatIssue, moveIssueListHelper } from '../boards_util';
import * as mutationTypes from './mutation_types';
import { s__ } from '~/locale';
......@@ -99,20 +99,30 @@ export default {
notImplemented();
},
[mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (state, { listIssues, listId }) => {
[mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => {
Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
},
[mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (
state,
{ listIssues, listPageInfo, listId },
) => {
const { listData, issues } = listIssues;
Vue.set(state, 'issues', { ...state.issues, ...issues });
Vue.set(state.issuesByListId, listId, listData[listId]);
const listIndex = state.boardLists.findIndex(l => l.id === listId);
Vue.set(state.boardLists[listIndex], 'loading', false);
Vue.set(
state.issuesByListId,
listId,
union(state.issuesByListId[listId] || [], listData[listId]),
);
Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]);
Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
},
[mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => {
state.error = s__(
'Boards|An error occurred while fetching the board issues. Please reload the page.',
);
const listIndex = state.boardLists.findIndex(l => l.id === listId);
Vue.set(state.boardLists[listIndex], 'loading', false);
Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
},
[mutationTypes.RESET_ISSUES]: state => {
......
......@@ -9,7 +9,9 @@ export default () => ({
activeId: inactiveId,
sidebarType: '',
boardLists: [],
listsFlags: {},
issuesByListId: {},
pageInfoByListId: {},
issues: {},
filterParams: {},
error: undefined,
......
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import BoardListHeaderFoss from '~/boards/components/board_list_header.vue';
import { __, sprintf, s__, n__ } from '~/locale';
import { __, sprintf, s__ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store';
import { inactiveId, LIST } from '~/boards/constants';
import eventHub from '~/sidebar/event_hub';
......@@ -16,13 +16,6 @@ export default {
computed: {
...mapState(['activeId', 'issuesByListId']),
...mapGetters(['isSwimlanesOn']),
issuesCount() {
if (this.isSwimlanesOn) {
return this.issuesByListId[this.list.id] ? this.issuesByListId[this.list.id].length : 0;
}
return this.list.issuesSize;
},
issuesTooltip() {
const { maxIssueCount } = this.list;
......@@ -36,9 +29,6 @@ export default {
// TODO: Remove this pattern.
return BoardListHeaderFoss.computed.issuesTooltip.call(this);
},
issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
weightCountToolTip() {
const { totalWeight } = this.list;
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { DRAGGABLE_TAG } from '../constants';
......@@ -14,6 +14,7 @@ export default {
BoardListHeader,
EpicLane,
IssuesLaneList,
GlButton,
GlIcon,
},
directives: {
......@@ -35,14 +36,14 @@ export default {
},
},
computed: {
...mapState(['epics']),
...mapState(['epics', 'pageInfoByListId', 'listsFlags']),
...mapGetters(['getUnassignedIssues']),
unassignedIssues() {
return listId => this.getUnassignedIssues(listId);
},
unassignedIssuesCount() {
return this.lists.reduce(
(total, list) => total + this.getUnassignedIssues(list.id).length,
(total, list) => total + this.listsFlags[list.id]?.unassignedIssuesCount || 0,
0,
);
},
......@@ -65,9 +66,12 @@ export default {
return this.canAdminList ? options : {};
},
hasMoreUnassignedIssues() {
return this.lists.some(list => this.pageInfoByListId[list.id]?.hasNextPage);
},
},
methods: {
...mapActions(['moveList']),
...mapActions(['moveList', 'fetchIssuesForList']),
handleDragOnEnd(params) {
const { newIndex, oldIndex, item } = params;
const { listId } = item.dataset;
......@@ -78,6 +82,13 @@ export default {
adjustmentValue: newIndex < oldIndex ? 1 : -1,
});
},
fetchMoreUnassignedIssues() {
this.lists.forEach(list => {
if (this.pageInfoByListId[list.id]?.hasNextPage) {
this.fetchIssuesForList({ listId: list.id, fetchNext: true, noEpicIssues: true });
}
});
},
},
};
</script>
......@@ -142,7 +153,8 @@ export default {
</span>
</div>
</div>
<div class="gl-display-flex" data-testid="board-lane-unassigned-issues">
<div data-testid="board-lane-unassigned-issues">
<div class="gl-display-flex">
<issues-lane-list
v-for="list in lists"
:key="`${list.id}-issues`"
......@@ -155,4 +167,15 @@ export default {
</div>
</div>
</div>
<div v-if="hasMoreUnassignedIssues" class="gl-p-3 gl-pr-0 gl-sticky gl-left-0 gl-max-w-full">
<gl-button
category="tertiary"
variant="info"
class="gl-w-full"
@click="fetchMoreUnassignedIssues()"
>
{{ s__('Board|Load more issues') }}
</gl-button>
</div>
</div>
</template>
......@@ -50,7 +50,7 @@ export default {
};
},
computed: {
...mapState(['activeId', 'filterParams', 'canAdminEpic']),
...mapState(['activeId', 'filterParams', 'canAdminEpic', 'listsFlags']),
treeRootWrapper() {
return this.canAdminList && this.canAdminEpic ? Draggable : 'ul';
},
......@@ -68,12 +68,15 @@ export default {
return this.canAdminList ? options : {};
},
isLoadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore;
},
},
watch: {
filterParams: {
handler() {
if (this.isUnassignedIssuesLane) {
this.fetchIssuesForList(this.list.id);
this.fetchIssuesForList({ listId: this.list.id, noEpicIssues: true });
}
},
deep: true,
......@@ -173,6 +176,7 @@ export default {
:is-active="isActiveIssue(issue)"
@show="showIssue(issue)"
/>
<gl-loading-icon v-if="isLoadingMore && isUnassignedIssuesLane" size="sm" class="gl-py-3" />
</component>
</div>
</div>
......
......@@ -5,7 +5,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { UPDATE_ISSUE_BY_ID } from '~/boards/stores/mutation_types';
import { RECEIVE_EPICS_SUCCESS } from '../../stores/mutation_types';
import { RECEIVE_FIRST_EPICS_SUCCESS } from '../../stores/mutation_types';
export default {
components: {
......@@ -38,7 +38,7 @@ export default {
methods: {
...mapMutations({
updateIssueById: UPDATE_ISSUE_BY_ID,
receiveEpicsSuccess: RECEIVE_EPICS_SUCCESS,
receiveEpicsSuccess: RECEIVE_FIRST_EPICS_SUCCESS,
}),
...mapActions(['setActiveIssueEpic']),
openEpicsDropdown() {
......
......@@ -12,12 +12,12 @@ query BoardEE(
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
board(id: $boardId) {
lists @include(if: $withLists) {
lists(issueFilters: $issueFilters) @include(if: $withLists) {
nodes {
...BoardListFragment
}
}
epics(first: 2, issueFilters: $issueFilters, after: $after) {
epics(first: 20, issueFilters: $issueFilters, after: $after) {
edges {
node {
...BoardEpicNode
......@@ -32,7 +32,7 @@ query BoardEE(
}
project(fullPath: $fullPath) @include(if: $isProject) {
board(id: $boardId) {
lists @include(if: $withLists) {
lists(issueFilters: $issueFilters) @include(if: $withLists) {
nodes {
...BoardListFragment
}
......
......@@ -10,11 +10,11 @@ import { EpicFilterType } from '../constants';
import boardsStoreEE from './boards_store_ee';
import * as types from './mutation_types';
import { fullEpicId } from '../boards_util';
import { formatListIssues, fullBoardId } from '~/boards/boards_util';
import { formatListIssues, formatListsPageInfo, fullBoardId } from '~/boards/boards_util';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/boards/eventhub';
import createDefaultClient from '~/lib/graphql';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql';
import issueSetEpic from '../queries/issue_set_epic.mutation.graphql';
import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql';
......@@ -25,19 +25,24 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
export const gqlClient = createDefaultClient();
export const gqlClient = createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
const fetchAndFormatListIssues = (state, extraVariables) => {
const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
const variables = {
...extraVariables,
fullPath,
boardId: fullBoardId(boardId),
filters: { ...filterParams },
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
...extraVariables,
};
return gqlClient
......@@ -50,7 +55,7 @@ const fetchAndFormatListIssues = (state, extraVariables) => {
})
.then(({ data }) => {
const { lists } = data[boardType]?.board;
return formatListIssues(lists);
return { listIssues: formatListIssues(lists), listPageInfo: formatListsPageInfo(lists) };
});
};
......@@ -104,7 +109,22 @@ export default {
}));
if (!withLists) {
commit(types.RECEIVE_EPICS_SUCCESS, { epics: epicsFormatted });
commit(types.RECEIVE_EPICS_SUCCESS, epicsFormatted);
} else {
if (lists) {
let boardLists = lists.nodes.map(list =>
boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }),
);
boardLists = sortBy([...boardLists], 'position');
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, boardLists);
}
if (epicsFormatted) {
commit(types.RECEIVE_FIRST_EPICS_SUCCESS, {
epics: epicsFormatted,
canAdminEpic: epics.edges[0]?.node?.userPermissions?.adminEpic,
});
}
}
if (epics.pageInfo?.hasNextPage) {
......@@ -113,12 +133,6 @@ export default {
endCursor: epics.pageInfo.endCursor,
});
}
return {
epics: epicsFormatted,
lists: lists?.nodes,
canAdminEpic: epics.edges[0]?.node?.userPermissions?.adminEpic,
};
})
.catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE));
},
......@@ -177,19 +191,28 @@ export default {
notImplemented();
},
fetchIssuesForList: ({ state, commit }, listId, noEpicIssues = false) => {
fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false, noEpicIssues = false }) => {
commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext });
const { filterParams } = state;
const variables = {
id: listId,
filters: noEpicIssues
? { ...filterParams, epicWildcardId: EpicFilterType.none }
? { ...filterParams, epicWildcardId: EpicFilterType.none.toUpperCase() }
: filterParams,
after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
first: 20,
};
return fetchAndFormatListIssues(state, variables)
.then(listIssues => {
commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listId });
.then(({ listIssues, listPageInfo }) => {
commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, {
listIssues,
listPageInfo,
listId,
noEpicIssues,
});
})
.catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId));
},
......@@ -204,7 +227,7 @@ export default {
};
return fetchAndFormatListIssues(state, variables)
.then(listIssues => {
.then(({ listIssues }) => {
commit(types.RECEIVE_ISSUES_FOR_EPIC_SUCCESS, { ...listIssues, epicId });
})
.catch(() => commit(types.RECEIVE_ISSUES_FOR_EPIC_FAILURE, epicId));
......@@ -214,21 +237,7 @@ export default {
commit(types.TOGGLE_EPICS_SWIMLANES);
if (state.isShowingEpicsSwimlanes) {
dispatch('fetchEpicsSwimlanes', {})
.then(({ lists, epics, canAdminEpic }) => {
if (lists) {
let boardLists = lists.map(list =>
boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }),
);
boardLists = sortBy([...boardLists], 'position');
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, boardLists);
}
if (epics) {
commit(types.RECEIVE_EPICS_SUCCESS, { epics, canAdminEpic });
}
})
.catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE));
dispatch('fetchEpicsSwimlanes', {}).catch(() => commit(types.RECEIVE_SWIMLANES_FAILURE));
} else if (!gon.features.graphqlBoardLists) {
boardsStore.create();
eventHub.$emit('initialBoardLoad');
......
......@@ -11,6 +11,7 @@ export const REQUEST_REMOVE_BOARD = 'REQUEST_REMOVE_BOARD';
export const RECEIVE_REMOVE_BOARD_SUCCESS = 'RECEIVE_REMOVE_BOARD_SUCCESS';
export const RECEIVE_REMOVE_BOARD_ERROR = 'RECEIVE_REMOVE_BOARD_ERROR';
export const TOGGLE_PROMOTION_STATE = 'TOGGLE_PROMOTION_STATE';
export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST';
export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
export const REQUEST_ISSUES_FOR_EPIC = 'REQUEST_ISSUES_FOR_EPIC';
......@@ -19,6 +20,7 @@ export const RECEIVE_ISSUES_FOR_EPIC_FAILURE = 'RECEIVE_ISSUES_FOR_EPIC_FAILURE'
export const TOGGLE_EPICS_SWIMLANES = 'TOGGLE_EPICS_SWIMLANES';
export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE';
export const RECEIVE_FIRST_EPICS_SUCCESS = 'RECEIVE_FIRST_EPICS_SUCCESS';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const RESET_EPICS = 'RESET_EPICS';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
......
......@@ -68,6 +68,25 @@ export default {
notImplemented();
},
[mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (
state,
{ listIssues, listPageInfo, listId, noEpicIssues },
) => {
const { listData, issues, listIssuesCount } = listIssues;
Vue.set(state, 'issues', { ...state.issues, ...issues });
Vue.set(
state.issuesByListId,
listId,
union(state.issuesByListId[listId] || [], listData[listId]),
);
Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]);
Vue.set(state.listsFlags, listId, {
isLoading: false,
isLoadingMore: false,
unassignedIssuesCount: noEpicIssues ? listIssuesCount : undefined,
});
},
[mutationTypes.REQUEST_ISSUES_FOR_EPIC]: (state, epicId) => {
Vue.set(state.epicsFlags, epicId, { isLoading: true });
},
......@@ -103,9 +122,15 @@ export default {
state.epicsSwimlanesFetchInProgress = false;
},
[mutationTypes.RECEIVE_EPICS_SUCCESS]: (state, { epics, canAdminEpic }) => {
Vue.set(state, 'epics', union(state.epics || [], epics));
[mutationTypes.RECEIVE_FIRST_EPICS_SUCCESS]: (state, { epics, canAdminEpic }) => {
Vue.set(state, 'epics', epics);
if (canAdminEpic !== undefined) {
state.canAdminEpic = canAdminEpic;
}
},
[mutationTypes.RECEIVE_EPICS_SUCCESS]: (state, epics) => {
Vue.set(state, 'epics', union(state.epics || [], epics));
},
[mutationTypes.RESET_EPICS]: state => {
......
......@@ -81,6 +81,7 @@ RSpec.describe 'epics swimlanes', :js do
it 'between lists within unassigned lane' do
wait_for_board_cards(1, 2)
wait_for_board_cards_in_second_epic(1, 1)
wait_for_board_cards_in_unassigned_lane(0, 1)
drag(list_from_index: 6, list_to_index: 7)
......
......@@ -43,47 +43,60 @@ RSpec.describe 'epics swimlanes filtering', :js do
page.find('.dropdown-item', text: 'Epic').click
end
wait_for_all_requests
stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 200)
end
it 'filters by author' do
wait_for_all_requests
set_filter("author", user2.username)
click_filter_link(user2.username)
submit_filter
wait_for_requests
wait_for_all_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
it 'filters by assignee' do
wait_for_all_requests
set_filter("assignee", user.username)
click_filter_link(user.username)
submit_filter
wait_for_requests
wait_for_all_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
it 'filters by milestone' do
wait_for_all_requests
set_filter("milestone", "\"#{milestone.title}")
click_filter_link(milestone.title)
submit_filter
wait_for_requests
wait_for_all_requests
wait_for_board_cards(2, 1)
wait_for_board_cards(3, 0)
wait_for_board_cards(4, 0)
end
it 'filters by label' do
wait_for_all_requests
set_filter("label", testing.title)
click_filter_link(testing.title)
submit_filter
wait_for_requests
wait_for_all_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
......@@ -91,7 +104,7 @@ RSpec.describe 'epics swimlanes filtering', :js do
def visit_board_page
visit project_boards_path(project)
wait_for_requests
wait_for_all_requests
end
def wait_for_board_cards(board_number, expected_cards)
......
......@@ -21,6 +21,18 @@ describe('EpicsSwimlanes', () => {
epics: mockEpics,
issuesByListId: mockIssuesByListId,
issues,
pageInfoByListId: {
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
listsFlags: {
'gid://gitlab/List/1': {
unassignedIssuesCount: 1,
},
'gid://gitlab/List/2': {
unassignedIssuesCount: 1,
},
},
},
getters,
});
......
......@@ -104,7 +104,7 @@ describe('fetchEpicsSwimlanes', () => {
[
{
type: types.RECEIVE_EPICS_SUCCESS,
payload: { epics: [mockEpic] },
payload: [mockEpic],
},
],
[],
......@@ -150,7 +150,7 @@ describe('fetchEpicsSwimlanes', () => {
[
{
type: types.RECEIVE_EPICS_SUCCESS,
payload: { epics: [mockEpic] },
payload: [mockEpic],
},
],
[
......@@ -288,7 +288,7 @@ describe('fetchIssuesForEpic', () => {
{
id: listId,
issues: {
nodes: [mockIssue],
edges: [{ node: [mockIssue] }],
},
},
],
......
......@@ -206,6 +206,21 @@ describe('RECEIVE_SWIMLANES_FAILURE', () => {
});
});
describe('RECEIVE_FIRST_EPICS_SUCCESS', () => {
it('populates epics and canAdminEpic with payload', () => {
state = {
...state,
epics: {},
canAdminEpic: false,
};
mutations.RECEIVE_FIRST_EPICS_SUCCESS(state, { epics: mockEpics, canAdminEpic: true });
expect(state.epics).toEqual(mockEpics);
expect(state.canAdminEpic).toEqual(true);
});
});
describe('RECEIVE_EPICS_SUCCESS', () => {
it('populates epics with payload', () => {
state = {
......@@ -213,7 +228,7 @@ describe('RECEIVE_EPICS_SUCCESS', () => {
epics: {},
};
mutations.RECEIVE_EPICS_SUCCESS(state, { epics: mockEpics });
mutations.RECEIVE_EPICS_SUCCESS(state, mockEpics);
expect(state.epics).toEqual(mockEpics);
});
......
......@@ -4188,6 +4188,9 @@ msgstr ""
msgid "Boards|View scope"
msgstr ""
msgid "Board|Load more issues"
msgstr ""
msgid "Both project and dashboard_path are required"
msgstr ""
......@@ -14003,9 +14006,6 @@ msgstr ""
msgid "Invalid URL"
msgstr ""
msgid "Invalid board"
msgstr ""
msgid "Invalid container_name"
msgstr ""
......
......@@ -17,6 +17,7 @@ RSpec.describe 'Labels Hierarchy', :js do
let!(:project_label_1) { create(:label, project: project_1, title: 'Label_4') }
before do
stub_feature_flags(graphql_board_lists: false)
grandparent.add_owner(user)
sign_in(user)
......
/* global List */
/* global ListIssue */
import Vuex from 'vuex';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import { createLocalVue, mount } from '@vue/test-utils';
import eventHub from '~/boards/eventhub';
import BoardList from '~/boards/components/board_list_new.vue';
import BoardCard from '~/boards/components/board_card.vue';
import '~/boards/models/issue';
import '~/boards/models/list';
import { listObj, mockIssuesByListId, issues } from './mock_data';
import defaultState from '~/boards/stores/state';
const localVue = createLocalVue();
localVue.use(Vuex);
const actions = {
fetchIssuesForList: jest.fn(),
};
const createStore = (state = defaultState) => {
return new Vuex.Store({
state,
actions,
});
};
const createComponent = ({
listIssueProps = {},
componentProps = {},
listProps = {},
state = {},
} = {}) => {
const store = createStore({
issuesByListId: mockIssuesByListId,
issues,
pageInfoByListId: {
'gid://gitlab/List/1': { hasNextPage: true },
'gid://gitlab/List/2': {},
},
listsFlags: {
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
...state,
});
const list = new List({
...listObj,
id: 'gid://gitlab/List/1',
...listProps,
doNotFetchIssues: true,
});
const issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
assignees: [],
...listIssueProps,
});
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
list.issuesSize = 1;
}
const component = mount(BoardList, {
localVue,
propsData: {
disabled: false,
list,
issues: [issue],
...componentProps,
},
store,
provide: {
groupId: null,
rootPath: '/',
},
});
return component;
};
describe('Board list component', () => {
let wrapper;
useFakeRequestAnimationFrame();
describe('When Expanded', () => {
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders component', () => {
expect(wrapper.find('.board-list-component').exists()).toBe(true);
});
it('renders loading icon', () => {
wrapper = createComponent({
state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } },
});
expect(wrapper.find('[data-testid="board_list_loading"').exists()).toBe(true);
});
it('renders issues', () => {
expect(wrapper.findAll(BoardCard).length).toBe(1);
});
it('sets data attribute with issue id', () => {
expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
});
it('shows new issue form', async () => {
wrapper.vm.toggleForm();
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
});
it('shows new issue form after eventhub event', async () => {
eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`);
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
});
it('does not show new issue form for closed list', () => {
wrapper.setProps({ list: { type: 'closed' } });
wrapper.vm.toggleForm();
expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
});
it('shows count list item', async () => {
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').exists()).toBe(true);
expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues');
});
it('sets data attribute with invalid id', async () => {
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
});
it('shows how many more issues to load', async () => {
wrapper.vm.showCount = true;
wrapper.setProps({ list: { issuesSize: 20 } });
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
});
});
describe('load more issues', () => {
beforeEach(() => {
wrapper = createComponent({
listProps: { issuesSize: 25 },
});
});
afterEach(() => {
wrapper.destroy();
});
it('loads more issues after scrolling', () => {
wrapper.vm.$refs.list.dispatchEvent(new Event('scroll'));
expect(actions.fetchIssuesForList).toHaveBeenCalled();
});
it('does not load issues if already loading', () => {
wrapper.vm.$refs.list.dispatchEvent(new Event('scroll'));
wrapper.vm.$refs.list.dispatchEvent(new Event('scroll'));
expect(actions.fetchIssuesForList).toHaveBeenCalledTimes(1);
});
it('shows loading more spinner', async () => {
wrapper.vm.showCount = true;
wrapper.vm.list.loadingMore = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
});
});
describe('max issue count warning', () => {
beforeEach(() => {
wrapper = createComponent({
listProps: { issuesSize: 50 },
});
});
afterEach(() => {
wrapper.destroy();
});
describe('when issue count exceeds max issue count', () => {
it('sets background to bg-danger-100', async () => {
wrapper.setProps({ list: { issuesSize: 4, maxIssueCount: 3 } });
await wrapper.vm.$nextTick();
expect(wrapper.find('.bg-danger-100').exists()).toBe(true);
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to bg-danger-100', () => {
wrapper.setProps({ list: { issuesSize: 2, maxIssueCount: 3 } });
expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
});
});
describe('when list max issue count is 0', () => {
it('does not sets background to bg-danger-100', () => {
wrapper.setProps({ list: { maxIssueCount: 0 } });
expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
});
});
});
});
......@@ -44,7 +44,6 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP
disabled: false,
list,
issues: list.issues,
loading: false,
...componentProps,
},
provide: {
......@@ -94,7 +93,7 @@ describe('Board list component', () => {
});
it('renders loading icon', () => {
component.loading = true;
component.list.loading = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
......
......@@ -250,6 +250,13 @@ describe('fetchIssuesForList', () => {
boardType: 'group',
};
const mockIssuesNodes = mockIssues.map(issue => ({ node: issue }));
const pageInfo = {
endCursor: '',
hasNextPage: false,
};
const queryResponse = {
data: {
group: {
......@@ -259,7 +266,8 @@ describe('fetchIssuesForList', () => {
{
id: listId,
issues: {
nodes: mockIssues,
edges: mockIssuesNodes,
pageInfo,
},
},
],
......@@ -271,17 +279,25 @@ describe('fetchIssuesForList', () => {
const formattedIssues = formatListIssues(queryResponse.data.group.board.lists);
it('should commit mutation RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => {
const listPageInfo = {
[listId]: pageInfo,
};
it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction(
actions.fetchIssuesForList,
listId,
{ listId },
state,
[
{
type: types.REQUEST_ISSUES_FOR_LIST,
payload: { listId, fetchNext: false },
},
{
type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS,
payload: { listIssues: formattedIssues, listId },
payload: { listIssues: formattedIssues, listPageInfo, listId },
},
],
[],
......@@ -289,14 +305,20 @@ describe('fetchIssuesForList', () => {
);
});
it('should commit mutation RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => {
it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
testAction(
actions.fetchIssuesForList,
listId,
{ listId },
state,
[{ type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }],
[
{
type: types.REQUEST_ISSUES_FOR_LIST,
payload: { listId, fetchNext: false },
},
{ type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId },
],
[],
done,
);
......
......@@ -173,13 +173,23 @@ describe('Board Store Mutations', () => {
state = {
...state,
issuesByListId: {},
issuesByListId: {
'gid://gitlab/List/1': [],
},
issues: {},
boardLists: mockListsWithModel,
};
const listPageInfo = {
'gid://gitlab/List/1': {
endCursor: '',
hasNextPage: false,
},
};
mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, {
listIssues: { listData: listIssues, issues },
listPageInfo,
listId: 'gid://gitlab/List/1',
});
......
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