Commit b9051ffe authored by Phil Hughes's avatar Phil Hughes

Merge branch '299814-add-roadmap-pagination' into 'master'

Use pagination to load epics in roadmap

See merge request gitlab-org/gitlab!65652
parents 5912f3de 9c0b873f
<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 }) => {
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