Commit 0e505c3c authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'mw-productivity-analytics-daterange-picker' into 'master'

Productivity Analytics: Replace timeframe dropdown with daterange picker

See merge request gitlab-org/gitlab!16493
parents 604bb3e1 f0184781
<script>
import { mapState, mapActions } from 'vuex';
import { GlDaterangePicker } from '@gitlab/ui';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { defaultDaysInPast } from '../constants';
export default {
components: {
GlDaterangePicker,
},
computed: {
...mapState('filters', ['groupNamespace', 'startDate', 'endDate']),
dateRange: {
get() {
return { startDate: this.startDate, endDate: this.endDate };
},
set({ startDate, endDate }) {
this.setDateRange({ startDate, endDate });
},
},
},
mounted() {
this.initDateRange();
},
methods: {
...mapActions('filters', ['setDateRange']),
initDateRange() {
const endDate = new Date(Date.now());
const startDate = new Date(getDateInPast(endDate, defaultDaysInPast));
// let's not fetch data since we might not have a groupNamespace selected yet
// this just populates the store with the initial data and waits for a groupNamespace to be set
this.setDateRange({ skipFetch: true, startDate, endDate });
},
},
};
</script>
<template>
<div
v-if="groupNamespace"
class="daterange-container d-flex flex-column flex-lg-row align-items-lg-center justify-content-lg-end"
>
<gl-daterange-picker
v-model="dateRange"
class="d-flex flex-column flex-lg-row"
:default-start-date="startDate"
:default-end-date="endDate"
theme="animate-picker"
start-picker-class="d-flex flex-column flex-lg-row align-items-lg-center mr-lg-2 mb-2 mb-md-0"
end-picker-class="d-flex flex-column flex-lg-row align-items-lg-center"
/>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import DateRangeDropdown from '../../shared/components/date_range_dropdown.vue';
export default {
components: {
DateRangeDropdown,
},
computed: {
...mapState('filters', ['groupNamespace', 'daysInPast']),
},
methods: {
...mapActions('filters', ['setDaysInPast']),
},
};
</script>
<template>
<div
v-if="groupNamespace"
class="dropdown-container d-flex flex-column flex-lg-row align-items-lg-center justify-content-lg-end"
>
<label class="mb-0 mr-1">{{ s__('Analytics|Timeframe') }}</label>
<date-range-dropdown :default-selected="daysInPast" @selected="setDaysInPast" />
</div>
</template>
......@@ -91,5 +91,5 @@ export const columnHighlightStyle = { color: '#418cd8', opacity: 0.8 };
export const scatterPlotAddonQueryDays = 30;
export const accessLevelReporter = 20;
export const projectsPerPage = 50;
export const defaultDaysInPast = 90;
import Vue from 'vue';
import store from './store';
import FilterDropdowns from './components/filter_dropdowns.vue';
import TimeFrameDropdown from './components/timeframe_dropdown.vue';
import DateRange from './components/daterange.vue';
import ProductivityAnalyticsApp from './components/app.vue';
import FilteredSearchProductivityAnalytics from './filtered_search_productivity_analytics';
import { getLabelsEndpoint, getMilestonesEndpoint } from './utils';
......@@ -71,7 +71,7 @@ export default () => {
el: timeframeContainer,
store,
render(h) {
return h(TimeFrameDropdown, {});
return h(DateRange, {});
},
});
......
import _ from 'underscore';
import { s__ } from '~/locale';
import httpStatus from '~/lib/utils/http_status';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import {
chartKeys,
metricTypes,
......@@ -54,6 +53,7 @@ export const getColumnChartData = state => chartKey => {
};
export const chartHasData = state => chartKey => !_.isEmpty(state.charts[chartKey].data);
/**
* Creates a series array of main data for the scatterplot chart.
*
......@@ -72,13 +72,10 @@ export const chartHasData = state => chartKey => !_.isEmpty(state.charts[chartKe
* ["2019-07-10T11:13:23.557Z", 139],
* ]
*
* It eliminates items which were merged before today minus the selected daysInPast.
* It eliminates items which were merged before the startDate (minus an additional days offset).
*/
export const getScatterPlotMainData = (state, getters, rootState) => {
const { data } = state.charts.scatterplot;
const dateInPast = getDateInPast(new Date(), rootState.filters.daysInPast);
return getScatterPlotData(data, dateInPast);
};
export const getScatterPlotMainData = (state, getters, rootState) =>
getScatterPlotData(state.charts.scatterplot.data, rootState.filters.startDate);
/**
* Creates a series array of median data for the scatterplot chart.
......
......@@ -53,8 +53,10 @@ export const setPath = ({ commit, dispatch }, path) => {
});
};
export const setDaysInPast = ({ commit, dispatch }, days) => {
commit(types.SET_DAYS_IN_PAST, days);
export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => {
commit(types.SET_DATE_RANGE, { startDate, endDate });
if (skipFetch) return false;
dispatch(
'charts/updateSelectedItems',
......
import dateFormat from 'dateformat';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { chartKeys, scatterPlotAddonQueryDays } from '../../../constants';
import { dateFormats } from '../../../../shared/constants';
/**
* Returns an object of common filter parameters based on the filter's state
......@@ -12,19 +15,23 @@ import { chartKeys, scatterPlotAddonQueryDays } from '../../../constants';
* author_username: 'author',
* milestone_title: 'my milestone',
* label_name: ['my label', 'yet another label'],
* merged_at_after: '2019-05-09T16:20:18.393Z'
* merged_at_after: '2019-06-11'
* merged_at_before: '2019-09-09'
* }
*
*/
export const getCommonFilterParams = state => chartKey => {
const { groupNamespace, projectPath, filters } = state;
const { groupNamespace, projectPath, filters, startDate, endDate } = state;
const { author_username, milestone_title, label_name } = urlParamsToObject(filters);
// for the scatterplot we need to add additional 30 days to the desired date in the past
const daysInPast =
// for the scatterplot we need to remove 30 days from the state's merged_at_after date
const mergedAtAfterDate =
chartKey && chartKey === chartKeys.scatterplot
? state.daysInPast + scatterPlotAddonQueryDays
: state.daysInPast;
? dateFormat(
new Date(getDateInPast(new Date(startDate), scatterPlotAddonQueryDays)),
dateFormats.isoDate,
)
: dateFormat(startDate, dateFormats.isoDate);
return {
group_id: groupNamespace,
......@@ -32,7 +39,8 @@ export const getCommonFilterParams = state => chartKey => {
author_username,
milestone_title,
label_name,
merged_at_after: `${daysInPast}days`,
merged_at_after: mergedAtAfterDate,
merged_at_before: dateFormat(endDate, dateFormats.isoDate),
};
};
......
export const SET_GROUP_NAMESPACE = 'SET_GROUP_NAMESPACE';
export const SET_PROJECT_PATH = 'SET_PROJECT_PATH';
export const SET_PATH = 'SET_PATH';
export const SET_DAYS_IN_PAST = 'SET_DAYS_IN_PAST';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
......@@ -11,7 +11,8 @@ export default {
[types.SET_PATH](state, path) {
state.filters = path;
},
[types.SET_DAYS_IN_PAST](state, daysInPast) {
state.daysInPast = daysInPast;
[types.SET_DATE_RANGE](state, { startDate, endDate }) {
state.startDate = startDate;
state.endDate = endDate;
},
};
......@@ -2,5 +2,6 @@ export default () => ({
groupNamespace: null,
projectPath: null,
filters: '',
daysInPast: 90,
startDate: null,
endDate: null,
});
......@@ -52,12 +52,12 @@ export const getMilestonesEndpoint = (namespacePath, projectPathWithNamespace) =
* ]
*
* @param {Object} data The raw data which will be transformed
* @param {String} dateInPast Date string in ISO format
* @param {Date} dateInPast Date in the past
* @returns {Array} The transformed data array sorted by date ascending
*/
export const getScatterPlotData = (data, dateInPast) =>
Object.keys(data)
.filter(key => new Date(data[key].merged_at) >= new Date(dateInPast))
.filter(key => new Date(data[key].merged_at) >= dateInPast)
.map(key => [data[key].merged_at, data[key].metric])
.sort((a, b) => new Date(a[0]) - new Date(b[0]));
......
<script>
import dateFormat from 'dateformat';
import { GlDiscreteScatterChart } from '@gitlab/ui/dist/charts';
import { scatterChartLineProps, defaultDateFormat, defaultDateTimeFormat } from '../constants';
import { scatterChartLineProps, dateFormats } from '../constants';
export default {
components: {
......@@ -33,7 +33,7 @@ export default {
chartOption: {
xAxis: {
axisLabel: {
formatter: date => dateFormat(date, defaultDateFormat),
formatter: date => dateFormat(date, dateFormats.defaultDate),
},
},
dataZoom: [
......@@ -69,7 +69,7 @@ export default {
renderTooltip({ data }) {
const [xValue, yValue] = data;
this.tooltipTitle = yValue;
this.tooltipContent = dateFormat(xValue, defaultDateTimeFormat);
this.tooltipContent = dateFormat(xValue, dateFormats.defaultDateTime);
},
},
};
......
export const defaultDateFormat = 'mmm d, yyyy';
export const defaultDateTimeFormat = 'mmm d, yyyy h:MMtt';
export const dateFormats = {
isoDate: 'yyyy-mm-dd',
defaultDate: 'mmm d, yyyy',
defaultDateTime: 'mmm d, yyyy h:MMtt',
};
/**
* #1f78d1 --> $blue-500 (see variables.scss)
......
......@@ -21,8 +21,8 @@
}
.filter-container {
flex: 1 1 50%;
width: 50%;
flex: 1 1 35%;
width: 35%;
@include media-breakpoint-down(md) {
width: 100%;
......@@ -38,6 +38,32 @@
}
}
.daterange-container {
flex: 1 1 30%;
width: 30%;
@include media-breakpoint-down(md) {
width: 100%;
}
.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;
}
}
}
}
.metric-dropdown {
@include media-breakpoint-down(sm) {
width: 100%;
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Daterange from 'ee/analytics/productivity_analytics/components/daterange.vue';
import store from 'ee/analytics/productivity_analytics/store';
import { GlDaterangePicker } from '@gitlab/ui';
import resetStore from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
const startDate = new Date(2019, 8, 1);
const endDate = new Date(2019, 8, 11);
const groupNamespace = 'gitlab-org';
describe('Daterange component', () => {
let wrapper;
let axiosMock;
const actionSpies = {
setDateRange: jest.fn(),
};
const factory = (props = {}) => {
wrapper = shallowMount(localVue.extend(Daterange), {
localVue,
store,
sync: false,
propsData: { ...props },
methods: {
...actionSpies,
},
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(store.state.endpoint).reply(200);
jest.spyOn(global.Date, 'now').mockImplementation(() => new Date('2019-09-25T00:00:00Z'));
factory();
});
afterEach(() => {
wrapper.destroy();
resetStore(store);
axiosMock.restore();
});
const findDaterangePicker = () => wrapper.find(GlDaterangePicker);
describe('template', () => {
describe('when there is no groupNamespace set', () => {
it('does not render the daterange picker', () => {
expect(findDaterangePicker().exists()).toBe(false);
});
});
describe('when a groupNamespace is set', () => {
beforeEach(() => {
store.state.filters.groupNamespace = groupNamespace;
});
it('renders the daterange picker', () => {
expect(findDaterangePicker().exists()).toBe(true);
});
});
});
describe('mounted', () => {
describe('initDateRange', () => {
it('dispatches setDateRange with skipFetch=true', () => {
expect(actionSpies.setDateRange).toHaveBeenCalledWith({
skipFetch: true,
startDate: new Date('2019-06-27T00:00:00.000Z'),
endDate: new Date('2019-09-25T00:00:00.000Z'),
});
});
});
});
describe('computed', () => {
beforeEach(() => {
store.state.filters.groupNamespace = groupNamespace;
});
describe('dateRange', () => {
describe('set', () => {
it('calls `setDateRange` with an object containing startDate and endDate', () => {
wrapper.vm.dateRange = { startDate, endDate };
expect(actionSpies.setDateRange).toHaveBeenCalledWith({ startDate, endDate });
});
});
describe('get', () => {
beforeEach(() => {
store.state.filters.startDate = startDate;
store.state.filters.endDate = endDate;
});
it("returns value of dateRange from state's startDate and endDate", () => {
expect(wrapper.vm.dateRange).toEqual({ startDate, endDate });
});
});
});
});
});
......@@ -11,9 +11,6 @@ import { getScatterPlotData, getMedianLineData } from 'ee/analytics/productivity
import { mockHistogramData, mockScatterplotData } from '../../../mock_data';
jest.mock('ee/analytics/productivity_analytics/utils');
jest.mock('~/lib/utils/datetime_utility', () => ({
getDateInPast: jest.fn().mockReturnValue('2019-07-16T00:00:00.00Z'),
}));
describe('Productivity analytics chart getters', () => {
let state;
......@@ -61,16 +58,13 @@ describe('Productivity analytics chart getters', () => {
const rootState = {
filters: {
daysInPast: 30,
startDate: '2019-07-16',
},
};
getters.getScatterPlotMainData(state, null, rootState);
expect(getScatterPlotData).toHaveBeenCalledWith(
mockScatterplotData,
'2019-07-16T00:00:00.00Z',
);
expect(getScatterPlotData).toHaveBeenCalledWith(mockScatterplotData, '2019-07-16');
});
});
......
......@@ -3,18 +3,23 @@ import * as types from 'ee/analytics/productivity_analytics/store/modules/filter
import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
describe('Productivity analytics filter actions', () => {
let store;
const currentYear = new Date().getFullYear();
const startDate = new Date(currentYear, 8, 1);
const endDate = new Date(currentYear, 8, 7);
const groupNamespace = 'gitlab-org';
const projectPath = 'gitlab-org/gitlab-test';
const path = 'author_username=root';
const daysInPast = 90;
describe('setGroupNamespace', () => {
it('commits the SET_GROUP_NAMESPACE mutation', done => {
const store = {
beforeEach(() => {
store = {
commit: jest.fn(),
dispatch: jest.fn(() => Promise.resolve()),
};
});
describe('setGroupNamespace', () => {
it('commits the SET_GROUP_NAMESPACE mutation', done => {
actions
.setGroupNamespace(store, groupNamespace)
.then(() => {
......@@ -47,11 +52,6 @@ describe('Productivity analytics filter actions', () => {
describe('setProjectPath', () => {
it('commits the SET_PROJECT_PATH mutation', done => {
const store = {
commit: jest.fn(),
dispatch: jest.fn(() => Promise.resolve()),
};
actions
.setProjectPath(store, projectPath)
.then(() => {
......@@ -84,11 +84,6 @@ describe('Productivity analytics filter actions', () => {
describe('setPath', () => {
it('commits the SET_PATH mutation', done => {
const store = {
commit: jest.fn(),
dispatch: jest.fn(() => Promise.resolve()),
};
actions
.setPath(store, path)
.then(() => {
......@@ -119,17 +114,12 @@ describe('Productivity analytics filter actions', () => {
});
});
describe('setDaysInPast', () => {
it('commits the SET_DAYS_IN_PAST mutation', done => {
const store = {
commit: jest.fn(),
dispatch: jest.fn(() => Promise.resolve()),
};
describe('setDateRange', () => {
it('commits the SET_DATE_RANGE mutation and fetches data by default', done => {
actions
.setDaysInPast(store, daysInPast)
.setPath(store, { startDate, endDate })
.then(() => {
expect(store.commit).toHaveBeenCalledWith(types.SET_DAYS_IN_PAST, daysInPast);
expect(store.commit).toHaveBeenCalledWith(types.SET_PATH, { startDate, endDate });
expect(store.dispatch.mock.calls[0]).toEqual([
'charts/updateSelectedItems',
......@@ -154,5 +144,19 @@ describe('Productivity analytics filter actions', () => {
.then(done)
.catch(done.fail);
});
it("commits the SET_DATE_RANGE mutation and doesn't fetch data when fetchData=false", done => {
actions
.setPath(store, { fetchData: false, startDate, endDate })
.then(() => {
expect(store.commit).toHaveBeenCalledWith(types.SET_PATH, {
fetchData: false,
startDate,
endDate,
});
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -4,6 +4,9 @@ import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
describe('Productivity analytics filter getters', () => {
let state;
const currentYear = new Date().getFullYear();
const startDate = new Date(currentYear, 8, 1);
const endDate = new Date(currentYear, 8, 7);
beforeEach(() => {
state = createState();
......@@ -15,7 +18,8 @@ describe('Productivity analytics filter getters', () => {
groupNamespace: 'gitlab-org',
projectPath: 'gitlab-org/gitlab-test',
filters: '?author_username=root&milestone_title=foo&label_name[]=labelxyz',
daysInPast: 30,
startDate,
endDate,
};
});
......@@ -25,7 +29,8 @@ describe('Productivity analytics filter getters', () => {
author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '30days',
merged_at_after: '2019-09-01',
merged_at_before: '2019-09-07',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
......@@ -37,12 +42,13 @@ describe('Productivity analytics filter getters', () => {
});
describe('when chart is scatterplot', () => {
it('returns an object with common filter params and adds additional days to the merged_at_after property', () => {
it('returns an object with common filter params and subtracts 30 days from the merged_at_after date', () => {
const expected = {
author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '60days',
merged_at_after: '2019-08-02',
merged_at_before: '2019-09-07',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
......
......@@ -36,12 +36,15 @@ describe('Productivity analytics filter mutations', () => {
});
});
describe(types.SET_DAYS_IN_PAST, () => {
it('sets the daysInPast', () => {
const daysInPast = 14;
mutations[types.SET_DAYS_IN_PAST](state, daysInPast);
expect(state.daysInPast).toBe(daysInPast);
describe(types.SET_DATE_RANGE, () => {
it('sets the startDate and endDate', () => {
const currentYear = new Date().getFullYear();
const startDate = new Date(currentYear, 8, 1);
const endDate = new Date(currentYear, 8, 7);
mutations[types.SET_DATE_RANGE](state, { startDate, endDate });
expect(state.startDate).toBe(startDate);
expect(state.endDate).toBe(endDate);
});
});
});
......@@ -37,7 +37,7 @@ describe('Productivity Analytics utils', () => {
describe('getScatterPlotData', () => {
it('filters out data before given "dateInPast", transforms the data and sorts by date ascending', () => {
const dateInPast = '2019-08-09T22:00:00.000Z';
const dateInPast = new Date(2019, 7, 9); // '2019-08-09T22:00:00.000Z';
const result = getScatterPlotData(mockScatterplotData, dateInPast);
const expected = [
['2019-08-09T22:00:00.000Z', 44],
......
......@@ -1617,9 +1617,6 @@ msgstr ""
msgid "Analytics"
msgstr ""
msgid "Analytics|Timeframe"
msgstr ""
msgid "Ancestors"
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