Commit de56725c authored by Kushal Pandya's avatar Kushal Pandya

Add pagination support in Epic tree

Adds support for lazy-loading items in Epic tree
on page-by-page basis with 100 as size per page.
parent cda4cb83
<script>
import { mapState, mapActions } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlLoadingIcon,
},
props: {
parentItem: {
type: Object,
......@@ -10,16 +17,49 @@ export default {
required: true,
},
},
data() {
return {
fetchInProgress: false,
};
},
computed: {
...mapState(['childrenFlags']),
hasMoreChildren() {
const flags = this.childrenFlags[this.parentItem.reference];
return flags.hasMoreEpics || flags.hasMoreIssues;
},
},
methods: {
...mapActions(['fetchNextPageItems']),
handleShowMoreClick() {
this.fetchInProgress = true;
this.fetchNextPageItems({
parentItem: this.parentItem,
})
.then(() => {
this.fetchInProgress = false;
})
.catch(() => {
this.fetchInProgress = false;
});
},
},
};
</script>
<template>
<ul class="list-unstyled related-items-list tree-root">
<tree-item
v-for="(item, index) in children"
:key="index"
:parent-item="parentItem"
:item="item"
/>
<tree-item v-for="item in children" :key="item.id" :parent-item="parentItem" :item="item" />
<li v-if="hasMoreChildren" class="tree-item list-item pt-0 pb-0 d-flex justify-content-center">
<gl-button
v-if="!fetchInProgress"
class="d-inline-block mb-2"
variant="link"
@click="handleShowMoreClick($event)"
>{{ s__('Epics|Show more') }}</gl-button
>
<gl-loading-icon v-else size="sm" class="mt-1 mb-1" />
</li>
</ul>
</template>
#import "./epic.fragment.graphql"
#import "./issue.fragment.graphql"
query childItems($fullPath: ID!, $iid: ID) {
fragment PageInfo on PageInfo {
endCursor
hasNextPage
}
query childItems(
$fullPath: ID!
$iid: ID
$pageSize: Int = 100
$epicEndCursor: String = ""
$issueEndCursor: String = ""
) {
group(fullPath: $fullPath) {
id
path
fullPath
epic(iid: $iid) {
...BaseEpic
children {
children(first: $pageSize, after: $epicEndCursor) {
edges {
node {
...EpicNode
}
}
pageInfo {
...PageInfo
}
}
issues {
issues(first: $pageSize, after: $issueEndCursor) {
edges {
node {
...IssueNode
}
}
pageInfo {
...PageInfo
}
}
}
}
......
......@@ -42,11 +42,15 @@ export const setChildrenCount = ({ commit, state }, { children, isRemoved = fals
export const expandItem = ({ commit }, data) => commit(types.EXPAND_ITEM, data);
export const collapseItem = ({ commit }, data) => commit(types.COLLAPSE_ITEM, data);
export const setItemChildren = ({ commit, dispatch }, { parentItem, children, isSubItem }) => {
export const setItemChildren = (
{ commit, dispatch },
{ parentItem, children, isSubItem, append = false },
) => {
commit(types.SET_ITEM_CHILDREN, {
parentItem,
children,
isSubItem,
append,
});
dispatch('setChildrenCount', { children });
......@@ -60,6 +64,9 @@ export const setItemChildren = ({ commit, dispatch }, { parentItem, children, is
export const setItemChildrenFlags = ({ commit }, data) =>
commit(types.SET_ITEM_CHILDREN_FLAGS, data);
export const setEpicPageInfo = ({ commit }, data) => commit(types.SET_EPIC_PAGE_INFO, data);
export const setIssuePageInfo = ({ commit }, data) => commit(types.SET_ISSUE_PAGE_INFO, data);
export const requestItems = ({ commit }, data) => commit(types.REQUEST_ITEMS, data);
export const receiveItemsSuccess = ({ commit }, data) => commit(types.RECEIVE_ITEMS_SUCCESS, data);
export const receiveItemsFailure = ({ commit }, data) => {
......@@ -67,6 +74,8 @@ export const receiveItemsFailure = ({ commit }, data) => {
commit(types.RECEIVE_ITEMS_FAILURE, data);
};
export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
const { iid, fullPath } = parentItem;
dispatch('requestItems', {
parentItem,
isSubItem,
......@@ -75,7 +84,7 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
gqClient
.query({
query: epicChildren,
variables: { iid: parentItem.iid, fullPath: parentItem.fullPath },
variables: { iid, fullPath },
})
.then(({ data }) => {
const children = processQueryResponse(data.group);
......@@ -96,6 +105,16 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
children,
isSubItem,
});
dispatch('setEpicPageInfo', {
parentItem,
pageInfo: data.group.epic.children.pageInfo,
});
dispatch('setIssuePageInfo', {
parentItem,
pageInfo: data.group.epic.issues.pageInfo,
});
})
.catch(() => {
dispatch('receiveItemsFailure', {
......@@ -105,6 +124,69 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
});
};
export const receiveNextPageItemsFailure = () => {
flash(s__('Epics|Something went wrong while fetching child epics.'));
};
export const fetchNextPageItems = ({ dispatch, state }, { parentItem, isSubItem = false }) => {
const { iid, fullPath } = parentItem;
const parentItemFlags = state.childrenFlags[parentItem.reference];
const variables = { iid, fullPath };
if (parentItemFlags.hasMoreEpics) {
variables.epicEndCursor = parentItemFlags.epicEndCursor;
}
if (parentItemFlags.hasMoreIssues) {
variables.issueEndCursor = parentItemFlags.issueEndCursor;
}
return gqClient
.query({
query: epicChildren,
variables,
})
.then(({ data }) => {
const { epic } = data.group;
const emptyChildren = { edges: [], pageInfo: epic.children.pageInfo };
const emptyIssues = { edges: [], pageInfo: epic.issues.pageInfo };
// Ensure we don't re-render already existing items
const children = processQueryResponse({
epic: {
children: parentItemFlags.hasMoreEpics ? epic.children : emptyChildren,
issues: parentItemFlags.hasMoreIssues ? epic.issues : emptyIssues,
},
});
dispatch('setItemChildren', {
parentItem,
children,
isSubItem,
append: true,
});
dispatch('setItemChildrenFlags', {
children,
isSubItem: false,
});
dispatch('setEpicPageInfo', {
parentItem,
pageInfo: data.group.epic.children.pageInfo,
});
dispatch('setIssuePageInfo', {
parentItem,
pageInfo: data.group.epic.issues.pageInfo,
});
})
.catch(() => {
dispatch('receiveNextPageItemsFailure', {
parentItem,
});
});
};
export const toggleItem = ({ state, dispatch }, { parentItem }) => {
if (!state.childrenFlags[parentItem.reference].itemExpanded) {
if (!state.children[parentItem.reference]) {
......
......@@ -5,6 +5,8 @@ export const SET_INITIAL_PARENT_ITEM = 'SET_INITIAL_PARENT_ITEM';
export const SET_CHILDREN_COUNT = 'SET_CHILDREN_COUNT';
export const SET_ITEM_CHILDREN = 'SET_ITEM_CHILDREN';
export const SET_ITEM_CHILDREN_FLAGS = 'SET_ITEM_CHILDREN_FLAGS';
export const SET_EPIC_PAGE_INFO = 'SET_EPIC_PAGE_INFO';
export const SET_ISSUE_PAGE_INFO = 'SET_ISSUE_PAGE_INFO';
export const REQUEST_ITEMS = 'REQUEST_ITEMS';
export const RECEIVE_ITEMS_SUCCESS = 'RECEIVE_ITEMS_SUCCESS';
......
......@@ -23,8 +23,12 @@ export default {
state.issuesCount = issuesCount;
},
[types.SET_ITEM_CHILDREN](state, { parentItem, children }) {
Vue.set(state.children, parentItem.reference, children);
[types.SET_ITEM_CHILDREN](state, { parentItem, children, append }) {
if (append) {
state.children[parentItem.reference].push(...children);
} else {
Vue.set(state.children, parentItem.reference, children);
}
},
[types.SET_ITEM_CHILDREN_FLAGS](state, { children }) {
......@@ -38,6 +42,20 @@ export default {
});
},
[types.SET_EPIC_PAGE_INFO](state, { parentItem, pageInfo }) {
const parentFlags = state.childrenFlags[parentItem.reference];
parentFlags.epicEndCursor = pageInfo.endCursor;
parentFlags.hasMoreEpics = pageInfo.hasNextPage;
},
[types.SET_ISSUE_PAGE_INFO](state, { parentItem, pageInfo }) {
const parentFlags = state.childrenFlags[parentItem.reference];
parentFlags.issueEndCursor = pageInfo.endCursor;
parentFlags.hasMoreIssues = pageInfo.hasNextPage;
},
[types.REQUEST_ITEMS](state, { parentItem, isSubItem }) {
if (isSubItem) {
state.childrenFlags[parentItem.reference].itemChildrenFetchInProgress = true;
......
......@@ -61,4 +61,4 @@ export const extractChildIssues = issues =>
* @param {Object} responseRoot
*/
export const processQueryResponse = ({ epic }) =>
[].concat(extractChildIssues(epic.issues), extractChildEpics(epic.children));
[].concat(extractChildEpics(epic.children), extractChildIssues(epic.issues));
......@@ -61,25 +61,33 @@ describe('RelatedItemsTree', () => {
});
describe(types.SET_ITEM_CHILDREN, () => {
it('should set provided `data.children` to `state.children` with reference key as present in `data.parentItem`', () => {
const data = {
parentItem: { reference: '&1' },
children: [
{
reference: 'frontend&1',
},
{
reference: 'frontend&2',
},
],
};
const data = {
parentItem: { reference: '&1' },
children: [
{
reference: 'frontend&1',
},
{
reference: 'frontend&2',
},
],
};
it('should set provided children to parent in the state', () => {
mutations[types.SET_ITEM_CHILDREN](state, data);
expect(state.children[data.parentItem.reference]).toEqual(
expect.arrayContaining(data.children),
);
});
it('should append provided children to parent in the state when `append` param is `true`', () => {
mutations[types.SET_ITEM_CHILDREN](state, data);
data.append = true;
mutations[types.SET_ITEM_CHILDREN](state, data);
expect(state.children[data.parentItem.reference].length).toEqual(4);
});
});
describe(types.SET_ITEM_CHILDREN_FLAGS, () => {
......@@ -114,6 +122,50 @@ describe('RelatedItemsTree', () => {
});
});
describe(types.SET_EPIC_PAGE_INFO, () => {
it('should set `epicEndCursor` and `hasMoreEpics` to `state.childrenFlags`', () => {
const data = {
parentItem: { reference: '&1' },
pageInfo: {
endCursor: 'abc',
hasNextPage: true,
},
};
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.SET_EPIC_PAGE_INFO](state, data);
expect(state.childrenFlags[data.parentItem.reference].epicEndCursor).toEqual(
data.pageInfo.endCursor,
);
expect(state.childrenFlags[data.parentItem.reference].hasMoreEpics).toEqual(
data.pageInfo.hasNextPage,
);
});
});
describe(types.SET_ISSUE_PAGE_INFO, () => {
it('should set `issueEndCursor` and `hasMoreIssues` to `state.childrenFlags`', () => {
const data = {
parentItem: { reference: '&1' },
pageInfo: {
endCursor: 'abc',
hasNextPage: true,
},
};
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.SET_ISSUE_PAGE_INFO](state, data);
expect(state.childrenFlags[data.parentItem.reference].issueEndCursor).toEqual(
data.pageInfo.endCursor,
);
expect(state.childrenFlags[data.parentItem.reference].hasMoreIssues).toEqual(
data.pageInfo.hasNextPage,
);
});
});
describe(types.REQUEST_ITEMS, () => {
const data = {
parentItem: {
......
......@@ -71,11 +71,11 @@ describe('RelatedItemsTree', () => {
it('returns array of issues and epics from query response with issues being on top of the list', () => {
const formattedChildren = epicUtils.processQueryResponse(mockQueryResponse.data.group);
expect(formattedChildren.length).toBe(4); // 2 Issues and 2 Epics
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[1]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[2]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[3]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren.length).toBe(4); // 2 Epics and 2 Issues
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[1]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[2]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[3]).toHaveProperty('type', ChildType.Issue);
});
});
});
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import RelatedItemsBody from 'ee/related_items_tree/components/related_items_tree_body.vue';
import TreeRoot from 'ee/related_items_tree/components/tree_root.vue';
import { mockParentItem } from '../mock_data';
......@@ -11,7 +10,7 @@ const createComponent = (parentItem = mockParentItem, children = []) => {
return shallowMount(RelatedItemsBody, {
localVue,
stubs: {
'tree-root': TreeRoot,
'tree-root': true,
},
propsData: {
parentItem,
......@@ -38,7 +37,7 @@ describe('RelatedItemsTree', () => {
});
it('renders tree-root component', () => {
expect(wrapper.find('.related-items-list.tree-root').isVisible()).toBe(true);
expect(wrapper.find('tree-root-stub').isVisible()).toBe(true);
});
});
});
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import TreeRoot from 'ee/related_items_tree/components/tree_root.vue';
import { ChildType } from 'ee/related_items_tree/constants';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { mockParentItem, mockEpic1 } from '../mock_data';
import { mockQueryResponse, mockParentItem } from '../mock_data';
const mockItem = Object.assign({}, mockEpic1, {
type: ChildType.Epic,
pathIdSeparator: '&',
});
const { epic } = mockQueryResponse.data.group;
const createComponent = (parentItem = mockParentItem, children = [mockItem]) => {
const createComponent = ({
parentItem = mockParentItem,
epicPageInfo = epic.children.pageInfo,
issuesPageInfo = epic.issues.pageInfo,
} = {}) => {
const store = createDefaultStore();
const localVue = createLocalVue();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildrenFlags', {
isSubItem: false,
children,
});
store.dispatch('setEpicPageInfo', {
parentItem,
pageInfo: epicPageInfo,
});
store.dispatch('setIssuePageInfo', {
parentItem,
pageInfo: issuesPageInfo,
});
return shallowMount(TreeRoot, {
localVue,
store,
stubs: {
'tree-item': true,
'gl-button': GlButton,
},
propsData: {
parentItem,
......@@ -38,10 +61,64 @@ describe('RelatedItemsTree', () => {
wrapper.destroy();
});
describe('computed', () => {
describe('hasMoreChildren', () => {
it('returns `true` when either `hasMoreEpics` or `hasMoreIssues` is true', () => {
expect(wrapper.vm.hasMoreChildren).toBe(true);
});
it('returns `false` when both `hasMoreEpics` and `hasMoreIssues` is false', () => {
const wrapperNoMoreChild = createComponent({
epicPageInfo: {
hasNextPage: false,
endCursor: 'abc',
},
issuesPageInfo: {
hasNextPage: false,
endCursor: 'def',
},
});
expect(wrapperNoMoreChild.vm.hasMoreChildren).toBe(false);
wrapperNoMoreChild.destroy();
});
});
});
describe('methods', () => {
describe('handleShowMoreClick', () => {
it('sets `fetchInProgress` to true and calls `fetchNextPageItems` action with parentItem as param', () => {
spyOn(wrapper.vm, 'fetchNextPageItems').and.callFake(() => new Promise(() => {}));
wrapper.vm.handleShowMoreClick();
expect(wrapper.vm.fetchInProgress).toBe(true);
expect(wrapper.vm.fetchNextPageItems).toHaveBeenCalledWith(
jasmine.objectContaining({
parentItem: mockParentItem,
}),
);
});
});
});
describe('template', () => {
it('renders tree item component', () => {
expect(wrapper.html()).toContain('tree-item-stub');
});
it('renders `Show more` link', () => {
expect(wrapper.find('button').text()).toBe('Show more');
});
it('calls `handleShowMoreClick` when `Show more` link is clicked', () => {
spyOn(wrapper.vm, 'handleShowMoreClick');
wrapper.find('button').trigger('click');
expect(wrapper.vm.handleShowMoreClick).toHaveBeenCalled();
});
});
});
});
......@@ -136,6 +136,10 @@ export const mockQueryResponse = {
node: mockEpic2,
},
],
pageInfo: {
endCursor: 'abc',
hasNextPage: true,
},
},
issues: {
edges: [
......@@ -146,6 +150,10 @@ export const mockQueryResponse = {
node: mockIssue2,
},
],
pageInfo: {
endCursor: 'def',
hasNextPage: true,
},
},
},
},
......
......@@ -140,7 +140,12 @@ describe('RelatedItemTree', () => {
});
describe('setItemChildren', () => {
const mockPayload = { children: ['foo'], parentItem: mockParentItem, isSubItem: false };
const mockPayload = {
children: ['foo'],
parentItem: mockParentItem,
isSubItem: false,
append: false,
};
it('should set provided `children` values on state.children with provided parentItem.reference key', done => {
testAction(
......@@ -204,6 +209,46 @@ describe('RelatedItemTree', () => {
});
});
describe('setEpicPageInfo', () => {
it('should set `epicEndCursor` and `hasMoreEpics` to `state.childrenFlags`', done => {
const { pageInfo } = mockQueryResponse.data.group.epic.children;
testAction(
actions.setEpicPageInfo,
{ parentItem: mockParentItem, pageInfo },
{},
[
{
type: types.SET_EPIC_PAGE_INFO,
payload: { parentItem: mockParentItem, pageInfo },
},
],
[],
done,
);
});
});
describe('setIssuePageInfo', () => {
it('should set `issueEndCursor` and `hasMoreIssues` to `state.childrenFlags`', done => {
const { pageInfo } = mockQueryResponse.data.group.epic.issues;
testAction(
actions.setIssuePageInfo,
{ parentItem: mockParentItem, pageInfo },
{},
[
{
type: types.SET_ISSUE_PAGE_INFO,
payload: { parentItem: mockParentItem, pageInfo },
},
],
[],
done,
);
});
});
describe('requestItems', () => {
it('should set `state.itemsFetchInProgress` to true', done => {
testAction(
......@@ -278,7 +323,7 @@ describe('RelatedItemTree', () => {
);
});
it('should dispatch `receiveItemsSuccess`, `setItemChildren` and `setItemChildrenFlags` on request success', done => {
it('should dispatch `receiveItemsSuccess`, `setItemChildren`, `setItemChildrenFlags`, `setEpicPageInfo` and `setIssuePageInfo` on request success', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(
Promise.resolve({
data: mockQueryResponse.data,
......@@ -286,6 +331,8 @@ describe('RelatedItemTree', () => {
);
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
const epicPageInfo = mockQueryResponse.data.group.epic.children.pageInfo;
const issuesPageInfo = mockQueryResponse.data.group.epic.issues.pageInfo;
testAction(
actions.fetchItems,
......@@ -320,6 +367,20 @@ describe('RelatedItemTree', () => {
children,
},
},
{
type: 'setEpicPageInfo',
payload: {
parentItem: mockParentItem,
pageInfo: epicPageInfo,
},
},
{
type: 'setIssuePageInfo',
payload: {
parentItem: mockParentItem,
pageInfo: issuesPageInfo,
},
},
],
done,
);
......@@ -351,6 +412,99 @@ describe('RelatedItemTree', () => {
});
});
describe('receiveNextPageItemsFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should show flash error with message "Something went wrong while fetching child epics."', () => {
const message = 'Something went wrong while fetching child epics.';
actions.receiveNextPageItemsFailure(
{
commit: () => {},
},
{},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
message,
);
});
});
describe('fetchNextPageItems', () => {
it('should dispatch `setItemChildren`, `setItemChildrenFlags`, `setEpicPageInfo` and `setIssuePageInfo` on request success', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(
Promise.resolve({
data: mockQueryResponse.data,
}),
);
const epicPageInfo = mockQueryResponse.data.group.epic.children.pageInfo;
const issuesPageInfo = mockQueryResponse.data.group.epic.issues.pageInfo;
testAction(
actions.fetchNextPageItems,
{ parentItem: mockParentItem, isSubItem: false },
{ childrenFlags: { 'gitlab-org&1': {} } },
[],
[
{
type: 'setItemChildren',
payload: {
parentItem: mockParentItem,
isSubItem: false,
append: true,
children: [],
},
},
{
type: 'setItemChildrenFlags',
payload: {
isSubItem: false,
children: [],
},
},
{
type: 'setEpicPageInfo',
payload: {
parentItem: mockParentItem,
pageInfo: epicPageInfo,
},
},
{
type: 'setIssuePageInfo',
payload: {
parentItem: mockParentItem,
pageInfo: issuesPageInfo,
},
},
],
done,
);
});
it('should dispatch `receiveNextPageItemsFailure` on request failure', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(Promise.reject());
testAction(
actions.fetchNextPageItems,
{ parentItem: mockParentItem, isSubItem: false },
{ childrenFlags: { 'gitlab-org&1': {} } },
[],
[
{
type: 'receiveNextPageItemsFailure',
payload: {
parentItem: mockParentItem,
},
},
],
done,
);
});
});
describe('toggleItem', () => {
const data = {
parentItem: {
......
......@@ -5158,6 +5158,9 @@ msgstr ""
msgid "Epics|Remove issue"
msgstr ""
msgid "Epics|Show more"
msgstr ""
msgid "Epics|Something went wrong while creating child epics."
msgstr ""
......
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