Commit 58580ae9 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '13216-implement-a-date-picker-for-cycle-analytics' into 'master'

Resolve "Implement a date picker for cycle analytics"

Closes #13216

See merge request gitlab-org/gitlab!16510
parents f560fedd 5ff67e3a
<script>
import { GlEmptyState } from '@gitlab/ui';
import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE } from '../constants';
import { PROJECTS_PER_PAGE, DEFAULT_DAYS_IN_PAST } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import DateRangeDropdown from '../../shared/components/date_range_dropdown.vue';
import SummaryTable from './summary_table.vue';
import StageTable from './stage_table.vue';
......@@ -16,9 +16,9 @@ export default {
GlEmptyState,
GroupsDropdownFilter,
ProjectsDropdownFilter,
DateRangeDropdown,
SummaryTable,
StageTable,
GlDaterangePicker,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -61,11 +61,12 @@ export default {
'selectedStageName',
'stages',
'summary',
'dataTimeframe',
'labels',
'currentStageEvents',
'customStageFormEvents',
'errorCode',
'startDate',
'endDate',
]),
...mapGetters(['currentStage', 'defaultStage', 'hasNoAccessError', 'currentGroupPath']),
shouldRenderEmptyState() {
......@@ -77,6 +78,17 @@ export default {
shouldDisplayFilters() {
return this.selectedGroup && !this.errorCode;
},
dateRange: {
get() {
return { startDate: this.startDate, endDate: this.endDate };
},
set({ startDate, endDate }) {
this.setDateRange({ startDate, endDate });
},
},
},
mounted() {
this.initDateRange();
},
methods: {
...mapActions([
......@@ -88,8 +100,10 @@ export default {
'setSelectedGroup',
'setSelectedProjects',
'setSelectedTimeframe',
'fetchStageData',
'setSelectedStageName',
'hideCustomStageForm',
'setDateRange',
]),
onGroupSelect(group) {
this.setCycleAnalyticsDataEndpoint(group.path);
......@@ -101,10 +115,6 @@ export default {
this.setSelectedProjects(projectIds);
this.fetchCycleAnalyticsData();
},
onTimeframeSelect(days) {
this.setSelectedTimeframe(days);
this.fetchCycleAnalyticsData();
},
onStageSelect(stage) {
this.hideCustomStageForm();
this.setSelectedStageName(stage.name);
......@@ -114,6 +124,11 @@ export default {
onShowAddStageForm() {
this.fetchCustomStageFormData(this.currentGroupPath);
},
initDateRange() {
const endDate = new Date(Date.now());
const startDate = new Date(getDateInPast(endDate, DEFAULT_DAYS_IN_PAST));
this.setDateRange({ skipFetch: true, startDate, endDate });
},
},
};
</script>
......@@ -145,12 +160,14 @@ export default {
v-if="shouldDisplayFilters"
class="ml-0 ml-md-auto mt-2 mt-md-0 d-flex flex-column flex-md-row align-items-md-center justify-content-md-end"
>
<label class="text-bold mb-0 mr-1">{{ __('Timeframe') }}</label>
<date-range-dropdown
class="js-timeframe-filter"
:available-days-in-past="dateOptions"
:default-selected="dataTimeframe"
@selected="onTimeframeSelect"
<gl-daterange-picker
v-model="dateRange"
class="d-flex flex-column flex-lg-row js-daterange-picker"
:default-start-date="startDate"
:default-end-date="endDate"
start-picker-class="d-flex flex-column flex-lg-row align-items-lg-center mr-lg-2"
end-picker-class="d-flex flex-column flex-lg-row align-items-lg-center"
theme="animate-picker"
/>
</div>
</div>
......
......@@ -2,7 +2,7 @@ import { __ } from '~/locale';
export const PROJECTS_PER_PAGE = 50;
export const DEFAULT_DATA_TIME_FRAME = 30;
export const DEFAULT_DAYS_IN_PAST = 30;
export const EVENTS_LIST_ITEM_LIMIT = 50;
......
import dateFormat from 'dateformat';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import Api from '~/api';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants';
export const setCycleAnalyticsDataEndpoint = ({ commit }, groupPath) =>
commit(types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT, groupPath);
......@@ -13,11 +15,20 @@ export const setStageDataEndpoint = ({ commit }, stageSlug) =>
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) =>
commit(types.SET_SELECTED_PROJECTS, projectIds);
export const setSelectedTimeframe = ({ commit }, timeframe) =>
commit(types.SET_SELECTED_TIMEFRAME, timeframe);
export const setSelectedStageName = ({ commit }, stageName) =>
commit(types.SET_SELECTED_STAGE_NAME, stageName);
export const setDateRange = (
{ commit, dispatch, state },
{ skipFetch = false, startDate, endDate },
) => {
commit(types.SET_DATE_RANGE, { startDate, endDate });
if (skipFetch) return false;
return dispatch('fetchCycleAnalyticsData', { state, dispatch });
};
export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA);
export const receiveStageDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
......@@ -33,7 +44,8 @@ export const fetchStageData = ({ state, dispatch }) => {
axios
.get(state.endpoints.stageData, {
params: {
'cycle_analytics[start_date]': state.dataTimeframe,
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
})
......@@ -68,7 +80,8 @@ export const fetchCycleAnalyticsData = ({ state, dispatch }) => {
axios
.get(state.endpoints.cycleAnalyticsData, {
params: {
'cycle_analytics[start_date]': state.dataTimeframe,
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
})
......
......@@ -3,9 +3,10 @@ export const SET_STAGE_DATA_ENDPOINT = 'SET_STAGE_DATA_ENDPOINT';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_TIMEFRAME = 'SET_SELECTED_TIMEFRAME';
export const SET_SELECTED_STAGE_NAME = 'SET_SELECTED_STAGE_NAME';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR';
......
......@@ -17,12 +17,13 @@ export default {
[types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds;
},
[types.SET_SELECTED_TIMEFRAME](state, timeframe) {
state.dataTimeframe = timeframe;
},
[types.SET_SELECTED_STAGE_NAME](state, stageName) {
state.selectedStageName = stageName;
},
[types.SET_DATE_RANGE](state, { startDate, endDate }) {
state.startDate = startDate;
state.endDate = endDate;
},
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true;
state.isAddingCustomStage = false;
......
import { DEFAULT_DATA_TIME_FRAME } from '../constants';
export default () => ({
endpoints: {
cycleAnalyticsData: '',
stageData: '',
cycleAnalyticsData: null,
stageData: null,
},
dataTimeframe: DEFAULT_DATA_TIME_FRAME,
startDate: null,
endDate: null,
isLoading: false,
isLoadingStage: false,
......
.gl-daterange-picker {
.gl-datepicker-input {
width: 140px;
@include media-breakpoint-down(md) {
width: 100%;
}
}
label {
@include media-breakpoint-up(lg) {
margin-bottom: 0;
margin-right: $gl-padding-4;
}
}
}
......@@ -47,7 +47,7 @@ describe 'Group Cycle Analytics', :js do
end
it 'shows the date filter' do
expect(page).to have_selector('.js-timeframe-filter', visible: true)
expect(page).to have_selector('.js-daterange-picker', visible: true)
end
end
......
......@@ -3,12 +3,11 @@ import Vuex from 'vuex';
import Vue from 'vue';
import store from 'ee/analytics/cycle_analytics/store';
import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import { GlEmptyState } from '@gitlab/ui';
import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import GroupsDropdownFilter from 'ee/analytics/shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
import DateRangeDropdown from 'ee/analytics/shared/components/date_range_dropdown.vue';
import SummaryTable from 'ee/analytics/cycle_analytics/components/summary_table.vue';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import 'bootstrap';
......@@ -66,8 +65,8 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBe(flag);
};
const displaysDateRangeDropdown = flag => {
expect(wrapper.find(DateRangeDropdown).exists()).toBe(flag);
const displaysDateRangePicker = flag => {
expect(wrapper.find(GlDaterangePicker).exists()).toBe(flag);
};
const displaysSummaryTable = flag => {
......@@ -88,6 +87,27 @@ describe('Cycle Analytics component', () => {
mock.restore();
});
describe('mounted', () => {
const actionSpies = {
setDateRange: jest.fn(),
};
beforeEach(() => {
jest.spyOn(global.Date, 'now').mockImplementation(() => new Date(mockData.endDate));
wrapper = createComponent({ opts: { methods: actionSpies } });
});
describe('initDateRange', () => {
it('dispatches setDateRange with skipFetch=true', () => {
expect(actionSpies.setDateRange).toHaveBeenCalledWith({
skipFetch: true,
startDate: mockData.startDate,
endDate: mockData.endDate,
});
});
});
});
describe('displays the components as required', () => {
describe('before a filter has been selected', () => {
it('displays an empty state', () => {
......@@ -108,8 +128,8 @@ describe('Cycle Analytics component', () => {
displaysProjectsDropdownFilter(false);
});
it('does not display the date range dropdown', () => {
displaysDateRangeDropdown(false);
it('does not display the date range picker', () => {
displaysDateRangePicker(false);
});
it('does not display the summary table', () => {
......@@ -143,8 +163,8 @@ describe('Cycle Analytics component', () => {
);
});
it('displays the date range dropdown', () => {
displaysDateRangeDropdown(true);
it('displays the date range picker', () => {
displaysDateRangePicker(true);
});
it('displays the summary table', () => {
......@@ -224,8 +244,8 @@ describe('Cycle Analytics component', () => {
displaysProjectsDropdownFilter(false);
});
it('does not display the date range dropdown', () => {
displaysDateRangeDropdown(false);
it('does not display the date range picker', () => {
displaysDateRangePicker(false);
});
it('does not display the summary table', () => {
......
......@@ -3,6 +3,8 @@ import { getJSONFixture } from 'helpers/fixtures';
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
export const groupLabels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title }));
......@@ -47,6 +49,9 @@ const stageFixtures = defaultStages.reduce((acc, stage) => {
};
}, {});
export const endDate = new Date(Date.now());
export const startDate = new Date(getDateInPast(endDate, DEFAULT_DAYS_IN_PAST));
export const issueEvents = stageFixtures.issue;
export const planEvents = stageFixtures.plan;
export const reviewEvents = stageFixtures.review;
......
......@@ -4,7 +4,14 @@ import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { group, cycleAnalyticsData, allowedStages as stages, groupLabels } from '../mock_data';
import {
group,
cycleAnalyticsData,
allowedStages as stages,
groupLabels,
startDate,
endDate,
} from '../mock_data';
const stageData = { events: [] };
const error = new Error('Request failed with status code 404');
......@@ -41,7 +48,6 @@ describe('Cycle analytics actions', () => {
${'setSelectedGroup'} | ${'SET_SELECTED_GROUP'} | ${'selectedGroup'} | ${'someNewGroup'}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStageName'} | ${'SET_SELECTED_STAGE_NAME'} | ${'selectedStageName'} | ${'someNewGroup'}
${'setSelectedTimeframe'} | ${'SET_SELECTED_TIMEFRAME'} | ${'dataTimeframe'} | ${20}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
testAction(
actions[action],
......@@ -57,6 +63,21 @@ describe('Cycle analytics actions', () => {
);
});
describe('setDateRange', () => {
it('sets the dates as expected and dispatches fetchCycleAnalyticsData', done => {
const dispatch = expect.any(Function);
testAction(
actions.setDateRange,
{ startDate, endDate },
state,
[{ type: types.SET_DATE_RANGE, payload: { startDate, endDate } }],
[{ type: 'fetchCycleAnalyticsData', payload: { dispatch, state } }],
done,
);
});
});
describe('fetchStageData', () => {
beforeEach(() => {
mock.onGet(state.endpoints.stageData).replyOnce(200, { events: [] });
......
......@@ -13,6 +13,8 @@ import {
reviewStage,
productionStage,
groupLabels,
startDate,
endDate,
} from '../mock_data';
let state = null;
......@@ -45,13 +47,13 @@ describe('Cycle analytics mutations', () => {
});
it.each`
mutation | payload | expectedState
${types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT} | ${'cool-beans'} | ${{ endpoints: { cycleAnalyticsData: '/groups/cool-beans/-/cycle_analytics' } }}
${types.SET_STAGE_DATA_ENDPOINT} | ${'rad-stage'} | ${{ endpoints: { stageData: '/fake/api/events/rad-stage.json' } }}
${types.SET_SELECTED_GROUP} | ${'cool-beans'} | ${{ selectedGroup: 'cool-beans', selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_SELECTED_TIMEFRAME} | ${60} | ${{ dataTimeframe: 60 }}
${types.SET_SELECTED_STAGE_NAME} | ${'first-stage'} | ${{ selectedStageName: 'first-stage' }}
mutation | payload | expectedState
${types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT} | ${'cool-beans'} | ${{ endpoints: { cycleAnalyticsData: '/groups/cool-beans/-/cycle_analytics' } }}
${types.SET_STAGE_DATA_ENDPOINT} | ${'rad-stage'} | ${{ endpoints: { stageData: '/fake/api/events/rad-stage.json' } }}
${types.SET_SELECTED_GROUP} | ${'cool-beans'} | ${{ selectedGroup: 'cool-beans', selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE_NAME} | ${'first-stage'} | ${{ selectedStageName: 'first-stage' }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......
......@@ -16674,9 +16674,6 @@ msgstr ""
msgid "Timeago|right now"
msgstr ""
msgid "Timeframe"
msgstr ""
msgid "Timeout"
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