Commit 234ce02d authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '301082-fe-vsa-add-sorting-to-stage-tables' into 'master'

[FE] VSA - Add sorting to stage tables

See merge request gitlab-org/gitlab!60793
parents 619ac0da b895d8f1
......@@ -124,15 +124,20 @@ export default {
},
query() {
const selectedProjectIds = this.selectedProjectIds?.length ? this.selectedProjectIds : null;
const stageParams = this.featureFlags.hasPathNavigation
? {
sort: (!this.isOverviewStageSelected && this.pagination?.sort) || null,
direction: (!this.isOverviewStageSelected && this.pagination?.direction) || null,
}
: {};
return {
value_stream_id: this.selectedValueStream?.id || null,
project_ids: selectedProjectIds,
created_after: toYmd(this.startDate),
created_before: toYmd(this.endDate),
// the `overview` stage is always the default, so dont persist the id if its selected
stage_id:
this.selectedStage?.id && !this.isOverviewStageSelected ? this.selectedStage.id : null,
stage_id: (!this.isOverviewStageSelected && this.selectedStage?.id) || null, // the `overview` stage is always the default, so dont persist the id if its selected
...stageParams,
};
},
stageCount() {
......@@ -192,7 +197,7 @@ export default {
onStageReorder(data) {
this.reorderStage(data);
},
onHandleSelectPage(data) {
onHandleUpdatePagination(data) {
this.updateStageTablePagination(data);
},
},
......@@ -287,7 +292,7 @@ export default {
:empty-state-message="selectedStageError"
:no-data-svg-path="noDataSvgPath"
:pagination="pagination"
@handleSelectPage="onHandleSelectPage"
@handleUpdatePagination="onHandleUpdatePagination"
/>
</template>
<stage-table
......
<script>
import { GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
import { NOT_ENOUGH_DATA_ERROR } from '../constants';
import {
NOT_ENOUGH_DATA_ERROR,
PAGINATION_SORT_FIELD_END_EVENT,
PAGINATION_SORT_FIELD_DURATION,
PAGINATION_SORT_DIRECTION_ASC,
PAGINATION_SORT_DIRECTION_DESC,
} from '../constants';
import TotalTime from './total_time_component.vue';
const DEFAULT_WORKFLOW_TITLE_PROPERTIES = { thClass: 'gl-w-half', key: 'workflowTitleKey' };
const DEFAULT_WORKFLOW_TITLE_PROPERTIES = {
thClass: 'gl-w-half',
key: PAGINATION_SORT_FIELD_END_EVENT,
sortable: true,
};
const WORKFLOW_COLUMN_TITLES = {
issues: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Issues') },
jobs: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Jobs') },
......@@ -50,6 +60,15 @@ export default {
required: true,
},
},
data() {
const {
pagination: { sort, direction },
} = this;
return {
sort,
sortDesc: direction === PAGINATION_SORT_DIRECTION_DESC,
};
},
computed: {
isEmptyStage() {
return !this.stageEvents.length;
......@@ -81,7 +100,15 @@ export default {
return WORKFLOW_COLUMN_TITLES.issues;
},
fields() {
return [this.workflowTitle, { key: 'time', label: __('Time'), thClass: 'gl-w-half' }];
return [
this.workflowTitle,
{
key: PAGINATION_SORT_FIELD_DURATION,
label: __('Time'),
thClass: 'gl-w-half',
sortable: true,
},
];
},
prevPage() {
return Math.max(this.pagination.page - 1, 0);
......@@ -98,7 +125,16 @@ export default {
return item.title || item.name;
},
onSelectPage(page) {
this.$emit('handleSelectPage', { page });
const { sort, direction } = this.pagination;
this.$emit('handleUpdatePagination', { sort, direction, page });
},
onSort({ sortBy, sortDesc }) {
this.sort = sortBy;
this.sortDesc = sortDesc;
this.$emit('handleUpdatePagination', {
sort: sortBy,
direction: sortDesc ? PAGINATION_SORT_DIRECTION_DESC : PAGINATION_SORT_DIRECTION_ASC,
});
},
},
};
......@@ -113,11 +149,15 @@ export default {
stacked="lg"
thead-class="border-bottom"
show-empty
:sort-by.sync="sort"
:sort-direction.sync="pagination.direction"
:sort-desc.sync="sortDesc"
:fields="fields"
:items="stageEvents"
:empty-text="emptyStateMessage"
@sort-changed="onSort"
>
<template #cell(workflowTitleKey)="{ item }">
<template #cell(end_event)="{ item }">
<div data-testid="vsa-stage-event">
<div v-if="item.id" data-testid="vsa-stage-content">
<p class="gl-m-0">
......@@ -204,7 +244,7 @@ export default {
</div>
</div>
</template>
<template #cell(time)="{ item }">
<template #cell(duration)="{ item }">
<total-time :time="item.totalTime" data-testid="vsa-stage-event-time" />
</template>
</gl-table>
......
......@@ -75,4 +75,7 @@ export const NOT_ENOUGH_DATA_ERROR = s__(
);
export const PAGINATION_TYPE = 'keyset';
export const PAGINATION_SORT_FIELD = 'created_at';
export const PAGINATION_SORT_FIELD_END_EVENT = 'end_event';
export const PAGINATION_SORT_FIELD_DURATION = 'duration';
export const PAGINATION_SORT_DIRECTION_DESC = 'desc';
export const PAGINATION_SORT_DIRECTION_ASC = 'asc';
......@@ -29,6 +29,8 @@ export default () => {
milestone_title = null,
assignee_username = [],
label_name = [],
sort,
direction,
} = urlQueryToFilter(window.location.search);
store.dispatch('initializeCycleAnalytics', {
......@@ -37,10 +39,8 @@ export default () => {
selectedMilestone: milestone_title,
selectedAssigneeList: assignee_username,
selectedLabelList: label_name,
featureFlags: {
hasDurationChart,
hasPathNavigation,
},
featureFlags: { hasDurationChart, hasPathNavigation },
pagination: { sort: sort?.value || null, direction: direction?.value || null },
});
return new Vue({
......
......@@ -31,9 +31,9 @@ export const setFeatureFlags = ({ commit }, featureFlags) =>
export const setSelectedProjects = ({ commit }, projects) =>
commit(types.SET_SELECTED_PROJECTS, projects);
export const setSelectedStage = ({ commit }, stage) => {
export const setSelectedStage = ({ commit, getters: { paginationParams } }, stage) => {
commit(types.SET_SELECTED_STAGE, stage);
commit(types.SET_PAGINATION, { page: 1, hasNextPage: null });
commit(types.SET_PAGINATION, { ...paginationParams, page: 1, hasNextPage: null });
};
export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => {
......@@ -77,7 +77,7 @@ export const fetchStageData = ({ dispatch, getters, commit }, stageId) => {
.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) });
commit(types.SET_PAGINATION, { ...paginationParams, page, hasNextPage: Boolean(nextPage) });
})
.catch((error) => dispatch('receiveStageDataError', error));
};
......@@ -483,8 +483,8 @@ export const setFilters = ({ dispatch }) => {
export const updateStageTablePagination = (
{ commit, dispatch, state: { selectedStage } },
{ page },
paginationParams,
) => {
commit(types.SET_PAGINATION, { page });
commit(types.SET_PAGINATION, paginationParams);
return dispatch('fetchStageData', selectedStage.id);
};
......@@ -4,12 +4,7 @@ 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,
PAGINATION_TYPE,
PAGINATION_SORT_FIELD,
} from '../constants';
import { DEFAULT_VALUE_STREAM_ID, OVERVIEW_STAGE_CONFIG, PAGINATION_TYPE } from '../constants';
import { transformStagesForPathNavigation } from '../utils';
export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN;
......@@ -49,9 +44,10 @@ export const cycleAnalyticsRequestParams = (state, getters) => {
};
};
export const paginationParams = ({ pagination: { page } }) => ({
export const paginationParams = ({ pagination: { page, sort, direction } }) => ({
pagination: PAGINATION_TYPE,
sort: PAGINATION_SORT_FIELD,
sort,
direction,
page,
});
......
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import { transformRawStages, prepareStageErrors, formatMedianValuesWithOverview } from '../utils';
import * as types from './mutation_types';
......@@ -97,6 +99,7 @@ export default {
selectedProjects = [],
selectedValueStream = {},
defaultStageConfig = [],
pagination = {},
} = {},
) {
state.isLoading = true;
......@@ -106,6 +109,12 @@ export default {
state.startDate = startDate;
state.endDate = endDate;
state.defaultStageConfig = defaultStageConfig;
Vue.set(state, 'pagination', {
page: pagination.page ?? state.pagination.page,
sort: pagination.sort ?? state.pagination.sort,
direction: pagination.direction ?? state.pagination.direction,
});
},
[types.INITIALIZE_VALUE_STREAM_SUCCESS](state) {
state.isLoading = false;
......@@ -183,7 +192,12 @@ export default {
return aName.toUpperCase() > bName.toUpperCase() ? 1 : -1;
});
},
[types.SET_PAGINATION](state, { page, hasNextPage }) {
state.pagination = { page, hasNextPage };
[types.SET_PAGINATION](state, { page, hasNextPage, sort, direction }) {
Vue.set(state, 'pagination', {
page,
hasNextPage,
sort: sort || PAGINATION_SORT_FIELD_END_EVENT,
direction: direction || PAGINATION_SORT_DIRECTION_DESC,
});
},
};
import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
export default () => ({
featureFlags: {},
defaultStageConfig: [],
......@@ -38,5 +40,7 @@ export default () => ({
pagination: {
page: null,
hasNextPage: false,
sort: PAGINATION_SORT_FIELD_END_EVENT,
direction: PAGINATION_SORT_DIRECTION_DESC,
},
});
---
title: Add sorting to the VSA stage table
merge_request: 60793
author:
type: added
......@@ -16,6 +16,10 @@ import StageTableNav from 'ee/analytics/cycle_analytics/components/stage_table_n
import StageTableNew from 'ee/analytics/cycle_analytics/components/stage_table_new.vue';
import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import {
PAGINATION_SORT_FIELD_END_EVENT,
PAGINATION_SORT_DIRECTION_DESC,
} from 'ee/analytics/cycle_analytics/constants';
import createStore from 'ee/analytics/cycle_analytics/store';
import Daterange from 'ee/analytics/shared/components/daterange.vue';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
......@@ -611,11 +615,12 @@ describe('Value Stream Analytics component', () => {
value_stream_id: selectedValueStream.id,
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
stage_id: 1,
project_ids: null,
stage_id: null,
};
const selectedProjectIds = mockData.selectedProjects.map(({ id }) => getIdFromGraphQLId(id));
const selectedStage = { title: 'Plan', id: 2 };
beforeEach(async () => {
commonUtils.historyPushState = jest.fn();
......@@ -682,11 +687,6 @@ describe('Value Stream Analytics component', () => {
});
describe('with selectedStage set', () => {
const selectedStage = {
title: 'Plan',
id: 2,
};
beforeEach(async () => {
wrapper = await createComponent();
store.dispatch('setSelectedStage', selectedStage);
......@@ -703,5 +703,46 @@ describe('Value Stream Analytics component', () => {
});
});
});
describe('with hasPathNavigation=true', () => {
it('does not set the sort and direction parameters', async () => {
wrapper = await createComponent({
featureFlags: {
hasPathNavigation: true,
},
});
await store.dispatch('initializeCycleAnalytics', initialCycleAnalyticsState);
await wrapper.vm.$nextTick();
await shouldMergeUrlParams(wrapper, {
...defaultParams,
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
project_ids: null,
});
});
describe('with a stage selected', () => {
beforeEach(async () => {
wrapper = await createComponent({
featureFlags: {
hasPathNavigation: true,
},
});
await store.dispatch('setSelectedStage', selectedStage);
await wrapper.vm.$nextTick();
});
it('sets the stage, sort and direction parameters', async () => {
await shouldMergeUrlParams(wrapper, {
...defaultParams,
stage_id: selectedStage.id,
direction: PAGINATION_SORT_DIRECTION_DESC,
sort: PAGINATION_SORT_FIELD_END_EVENT,
});
});
});
});
});
});
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import StageTableNew from 'ee/analytics/cycle_analytics/components/stage_table_new.vue';
import { PAGINATION_SORT_FIELD_DURATION } from 'ee/analytics/cycle_analytics/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import {
stagingEvents,
......@@ -26,6 +27,7 @@ const pagination = { page: 1, hasNextPage: true };
const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event');
const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
const findTable = () => wrapper.findComponent(GlTable);
const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title');
function createComponent(props = {}, shallow = false) {
......@@ -289,7 +291,7 @@ describe('StageTable', () => {
findPagination().vm.$emit('input', 2);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('handleSelectPage')[0]).toEqual([{ page: 2 }]);
expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([{ page: 2 }]);
});
describe('with `hasNextPage=false', () => {
......@@ -302,4 +304,38 @@ describe('StageTable', () => {
});
});
});
describe('Sorting', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('clicking a table column will update the sort field', () => {
findTable().vm.$emit('sort-changed', {
sortBy: PAGINATION_SORT_FIELD_DURATION,
sortDesc: true,
});
expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
{
direction: 'desc',
sort: 'duration',
},
]);
});
it('with sortDesc=false will toggle the direction field', async () => {
findTable().vm.$emit('sort-changed', {
sortBy: PAGINATION_SORT_FIELD_DURATION,
sortDesc: false,
});
expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
{
direction: 'asc',
sort: 'duration',
},
]);
});
});
});
......@@ -4,7 +4,8 @@ import {
TASKS_BY_TYPE_SUBJECT_ISSUE,
OVERVIEW_STAGE_CONFIG,
PAGINATION_TYPE,
PAGINATION_SORT_FIELD,
PAGINATION_SORT_DIRECTION_DESC,
PAGINATION_SORT_FIELD_END_EVENT,
} 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';
......@@ -305,9 +306,16 @@ export const selectedProjects = [
export const pathNavIssueMetric = 172800;
export const initialPaginationState = { page: null, hasNextPage: false };
export const initialPaginationState = {
page: null,
hasNextPage: false,
sort: PAGINATION_SORT_FIELD_END_EVENT,
direction: PAGINATION_SORT_DIRECTION_DESC,
};
export const basePaginationResult = {
pagination: PAGINATION_TYPE,
sort: PAGINATION_SORT_FIELD,
sort: PAGINATION_SORT_FIELD_END_EVENT,
direction: PAGINATION_SORT_DIRECTION_DESC,
page: null,
};
import {
PAGINATION_SORT_DIRECTION_DESC,
PAGINATION_SORT_FIELD_END_EVENT,
} 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';
......@@ -78,22 +82,23 @@ describe('Value Stream Analytics mutations', () => {
stages: [{}, { name: "Can't be blank" }, {}, {}, {}, {}, {}, {}],
};
const pagination = { page: 10, hasNextPage: true };
const pagination = { page: 10, hasNextPage: true, sort: null, direction: null };
it.each`
mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${valueStreamErrors} | ${{ createValueStreamErrors: expectedValueStreamErrors, isCreatingValueStream: false }}
${types.RECEIVE_UPDATE_VALUE_STREAM_ERROR} | ${valueStreamErrors} | ${{ createValueStreamErrors: expectedValueStreamErrors, isEditingValueStream: false }}
${types.RECEIVE_DELETE_VALUE_STREAM_ERROR} | ${'Some error occurred'} | ${{ deleteValueStreamError: 'Some error occurred' }}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${valueStreams} | ${{ valueStreams, isLoadingValueStreams: false }}
${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 | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${valueStreamErrors} | ${{ createValueStreamErrors: expectedValueStreamErrors, isCreatingValueStream: false }}
${types.RECEIVE_UPDATE_VALUE_STREAM_ERROR} | ${valueStreamErrors} | ${{ createValueStreamErrors: expectedValueStreamErrors, isEditingValueStream: false }}
${types.RECEIVE_DELETE_VALUE_STREAM_ERROR} | ${'Some error occurred'} | ${{ deleteValueStreamError: 'Some error occurred' }}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${valueStreams} | ${{ valueStreams, isLoadingValueStreams: false }}
${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: { ...pagination, sort: PAGINATION_SORT_FIELD_END_EVENT, direction: PAGINATION_SORT_DIRECTION_DESC } }}
${types.SET_PAGINATION} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} | ${{ pagination: { ...pagination, sort: 'duration', direction: 'asc' } }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......@@ -217,6 +222,11 @@ describe('Value Stream Analytics mutations', () => {
selectedProjects,
createdAfter: '2019-12-31',
createdBefore: '2020-01-01',
pagination: {
page: 1,
sort: PAGINATION_SORT_FIELD_END_EVENT,
direction: PAGINATION_SORT_DIRECTION_DESC,
},
};
it.each`
......@@ -229,7 +239,19 @@ describe('Value Stream Analytics mutations', () => {
state = {};
mutations[types.INITIALIZE_VSA](state, initialData);
expect(state[stateKey]).toEqual(expectedState);
expect(state[stateKey]).toBe(expectedState);
});
it.each`
stateKey | expectedState
${'page'} | ${1}
${'sort'} | ${PAGINATION_SORT_FIELD_END_EVENT}
${'direction'} | ${PAGINATION_SORT_DIRECTION_DESC}
`('$stateKey will be set to $expectedState', ({ stateKey, expectedState }) => {
state = {};
mutations[types.INITIALIZE_VSA](state, initialData);
expect(state.pagination[stateKey]).toBe(expectedState);
});
});
});
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