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