Commit 9c0b873f authored by Kushal Pandya's avatar Kushal Pandya

Use pagination to load epics in roadmap

Use pagination via IntersectionObserver to load
epics in roadmap on scroll instead of loading all
epics at once.
parent 100fa268
<script>
import { GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -13,6 +14,8 @@ export default {
EpicItem,
epicItemHeight: EPIC_ITEM_HEIGHT,
components: {
GlIntersectionObserver,
GlLoadingIcon,
EpicItem,
CurrentDayIndicator,
},
......@@ -49,7 +52,15 @@ export default {
};
},
computed: {
...mapState(['bufferSize', 'epicIid', 'childrenEpics', 'childrenFlags', 'epicIds']),
...mapState([
'bufferSize',
'epicIid',
'childrenEpics',
'childrenFlags',
'epicIds',
'pageInfo',
'epicsFetchForNextPageInProgress',
]),
emptyRowContainerVisible() {
return this.displayedEpics.length < this.bufferSize;
},
......@@ -90,7 +101,7 @@ export default {
window.removeEventListener('resize', this.syncClientWidth);
},
methods: {
...mapActions(['setBufferSize', 'toggleEpic']),
...mapActions(['setBufferSize', 'toggleEpic', 'fetchEpics']),
initMounted() {
this.roadmapShellEl = this.$root.$el && this.$root.$el.querySelector('.js-roadmap-shell');
this.setBufferSize(Math.ceil((window.innerHeight - this.$el.offsetTop) / EPIC_ITEM_HEIGHT));
......@@ -138,6 +149,12 @@ export default {
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
},
handleScrolledToEnd() {
const { hasNextPage, endCursor } = this.pageInfo;
if (!this.epicsFetchForNextPageInProgress && hasNextPage) {
this.fetchEpics({ endCursor });
}
},
toggleIsEpicExpanded(epic) {
this.toggleEpic({ parentItem: epic });
},
......@@ -172,6 +189,16 @@ export default {
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
</span>
</div>
<gl-intersection-observer v-if="glFeatures.performanceRoadmap" @appear="handleScrolledToEnd">
<div
v-if="epicsFetchForNextPageInProgress"
class="gl-text-center gl-py-3"
data-testid="next-page-loading"
>
<gl-loading-icon inline class="gl-mr-2" />
{{ s__('GroupRoadmap|Loading epics') }}
</div>
</gl-intersection-observer>
<div
v-show="showBottomShadow"
:style="shadowCellStyles"
......
......@@ -74,3 +74,5 @@ export const EPIC_LEVEL_MARGIN = {
export const EPICS_LIMIT_DISMISSED_COOKIE_NAME = 'epics_limit_warning_dismissed';
export const EPICS_LIMIT_DISMISSED_COOKIE_TIMEOUT = 365;
export const ROADMAP_PAGE_SIZE = gon.features?.performanceRoadmap ? 50 : gon.roadmap_epics_limit;
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./epic.fragment.graphql"
query groupEpics(
......@@ -12,8 +13,9 @@ query groupEpics(
$myReactionEmoji: String
$confidential: Boolean
$search: String = ""
$first: Int = 1001
$first: Int = 50
$not: NegatedEpicFilterInput
$endCursor: String = ""
) {
group(fullPath: $fullPath) {
id
......@@ -31,6 +33,7 @@ query groupEpics(
first: $first
timeframe: $timeframe
not: $not
after: $endCursor
) {
edges {
node {
......@@ -40,6 +43,9 @@ query groupEpics(
}
}
}
pageInfo {
...PageInfo
}
}
}
}
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { EXTEND_AS } from '../constants';
import { EXTEND_AS, ROADMAP_PAGE_SIZE } from '../constants';
import epicChildEpics from '../queries/epicChildEpics.query.graphql';
import groupEpics from '../queries/groupEpics.query.graphql';
import groupMilestones from '../queries/groupMilestones.query.graphql';
......@@ -19,13 +19,14 @@ export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DAT
const fetchGroupEpics = (
{ epicIid, fullPath, epicsState, sortedBy, presetType, filterParams, timeframe },
defaultTimeframe,
{ timeframe: defaultTimeframe, endCursor },
) => {
let query;
let variables = {
fullPath,
state: epicsState,
sort: sortedBy,
endCursor,
...getEpicsTimeframeRange({
presetType,
timeframe: defaultTimeframe || timeframe,
......@@ -45,7 +46,7 @@ const fetchGroupEpics = (
variables = {
...variables,
...transformedFilterParams,
first: gon.roadmap_epics_limit + 1,
first: ROADMAP_PAGE_SIZE,
};
if (transformedFilterParams?.epicIid) {
......@@ -63,7 +64,10 @@ const fetchGroupEpics = (
? data?.group?.epic?.children?.edges || []
: data?.group?.epics?.edges || [];
return edges.map((e) => e.node);
return {
rawEpics: edges.map((e) => e.node),
pageInfo: data?.group?.epics?.pageInfo,
};
});
};
......@@ -84,7 +88,7 @@ export const fetchChildrenEpics = (state, { parentItem }) => {
export const receiveEpicsSuccess = (
{ commit, dispatch, state },
{ rawEpics, newEpic, timeframeExtended },
{ rawEpics, pageInfo, newEpic, timeframeExtended, appendToList },
) => {
const epicIds = [];
const epics = rawEpics.reduce((filteredEpics, epic) => {
......@@ -119,8 +123,11 @@ export const receiveEpicsSuccess = (
const updatedEpics = state.epics.concat(epics);
sortEpics(updatedEpics, state.sortedBy);
commit(types.RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS, updatedEpics);
} else if (appendToList) {
const updatedEpics = state.epics.concat(epics);
commit(types.RECEIVE_EPICS_FOR_NEXT_PAGE_SUCCESS, { epics: updatedEpics, pageInfo });
} else {
commit(types.RECEIVE_EPICS_SUCCESS, epics);
commit(types.RECEIVE_EPICS_SUCCESS, { epics, pageInfo });
}
};
export const receiveEpicsFailure = ({ commit }) => {
......@@ -160,12 +167,20 @@ export const receiveChildrenSuccess = (
commit(types.RECEIVE_CHILDREN_SUCCESS, { parentItemId, children });
};
export const fetchEpics = ({ state, commit, dispatch }) => {
commit(types.REQUEST_EPICS);
export const fetchEpics = ({ state, commit, dispatch }, { endCursor } = {}) => {
if (endCursor) {
commit(types.REQUEST_EPICS_FOR_NEXT_PAGE);
} else {
commit(types.REQUEST_EPICS);
}
fetchGroupEpics(state)
.then((rawEpics) => {
dispatch('receiveEpicsSuccess', { rawEpics });
fetchGroupEpics(state, { endCursor })
.then(({ rawEpics, pageInfo }) => {
dispatch('receiveEpicsSuccess', {
rawEpics,
pageInfo,
appendToList: Boolean(endCursor),
});
})
.catch(() => dispatch('receiveEpicsFailure'));
};
......@@ -173,10 +188,11 @@ export const fetchEpics = ({ state, commit, dispatch }) => {
export const fetchEpicsForTimeframe = ({ state, commit, dispatch }, { timeframe }) => {
commit(types.REQUEST_EPICS_FOR_TIMEFRAME);
return fetchGroupEpics(state, timeframe)
.then((rawEpics) => {
return fetchGroupEpics(state, { timeframe })
.then(({ rawEpics, pageInfo }) => {
dispatch('receiveEpicsSuccess', {
rawEpics,
pageInfo,
newEpic: true,
timeframeExtended: true,
});
......
......@@ -6,8 +6,10 @@ export const UPDATE_EPIC_IDS = 'UPDATE_EPIC_IDS';
export const REQUEST_EPICS = 'REQUEST_EPICS';
export const REQUEST_EPICS_FOR_TIMEFRAME = 'REQUEST_EPICS_FOR_TIMEFRAME';
export const REQUEST_EPICS_FOR_NEXT_PAGE = 'REQUEST_EPICS_FOR_NEXT_PAGE';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS = 'RECEIVE_EPICS_FOR_TIMEFRAME_SUCCESS';
export const RECEIVE_EPICS_FOR_NEXT_PAGE_SUCCESS = 'RECEIVE_EPICS_FOR_NEXT_PAGE_SUCCESS';
export const RECEIVE_EPICS_FAILURE = 'RECEIVE_EPICS_FAILURE';
......
......@@ -27,11 +27,15 @@ export default {
[types.REQUEST_EPICS_FOR_TIMEFRAME](state) {
state.epicsFetchForTimeframeInProgress = true;
},
[types.RECEIVE_EPICS_SUCCESS](state, epics) {
[types.REQUEST_EPICS_FOR_NEXT_PAGE](state) {
state.epicsFetchForNextPageInProgress = true;
},
[types.RECEIVE_EPICS_SUCCESS](state, { epics, pageInfo }) {
state.epicsFetchResultEmpty = epics.length === 0;
if (!state.epicsFetchResultEmpty) {
state.epics = epics;
state.pageInfo = pageInfo;
}
state.epicsFetchInProgress = false;
......@@ -40,9 +44,15 @@ export default {
state.epics = epics;
state.epicsFetchForTimeframeInProgress = false;
},
[types.RECEIVE_EPICS_FOR_NEXT_PAGE_SUCCESS](state, { epics, pageInfo }) {
state.epics = epics;
state.pageInfo = pageInfo;
state.epicsFetchForNextPageInProgress = false;
},
[types.RECEIVE_EPICS_FAILURE](state) {
state.epicsFetchInProgress = false;
state.epicsFetchForTimeframeInProgress = false;
state.epicsFetchForNextPageInProgress = false;
state.epicsFetchFailure = true;
Object.keys(state.childrenEpics).forEach((id) => {
Vue.set(state.childrenFlags, id, {
......
......@@ -7,6 +7,7 @@ export default () => ({
// Data
epicIid: '',
epics: [],
pageInfo: null,
childrenEpics: {},
childrenFlags: {},
visibleEpics: [],
......@@ -27,6 +28,7 @@ export default () => ({
hasFiltersApplied: false,
epicsFetchInProgress: false,
epicsFetchForTimeframeInProgress: false,
epicsFetchForNextPageInProgress: false,
epicsFetchFailure: false,
epicsFetchResultEmpty: false,
milestonesFetchInProgress: false,
......
......@@ -9,6 +9,7 @@ module Groups
before_action :persist_roadmap_layout, only: [:show]
before_action do
push_frontend_feature_flag(:async_filtering, @group, default_enabled: true)
push_frontend_feature_flag(:performance_roadmap, @group, default_enabled: :yaml)
end
feature_category :roadmaps
......
---
name: performance_roadmap
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65652
rollout_issue_url:
milestone: '14.2'
type: development
group: group::product planning
default_enabled: false
......@@ -28,6 +28,7 @@ RSpec.describe 'group epic roadmap', :js do
stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false)
stub_feature_flags(async_filtering: false)
stub_feature_flags(performance_roadmap: false)
sign_in(user)
end
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import EpicItem from 'ee/roadmap/components/epic_item.vue';
import EpicsListSection from 'ee/roadmap/components/epics_list_section.vue';
import {
......@@ -7,6 +9,7 @@ import {
TIMELINE_CELL_MIN_WIDTH,
} from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { REQUEST_EPICS_FOR_NEXT_PAGE } from 'ee/roadmap/store/mutation_types';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import {
mockFormattedChildEpic1,
......@@ -16,8 +19,10 @@ import {
rawEpics,
mockEpicsWithParents,
mockSortedBy,
mockPageInfo,
basePath,
} from 'ee_jest/roadmap/mock_data';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const store = createStore();
......@@ -30,7 +35,11 @@ store.dispatch('setInitialData', {
basePath,
});
store.dispatch('receiveEpicsSuccess', { rawEpics });
store.dispatch('receiveEpicsSuccess', {
rawEpics,
pageInfo: mockPageInfo,
appendToList: true,
});
const mockEpics = store.state.epics;
......@@ -44,8 +53,9 @@ const createComponent = ({
currentGroupId = mockGroupId,
presetType = PRESET_TYPES.MONTHS,
hasFiltersApplied = false,
performanceRoadmap = false,
} = {}) => {
return shallowMount(EpicsListSection, {
return shallowMountExtended(EpicsListSection, {
localVue,
store,
stubs: {
......@@ -59,6 +69,11 @@ const createComponent = ({
currentGroupId,
hasFiltersApplied,
},
provide: {
glFeatures: {
performanceRoadmap,
},
},
});
};
......@@ -66,6 +81,7 @@ describe('EpicsListSectionComponent', () => {
let wrapper;
beforeEach(() => {
gon.features = { performanceRoadmap: false };
wrapper = createComponent();
});
......@@ -253,6 +269,38 @@ describe('EpicsListSectionComponent', () => {
expect(wrapper.find('.epic-scroll-bottom-shadow').exists()).toBe(true);
});
describe('when `performanceRoadmap` feature flag is enabled', () => {
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
beforeEach(() => {
gon.features = { performanceRoadmap: true };
wrapper = createComponent({ performanceRoadmap: true });
});
it('renders gl-intersection-observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
it('calls action `fetchEpics` when gl-intersection-observer appears in viewport', () => {
const fakeFetchEpics = jest.spyOn(wrapper.vm, 'fetchEpics').mockImplementation();
findIntersectionObserver().vm.$emit('appear');
expect(fakeFetchEpics).toHaveBeenCalledWith({
endCursor: mockPageInfo.endCursor,
});
});
it('renders gl-loading icon when epicsFetchForNextPageInProgress is true', async () => {
wrapper.vm.$store.commit(REQUEST_EPICS_FOR_NEXT_PAGE);
await wrapper.vm.$nextTick();
expect(wrapper.findByTestId('next-page-loading').text()).toContain('Loading epics');
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
});
});
it('expands to show child epics when epic is toggled', () => {
......
......@@ -82,7 +82,7 @@ describe('RoadmapApp', () => {
beforeEach(() => {
wrapper = createComponent();
if (epicList) {
store.commit(types.RECEIVE_EPICS_SUCCESS, epicList);
store.commit(types.RECEIVE_EPICS_SUCCESS, { epics: epicList });
}
});
......@@ -103,7 +103,7 @@ describe('RoadmapApp', () => {
describe('empty state view', () => {
beforeEach(() => {
wrapper = createComponent();
store.commit(types.RECEIVE_EPICS_SUCCESS, []);
store.commit(types.RECEIVE_EPICS_SUCCESS, { epics: [] });
});
it('contains path for the empty state illustration', () => {
......@@ -138,7 +138,7 @@ describe('RoadmapApp', () => {
describe('roadmap view', () => {
beforeEach(() => {
wrapper = createComponent();
store.commit(types.RECEIVE_EPICS_SUCCESS, epics);
store.commit(types.RECEIVE_EPICS_SUCCESS, { epics });
});
it('contains roadmap filters UI', () => {
......@@ -239,7 +239,9 @@ describe('RoadmapApp', () => {
describe('roadmap epics limit warning', () => {
beforeEach(() => {
wrapper = createComponent();
store.commit(types.RECEIVE_EPICS_SUCCESS, [mockFormattedEpic, mockFormattedChildEpic2]);
store.commit(types.RECEIVE_EPICS_SUCCESS, {
epics: [mockFormattedEpic, mockFormattedChildEpic2],
});
window.gon.roadmap_epics_limit = 1;
});
......
......@@ -548,6 +548,14 @@ export const mockEpicNode2 = {
export const mockGroupEpics = [mockEpicNode1, mockEpicNode2];
export const mockPageInfo = {
endCursor: 'eyJzdGFydF9kYXRlIjoiMjAyMC0wOS0wMSIsImlkIjoiMzExIn0',
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'eyJzdGFydF9kYXRlIjoiMjAyMC0wNC0xOCIsImlkIjoiMjQ1In0',
__typename: 'PageInfo',
};
export const mockGroupEpicsQueryResponse = {
data: {
group: {
......@@ -568,6 +576,9 @@ export const mockGroupEpicsQueryResponse = {
__typename: 'EpicEdge',
},
],
pageInfo: {
...mockPageInfo,
},
__typename: 'EpicConnection',
},
__typename: 'Group',
......
......@@ -30,6 +30,7 @@ import {
mockGroupMilestones,
mockMilestone,
mockFormattedMilestone,
mockPageInfo,
} from '../mock_data';
jest.mock('~/flash');
......@@ -78,13 +79,17 @@ describe('Roadmap Vuex Actions', () => {
actions.receiveEpicsSuccess,
{
rawEpics: [mockRawEpic2],
pageInfo: mockPageInfo,
},
state,
[
{ type: types.UPDATE_EPIC_IDS, payload: [mockRawEpic2.id] },
{
type: types.UPDATE_EPIC_IDS,
payload: [mockRawEpic2.id],
},
{
type: types.RECEIVE_EPICS_SUCCESS,
payload: [mockFormattedEpic2],
payload: { epics: [mockFormattedEpic2], pageInfo: mockPageInfo },
},
],
[
......@@ -174,7 +179,7 @@ describe('Roadmap Vuex Actions', () => {
return testAction(
actions.fetchEpics,
null,
{},
state,
[
{
......@@ -184,7 +189,7 @@ describe('Roadmap Vuex Actions', () => {
[
{
type: 'receiveEpicsSuccess',
payload: { rawEpics: mockGroupEpics },
payload: { rawEpics: mockGroupEpics, pageInfo: mockPageInfo, appendToList: false },
},
],
);
......@@ -197,7 +202,7 @@ describe('Roadmap Vuex Actions', () => {
return testAction(
actions.fetchEpics,
null,
{},
state,
[
{
......@@ -237,6 +242,7 @@ describe('Roadmap Vuex Actions', () => {
type: 'receiveEpicsSuccess',
payload: {
rawEpics: mockGroupEpics,
pageInfo: mockPageInfo,
newEpic: true,
timeframeExtended: true,
},
......
......@@ -3,7 +3,13 @@ import mutations from 'ee/roadmap/store/mutations';
import defaultState from 'ee/roadmap/store/state';
import { mockGroupId, basePath, mockSortedBy, mockEpic } from 'ee_jest/roadmap/mock_data';
import {
mockGroupId,
basePath,
mockSortedBy,
mockEpic,
mockPageInfo,
} from 'ee_jest/roadmap/mock_data';
const setEpicMockData = (state) => {
state.epics = [mockEpic];
......@@ -77,14 +83,23 @@ describe('Roadmap Store Mutations', () => {
});
});
describe('REQUEST_EPICS_FOR_NEXT_PAGE', () => {
it('Should set state.epicsFetchForNextPageInProgress to `true`', () => {
mutations[types.REQUEST_EPICS_FOR_NEXT_PAGE](state);
expect(state.epicsFetchForNextPageInProgress).toBe(true);
});
});
describe('RECEIVE_EPICS_SUCCESS', () => {
it('Should set epicsFetchResultEmpty, epics in state based on provided epics array and set epicsFetchInProgress to `false`', () => {
const epics = [{ id: 1 }, { id: 2 }];
mutations[types.RECEIVE_EPICS_SUCCESS](state, epics);
mutations[types.RECEIVE_EPICS_SUCCESS](state, { epics, pageInfo: mockPageInfo });
expect(state.epicsFetchResultEmpty).toBe(false);
expect(state.epics).toEqual(epics);
expect(state.pageInfo).toEqual(mockPageInfo);
expect(state.epicsFetchInProgress).toBe(false);
});
});
......@@ -100,12 +115,28 @@ describe('Roadmap Store Mutations', () => {
});
});
describe('RECEIVE_EPICS_FOR_NEXT_PAGE_SUCCESS', () => {
it('Should set epics in state based on provided epics array and set epicsFetchForNextPageInProgress to `false`', () => {
const epics = [{ id: 1 }, { id: 2 }];
mutations[types.RECEIVE_EPICS_FOR_NEXT_PAGE_SUCCESS](state, {
epics,
pageInfo: mockPageInfo,
});
expect(state.epics).toEqual(epics);
expect(state.pageInfo).toEqual(mockPageInfo);
expect(state.epicsFetchForNextPageInProgress).toBe(false);
});
});
describe('RECEIVE_EPICS_FAILURE', () => {
it('Should set epicsFetchInProgress & epicsFetchForTimeframeInProgress to false and epicsFetchFailure to true', () => {
mutations[types.RECEIVE_EPICS_FAILURE](state);
expect(state.epicsFetchInProgress).toBe(false);
expect(state.epicsFetchForTimeframeInProgress).toBe(false);
expect(state.epicsFetchForNextPageInProgress).toBe(false);
expect(state.epicsFetchFailure).toBe(true);
});
});
......
......@@ -15638,6 +15638,9 @@ msgstr ""
msgid "GroupRoadmap|%{startDateInWords} – %{endDateInWords}"
msgstr ""
msgid "GroupRoadmap|Loading epics"
msgstr ""
msgid "GroupRoadmap|No start and end date"
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