Commit 065153e6 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '12524-vsa-enable-pagination-frontend' into 'master'

[VSA] Enable pagination (frontend)

See merge request gitlab-org/gitlab!59650
parents 9028f616 41ee917d
......@@ -69,6 +69,7 @@ export default {
'isLoadingValueStreams',
'selectedStageError',
'selectedValueStream',
'pagination',
]),
// NOTE: formEvents are fetched in the same request as the list of stages (fetchGroupStagesAndEvents)
// so i think its ok to bind formEvents here even though its only used as a prop to the custom-stage-form
......@@ -156,6 +157,7 @@ export default {
'removeStage',
'updateStage',
'reorderStage',
'updateStageTablePagination',
]),
...mapActions('customStages', ['hideForm', 'showCreateForm', 'showEditForm', 'createStage']),
onProjectsSelect(projects) {
......@@ -190,9 +192,11 @@ export default {
onStageReorder(data) {
this.reorderStage(data);
},
onHandleSelectPage(data) {
this.updateStageTablePagination(data);
},
},
multiProjectSelect: true,
dateOptions: [7, 30, 90],
maxDateRange: DATE_RANGE_LIMIT,
};
</script>
......@@ -279,9 +283,11 @@ export default {
v-if="!isLoading && !isOverviewStageSelected"
:is-loading="isLoading || isLoadingStage"
:stage-events="currentStageEvents"
:current-stage="selectedStage"
:selected-stage="selectedStage"
:empty-state-message="selectedStageError"
:no-data-svg-path="noDataSvgPath"
:pagination="pagination"
@handleSelectPage="onHandleSelectPage"
/>
</template>
<stage-table
......
<script>
import { GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
import { NOT_ENOUGH_DATA_ERROR } from '../constants';
import TotalTime from './total_time_component.vue';
......@@ -19,11 +19,12 @@ export default {
GlIcon,
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
TotalTime,
},
props: {
currentStage: {
selectedStage: {
type: Object,
required: true,
},
......@@ -44,6 +45,10 @@ export default {
required: false,
default: '',
},
pagination: {
type: Object,
required: true,
},
},
computed: {
isEmptyStage() {
......@@ -54,12 +59,12 @@ export default {
return emptyStateMessage || NOT_ENOUGH_DATA_ERROR;
},
isDefaultTestStage() {
const { currentStage } = this;
return !currentStage.custom && currentStage.title?.toLowerCase().trim() === 'test';
const { selectedStage } = this;
return !selectedStage.custom && selectedStage.title?.toLowerCase().trim() === 'test';
},
isDefaultStagingStage() {
const { currentStage } = this;
return !currentStage.custom && currentStage.title?.toLowerCase().trim() === 'staging';
const { selectedStage } = this;
return !selectedStage.custom && selectedStage.title?.toLowerCase().trim() === 'staging';
},
isMergeRequestStage() {
const [firstEvent] = this.stageEvents;
......@@ -78,6 +83,12 @@ export default {
fields() {
return [this.workflowTitle, { key: 'time', label: __('Time'), thClass: 'gl-w-half' }];
},
prevPage() {
return Math.max(this.pagination.page - 1, 0);
},
nextPage() {
return this.pagination.hasNextPage ? this.pagination.page + 1 : null;
},
},
methods: {
isMrLink(url = '') {
......@@ -86,6 +97,9 @@ export default {
itemTitle(item) {
return item.title || item.name;
},
onSelectPage(page) {
this.$emit('handleSelectPage', { page });
},
},
};
</script>
......@@ -194,5 +208,15 @@ export default {
<total-time :time="item.totalTime" data-testid="vsa-stage-event-time" />
</template>
</gl-table>
<gl-pagination
v-if="!isLoading"
:value="pagination.page"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-mt-3"
data-testid="vsa-stage-pagination"
@input="onSelectPage"
/>
</div>
</template>
......@@ -73,3 +73,6 @@ export const OVERVIEW_STAGE_CONFIG = {
export const NOT_ENOUGH_DATA_ERROR = s__(
"ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
);
export const PAGINATION_TYPE = 'keyset';
export const PAGINATION_SORT_FIELD = 'created_at';
import Api from 'ee/api';
import createFlash from '~/flash';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import httpStatus from '~/lib/utils/http_status';
import { __, sprintf } from '~/locale';
import { FETCH_VALUE_STREAM_DATA, OVERVIEW_STAGE_CONFIG } from '../constants';
......@@ -30,7 +31,10 @@ export const setFeatureFlags = ({ commit }, featureFlags) =>
export const setSelectedProjects = ({ commit }, projects) =>
commit(types.SET_SELECTED_PROJECTS, projects);
export const setSelectedStage = ({ commit }, stage) => commit(types.SET_SELECTED_STAGE, stage);
export const setSelectedStage = ({ commit }, stage) => {
commit(types.SET_SELECTED_STAGE, stage);
commit(types.SET_PAGINATION, { page: 1, hasNextPage: null });
};
export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => {
commit(types.SET_DATE_RANGE, { startDate, endDate });
......@@ -41,8 +45,6 @@ export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDat
};
export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA);
export const receiveStageDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
export const receiveStageDataError = ({ commit }, error) => {
const { message = '' } = error;
......@@ -53,18 +55,30 @@ export const receiveStageDataError = ({ commit }, error) => {
commit(types.RECEIVE_STAGE_DATA_ERROR, message);
};
export const fetchStageData = ({ dispatch, getters }, stageId) => {
const { cycleAnalyticsRequestParams = {}, currentValueStreamId, currentGroupPath } = getters;
export const fetchStageData = ({ dispatch, getters, commit }, stageId) => {
const {
cycleAnalyticsRequestParams = {},
currentValueStreamId,
currentGroupPath,
paginationParams,
} = getters;
dispatch('requestStageData');
return Api.cycleAnalyticsStageEvents({
groupId: currentGroupPath,
valueStreamId: currentValueStreamId,
stageId,
params: cycleAnalyticsRequestParams,
params: {
...cycleAnalyticsRequestParams,
...paginationParams,
},
})
.then(checkForDataError)
.then(({ data }) => dispatch('receiveStageDataSuccess', data))
.then(({ data, headers }) => {
const { page = null, nextPage = null } = parseIntPagination(normalizeHeaders(headers));
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
commit(types.SET_PAGINATION, { page, hasNextPage: Boolean(nextPage) });
})
.catch((error) => dispatch('receiveStageDataError', error));
};
......@@ -466,3 +480,11 @@ export const fetchValueStreams = ({ commit, dispatch, getters }) => {
export const setFilters = ({ dispatch }) => {
return dispatch('fetchCycleAnalyticsData');
};
export const updateStageTablePagination = (
{ commit, dispatch, state: { selectedStage } },
{ page },
) => {
commit(types.SET_PAGINATION, { page });
return dispatch('fetchStageData', selectedStage.id);
};
......@@ -4,7 +4,12 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import httpStatus from '~/lib/utils/http_status';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from '../../shared/constants';
import { DEFAULT_VALUE_STREAM_ID, OVERVIEW_STAGE_CONFIG } from '../constants';
import {
DEFAULT_VALUE_STREAM_ID,
OVERVIEW_STAGE_CONFIG,
PAGINATION_TYPE,
PAGINATION_SORT_FIELD,
} from '../constants';
import { transformStagesForPathNavigation } from '../utils';
export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN;
......@@ -44,6 +49,12 @@ export const cycleAnalyticsRequestParams = (state, getters) => {
};
};
export const paginationParams = ({ pagination: { page } }) => ({
pagination: PAGINATION_TYPE,
sort: PAGINATION_SORT_FIELD,
page,
});
const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
......
......@@ -4,6 +4,7 @@ export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM';
export const SET_PAGINATION = 'SET_PAGINATION';
export const REQUEST_VALUE_STREAM_DATA = 'REQUEST_VALUE_STREAM_DATA';
export const RECEIVE_VALUE_STREAM_DATA_SUCCESS = 'RECEIVE_VALUE_STREAM_DATA_SUCCESS';
......
......@@ -183,4 +183,7 @@ export default {
return aName.toUpperCase() > bName.toUpperCase() ? 1 : -1;
});
},
[types.SET_PAGINATION](state, { page, hasNextPage }) {
state.pagination = { page, hasNextPage };
},
};
......@@ -34,4 +34,9 @@ export default () => ({
summary: [],
medians: {},
valueStreams: [],
pagination: {
page: null,
hasNextPage: false,
},
});
---
title: Add pagination to the VSA stage table
merge_request: 59650
author:
type: changed
......@@ -144,10 +144,10 @@ describe('Value Stream Analytics component', () => {
});
if (withStageSelected) {
await Promise.all([
store.dispatch('receiveGroupStagesSuccess', mockData.customizableStagesAndEvents.stages),
store.dispatch('receiveStageDataSuccess', mockData.issueEvents),
]);
await store.dispatch(
'receiveGroupStagesSuccess',
mockData.customizableStagesAndEvents.stages,
);
}
return comp;
}
......
......@@ -22,8 +22,10 @@ const [firstIssueEvent] = issueEvents;
const [firstStagingEvent] = stagingEvents;
const [firstTestEvent] = testEvents;
const [firstReviewEvent] = reviewEvents;
const pagination = { page: 1, hasNextPage: true };
const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event');
const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title');
function createComponent(props = {}, shallow = false) {
......@@ -34,7 +36,8 @@ function createComponent(props = {}, shallow = false) {
isLoading: false,
stageEvents: issueEvents,
noDataSvgPath,
currentStage: issueStage,
selectedStage: issueStage,
pagination,
...props,
},
stubs: {
......@@ -90,7 +93,7 @@ describe('StageTable', () => {
beforeEach(() => {
wrapper = createComponent({
stageEvents: [{ ...firstIssueEvent }],
currentStage: { ...issueStage, custom: false },
selectedStage: { ...issueStage, custom: false },
});
});
......@@ -131,7 +134,7 @@ describe('StageTable', () => {
beforeEach(() => {
wrapper = createComponent({
stageEvents: [{ ...firstReviewEvent }],
currentStage: { ...reviewStage, custom: false },
selectedStage: { ...reviewStage, custom: false },
});
});
......@@ -145,7 +148,7 @@ describe('StageTable', () => {
beforeEach(() => {
wrapper = createComponent({
stageEvents: [{ ...firstStagingEvent }],
currentStage: { ...stagingStage, custom: false },
selectedStage: { ...stagingStage, custom: false },
});
});
......@@ -187,7 +190,7 @@ describe('StageTable', () => {
beforeEach(() => {
wrapper = createComponent({
stageEvents: [{ ...firstTestEvent }],
currentStage: { ...testStage, custom: false },
selectedStage: { ...testStage, custom: false },
});
});
......@@ -234,9 +237,18 @@ describe('StageTable', () => {
});
});
it('isLoading = true', () => {
wrapper = createComponent({ isLoading: true }, true);
expect(wrapper.find(GlLoadingIcon).exists()).toEqual(true);
describe('isLoading = true', () => {
beforeEach(() => {
wrapper = createComponent({ isLoading: true }, true);
});
it('will display the loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('will not display pagination', () => {
expect(findPagination().exists()).toBe(false);
});
});
describe('with no stageEvents', () => {
......@@ -263,4 +275,31 @@ describe('StageTable', () => {
expect(wrapper.html()).toContain(emptyStateMessage);
});
});
describe('Pagination', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('will display the pagination component', () => {
expect(findPagination().exists()).toBe(true);
});
it('clicking prev or next will emit an event', async () => {
findPagination().vm.$emit('input', 2);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('handleSelectPage')[0]).toEqual([{ page: 2 }]);
});
describe('with `hasNextPage=false', () => {
beforeEach(() => {
wrapper = createComponent({ pagination: { page: 1, hasNextPage: false } });
});
it('will not display the pagination component', () => {
expect(findPagination().exists()).toBe(false);
});
});
});
});
......@@ -3,6 +3,8 @@ import {
DEFAULT_DAYS_IN_PAST,
TASKS_BY_TYPE_SUBJECT_ISSUE,
OVERVIEW_STAGE_CONFIG,
PAGINATION_TYPE,
PAGINATION_SORT_FIELD,
} from 'ee/analytics/cycle_analytics/constants';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
......@@ -302,3 +304,10 @@ export const selectedProjects = [
];
export const pathNavIssueMetric = 172800;
export const initialPaginationState = { page: null, hasNextPage: false };
export const basePaginationResult = {
pagination: PAGINATION_TYPE,
sort: PAGINATION_SORT_FIELD,
page: null,
};
......@@ -78,7 +78,6 @@ describe('Value Stream Analytics actions', () => {
action | type | stateKey | payload
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${'selectedStage'} | ${{ id: 'someStageId' }}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
return testAction(
actions[action],
......@@ -94,6 +93,18 @@ describe('Value Stream Analytics actions', () => {
);
});
describe('setSelectedStage', () => {
const data = { id: 'someStageId' };
const payload = { hasNextPage: null, page: 1 };
it(`dispatches the ${types.SET_SELECTED_STAGE} and ${types.SET_PAGINATION} actions`, () => {
return testAction(actions.setSelectedStage, data, { ...state, selectedValueStream: {} }, [
{ type: types.SET_SELECTED_STAGE, payload: data },
{ type: types.SET_PAGINATION, payload },
]);
});
});
describe('setSelectedValueStream', () => {
const vs = { id: 'vs-1', name: 'Value stream 1' };
......@@ -144,28 +155,64 @@ describe('Value Stream Analytics actions', () => {
});
describe('fetchStageData', () => {
const headers = {
'X-Next-Page': 2,
'X-Page': 1,
};
beforeEach(() => {
state = { ...state, currentGroup };
mock = new MockAdapter(axios);
mock.onGet(endpoints.stageData).reply(httpStatusCodes.OK, { events: [] });
mock.onGet(endpoints.stageData).reply(httpStatusCodes.OK, stageData, headers);
});
it('dispatches receiveStageDataSuccess with received data on success', () => {
it(`commits ${types.RECEIVE_STAGE_DATA_SUCCESS} with received data and headers on success`, () => {
return testAction(
actions.fetchStageData,
selectedStageSlug,
state,
[],
[
{ type: 'requestStageData' },
{
type: 'receiveStageDataSuccess',
payload: { events: [] },
type: types.RECEIVE_STAGE_DATA_SUCCESS,
payload: stageData,
},
{
type: types.SET_PAGINATION,
payload: { page: headers['X-Page'], hasNextPage: true },
},
],
[{ type: 'requestStageData' }],
);
});
describe('without a next page', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock
.onGet(endpoints.stageData)
.reply(httpStatusCodes.OK, { events: [] }, { ...headers, 'X-Next-Page': null });
});
it('sets hasNextPage to false', () => {
return testAction(
actions.fetchStageData,
selectedStageSlug,
state,
[
{
type: types.RECEIVE_STAGE_DATA_SUCCESS,
payload: { events: [] },
},
{
type: types.SET_PAGINATION,
payload: { page: headers['X-Page'], hasNextPage: false },
},
],
[{ type: 'requestStageData' }],
);
});
});
describe('with a failing request', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
......@@ -190,18 +237,6 @@ describe('Value Stream Analytics actions', () => {
);
});
});
describe('receiveStageDataSuccess', () => {
it(`commits the ${types.RECEIVE_STAGE_DATA_SUCCESS} mutation`, () => {
return testAction(
actions.receiveStageDataSuccess,
{ ...stageData },
state,
[{ type: types.RECEIVE_STAGE_DATA_SUCCESS, payload: { events: [] } }],
[],
);
});
});
});
describe('receiveStageDataError', () => {
......
......@@ -16,6 +16,8 @@ import {
transformedStagePathData,
issueStage,
stageMedians,
basePaginationResult,
initialPaginationState,
} from '../mock_data';
let state = null;
......@@ -218,4 +220,24 @@ describe('Value Stream Analytics getters', () => {
expect(getters.pathNavigationData(state)).toEqual(transformedStagePathData);
});
});
describe('paginationParams', () => {
beforeEach(() => {
state = { pagination: initialPaginationState };
});
it('returns the `pagination` type', () => {
expect(getters.paginationParams(state)).toEqual(basePaginationResult);
});
it('returns the `sort` type', () => {
expect(getters.paginationParams(state)).toEqual(basePaginationResult);
});
it('with page=10, sets the `page` property', () => {
const page = 10;
state = { pagination: { ...initialPaginationState, page } };
expect(getters.paginationParams(state)).toEqual({ ...basePaginationResult, page });
});
});
});
......@@ -78,6 +78,8 @@ describe('Value Stream Analytics mutations', () => {
stages: [{}, { name: "Can't be blank" }, {}, {}, {}, {}, {}, {}],
};
const pagination = { page: 10, hasNextPage: true };
it.each`
mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
......@@ -91,6 +93,7 @@ describe('Value Stream Analytics mutations', () => {
${types.SET_SELECTED_VALUE_STREAM} | ${valueStreams[1].id} | ${{ selectedValueStream: {} }}
${types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS} | ${valueStreams[1]} | ${{ selectedValueStream: valueStreams[1] }}
${types.RECEIVE_UPDATE_VALUE_STREAM_SUCCESS} | ${valueStreams[1]} | ${{ selectedValueStream: valueStreams[1] }}
${types.SET_PAGINATION} | ${pagination} | ${{ pagination }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......
......@@ -225,7 +225,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RequestParams do
expect(data_collector_params[:direction]).to eq(:asc)
end
it 'adds corting params to data attributes' do
it 'adds sorting params to data attributes' do
data_attributes = subject.to_data_attributes
expect(data_attributes[:sort]).to eq('duration')
......
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