Commit 2b0f6ddb authored by Martin Wortschack's avatar Martin Wortschack Committed by Filipa Lacerda

Render scatterplot with 30 day rolling median line

- Add scatterplot utils
- Add getDateInPast to datetime utility
- Add changelog entry
- Update PO file
- Update specs
- Add mock data for scatterplot
parent d9117a45
......@@ -547,3 +547,13 @@ export const calculateRemainingMilliseconds = endDate => {
const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
return Math.max(remainingMilliseconds, 0);
};
/**
* Subtracts a given number of days from a given date and returns the new date.
*
* @param {Date} date the date that we will substract days from
* @param {number} daysInPast number of days that are subtracted from a given date
* @returns {String} Date string in ISO format
*/
export const getDateInPast = (date, daysInPast) =>
new Date(date.setTime(date.getTime() - daysInPast * 24 * 60 * 60 * 1000)).toISOString();
......@@ -106,3 +106,14 @@ export const sum = (a = 0, b = 0) => a + b;
* @param {Int} number
*/
export const isOdd = (number = 0) => number % 2;
/**
* Computes the median for a given array.
* @param {Array} arr An array of numbers
* @returns {Number} The median of the given array
*/
export const median = arr => {
const middle = Math.floor(arr.length / 2);
const sorted = arr.sort((a, b) => a - b);
return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
};
......@@ -11,6 +11,7 @@ import {
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import Icon from '~/vue_shared/components/icon.vue';
import MetricChart from './metric_chart.vue';
import Scatterplot from '../../shared/components/scatterplot.vue';
import MergeRequestTable from './mr_table.vue';
import { chartKeys } from '../constants';
......@@ -24,6 +25,7 @@ export default {
GlButton,
Icon,
MetricChart,
Scatterplot,
MergeRequestTable,
},
directives: {
......@@ -55,8 +57,10 @@ export default {
...mapGetters('charts', [
'chartLoading',
'chartHasData',
'getChartData',
'getColumnChartData',
'getColumnChartDatazoomOption',
'getScatterPlotMainData',
'getScatterPlotMedianData',
'getMetricDropdownLabel',
'getSelectedMetric',
'hasNoAccessError',
......@@ -141,19 +145,19 @@ export default {
"
/>
<template v-if="showAppContent">
<h4>{{ __('Merge Requests') }}</h4>
<h4>{{ s__('ProductivityAnalytics|Merge Requests') }}</h4>
<metric-chart
ref="mainChart"
class="mb-4"
:title="__('Time to merge')"
:title="s__('ProductivityAnalytics|Time to merge')"
:description="
__('You can filter by \'days to merge\' by clicking on the columns in the chart.')
"
:is-loading="chartLoading(chartKeys.main)"
:chart-data="getChartData(chartKeys.main)"
:chart-data="getColumnChartData(chartKeys.main)"
>
<gl-column-chart
:data="{ full: getChartData(chartKeys.main) }"
:data="{ full: getColumnChartData(chartKeys.main) }"
:option="getColumnChartOption(chartKeys.main)"
:y-axis-title="__('Merge requests')"
:x-axis-title="__('Days')"
......@@ -176,17 +180,17 @@ export default {
:is-loading="chartLoading(chartKeys.timeBasedHistogram)"
:metric-types="getMetricTypes(chartKeys.timeBasedHistogram)"
:selected-metric="getSelectedMetric(chartKeys.timeBasedHistogram)"
:chart-data="getChartData(chartKeys.timeBasedHistogram)"
:chart-data="getColumnChartData(chartKeys.timeBasedHistogram)"
@metricTypeChange="
metric =>
setMetricType({ metricType: metric, chartKey: chartKeys.timeBasedHistogram })
"
>
<gl-column-chart
:data="{ full: getChartData(chartKeys.timeBasedHistogram) }"
:data="{ full: getColumnChartData(chartKeys.timeBasedHistogram) }"
:option="getColumnChartOption(chartKeys.timeBasedHistogram)"
:y-axis-title="__('Merge requests')"
:x-axis-title="__('Hours')"
:y-axis-title="s__('ProductivityAnalytics|Merge requests')"
:x-axis-title="s__('ProductivityAnalytics|Hours')"
x-axis-type="category"
/>
</metric-chart>
......@@ -202,26 +206,46 @@ export default {
:is-loading="chartLoading(chartKeys.commitBasedHistogram)"
:metric-types="getMetricTypes(chartKeys.commitBasedHistogram)"
:selected-metric="getSelectedMetric(chartKeys.commitBasedHistogram)"
:chart-data="getChartData(chartKeys.commitBasedHistogram)"
:chart-data="getColumnChartData(chartKeys.commitBasedHistogram)"
@metricTypeChange="
metric =>
setMetricType({ metricType: metric, chartKey: chartKeys.commitBasedHistogram })
"
>
<gl-column-chart
:data="{ full: getChartData(chartKeys.commitBasedHistogram) }"
:data="{ full: getColumnChartData(chartKeys.commitBasedHistogram) }"
:option="getColumnChartOption(chartKeys.commitBasedHistogram)"
:y-axis-title="__('Merge requests')"
:y-axis-title="s__('ProductivityAanalytics|Merge requests')"
:x-axis-title="getMetricDropdownLabel(chartKeys.commitBasedHistogram)"
x-axis-type="category"
/>
</metric-chart>
</div>
<metric-chart
ref="scatterplot"
class="mb-4"
:title="s__('ProductivityAnalytics|Trendline')"
:is-loading="chartLoading(chartKeys.scatterplot)"
:metric-types="getMetricTypes(chartKeys.scatterplot)"
:chart-data="getScatterPlotMainData"
:selected-metric="getSelectedMetric(chartKeys.scatterplot)"
@metricTypeChange="
metric => setMetricType({ metricType: metric, chartKey: chartKeys.scatterplot })
"
>
<scatterplot
:x-axis-title="s__('ProductivityAnalytics|Merge date')"
:y-axis-title="s__('ProductivityAnalytics|Days')"
:scatter-data="getScatterPlotMainData"
:median-line-data="getScatterPlotMedianData"
/>
</metric-chart>
<div
class="js-mr-table-sort d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2"
>
<h5>{{ __('List') }}</h5>
<h5>{{ s__('ProductivityAnalytics|List') }}</h5>
<div
v-if="showMergeRequestTable"
class="d-flex flex-column flex-md-row align-items-md-center"
......@@ -261,7 +285,6 @@ export default {
</div>
<div class="js-mr-table">
<div ref="foo"></div>
<gl-loading-icon v-if="isLoadingTable" size="md" class="my-4 py-4" />
<merge-request-table
v-if="showMergeRequestTable"
......
......@@ -13,35 +13,40 @@ export const chartTypes = {
};
export const metricTypes = [
{
key: 'days_to_merge',
label: __('Days to merge'),
charts: [chartKeys.scatterplot],
},
{
key: 'time_to_first_comment',
label: __('Time from first commit until first comment'),
chart: chartKeys.timeBasedHistogram,
charts: [chartKeys.timeBasedHistogram, chartKeys.scatterplot],
},
{
key: 'time_to_last_commit',
label: __('Time from first comment to last commit'),
chart: chartKeys.timeBasedHistogram,
charts: [chartKeys.timeBasedHistogram, chartKeys.scatterplot],
},
{
key: 'time_to_merge',
label: __('Time from last commit to merge'),
chart: chartKeys.timeBasedHistogram,
charts: [chartKeys.timeBasedHistogram, chartKeys.scatterplot],
},
{
key: 'commits_count',
label: __('Number of commits per MR'),
chart: chartKeys.commitBasedHistogram,
charts: [chartKeys.commitBasedHistogram, chartKeys.scatterplot],
},
{
key: 'loc_per_commit',
label: __('Number of LOCs per commit'),
chart: chartKeys.commitBasedHistogram,
charts: [chartKeys.commitBasedHistogram, chartKeys.scatterplot],
},
{
key: 'files_touched',
label: __('Number of files touched'),
chart: chartKeys.commitBasedHistogram,
charts: [chartKeys.commitBasedHistogram, chartKeys.scatterplot],
},
];
......@@ -64,7 +69,6 @@ export const daysToMergeMetric = {
};
export const defaultMaxColumnChartItemsPerPage = 20;
export const maxColumnChartItemsPerPage = {
[chartKeys.main]: 40,
};
......@@ -75,10 +79,6 @@ export const dataZoomOptions = [
bottom: 10,
start: 0,
},
{
type: 'inside',
start: 0,
},
];
/**
......@@ -86,6 +86,10 @@ export const dataZoomOptions = [
*/
export const columnHighlightStyle = { color: '#418cd8', opacity: 0.8 };
// The number of days which will be to the state's daysInPast
// This is required to query historical data from the API to draw a 30 days rolling median line
export const scatterPlotAddonQueryDays = 30;
export const accessLevelReporter = 20;
export const projectsPerPage = 50;
export const getMetricTypes = state => chartKey =>
state.metricTypes.filter(m => m.chart === chartKey);
state.metricTypes.filter(m => m.charts.indexOf(chartKey) !== -1);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -50,11 +50,8 @@ export const setMetricType = ({ commit, dispatch }, { chartKey, metricType }) =>
export const chartItemClicked = ({ commit, dispatch }, { chartKey, item }) => {
commit(types.UPDATE_SELECTED_CHART_ITEMS, { chartKey, item });
// update histograms
dispatch('fetchChartData', chartKeys.timeBasedHistogram);
dispatch('fetchChartData', chartKeys.commitBasedHistogram);
// TODO: update scatterplot
// update secondary charts
dispatch('fetchSecondaryChartData');
// update table
dispatch('table/fetchMergeRequests', null, { root: true });
......
import _ from 'underscore';
import httpStatus from '~/lib/utils/http_status';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import {
chartKeys,
metricTypes,
......@@ -7,7 +8,9 @@ import {
defaultMaxColumnChartItemsPerPage,
maxColumnChartItemsPerPage,
dataZoomOptions,
scatterPlotAddonQueryDays,
} from '../../../constants';
import { getScatterPlotData, getMedianLineData } from '../../../utils';
export const chartLoading = state => chartKey => state.charts[chartKey].isLoading;
......@@ -30,7 +33,7 @@ export const chartLoading = state => chartKey => state.charts[chartKey].isLoadin
* the itemStyle will be set accordingly in order to highlight the relevant bar.
*
*/
export const getChartData = state => chartKey => {
export const getColumnChartData = state => chartKey => {
const dataWithSelected = Object.keys(state.charts[chartKey].data).map(key => {
const dataArr = [key, state.charts[chartKey].data[key]];
let itemStyle = {};
......@@ -49,6 +52,44 @@ export const getChartData = state => chartKey => {
};
export const chartHasData = state => chartKey => !_.isEmpty(state.charts[chartKey].data);
/**
* Creates a series array of main data for the scatterplot chart.
*
* Takes an object of the form
* {
* "1": { "metric": 138", merged_at": "2019-07-09T14:58:07.756Z" },
* "2": { "metric": 139, "merged_at": "2019-07-10T11:13:23.557Z" },
* "3": { "metric": 24, "merged_at": "2019-07-01T07:06:23.193Z" }
* }
*
* and creates the following structure:
*
* [
* ["2019-07-01T07:06:23.193Z", 24],
* ["2019-07-09T14:58:07.756Z", 138],
* ["2019-07-10T11:13:23.557Z", 139],
* ]
*
* It eliminates items which were merged before today minus the selected daysInPast.
*/
export const getScatterPlotMainData = (state, getters, rootState) => {
const { data } = state.charts.scatterplot;
const dateInPast = getDateInPast(new Date(), rootState.filters.daysInPast);
return getScatterPlotData(data, dateInPast);
};
/**
* Creates a series array of median data for the scatterplot chart.
*
* It calls getMedianLineData internally with the raw scatterplot data and the computed by getters.getScatterPlotMainData.
* scatterPlotAddonQueryDays is necessary since we query the API with an additional day offset to compute the median.
*/
export const getScatterPlotMedianData = (state, getters) =>
getMedianLineData(
state.charts.scatterplot.data,
getters.getScatterPlotMainData,
scatterPlotAddonQueryDays,
);
export const getMetricDropdownLabel = state => chartKey =>
metricTypes.find(m => m.key === state.charts[chartKey].params.metricType).label;
......@@ -58,7 +99,7 @@ export const getFilterParams = (state, getters, rootState, rootGetters) => chart
// common filter params
const params = {
...rootGetters['filters/getCommonFilterParams'],
...rootGetters['filters/getCommonFilterParams'](chartKey),
chart_type: chartParams.chartType,
};
......
......@@ -38,6 +38,7 @@ export default () => ({
selected: [],
params: {
chartType: chartTypes.scatterplot,
metricType: 'days_to_merge',
},
},
},
......
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { chartKeys, scatterPlotAddonQueryDays } from '../../../constants';
/**
* Returns an object of common filter parameters based on the filter's state
......@@ -15,29 +16,25 @@ import { urlParamsToObject } from '~/lib/utils/common_utils';
* }
*
*/
export const getCommonFilterParams = (state, getters) => {
export const getCommonFilterParams = state => chartKey => {
const { groupNamespace, projectPath, filters } = 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 =
chartKey && chartKey === chartKeys.scatterplot
? state.daysInPast + scatterPlotAddonQueryDays
: state.daysInPast;
return {
group_id: groupNamespace,
project_id: projectPath,
author_username,
milestone_title,
label_name,
merged_at_after: getters.mergedOnAfterDate,
merged_at_after: `${daysInPast}days`,
};
};
/**
* Computes the "merged_at_after" date which will be used in the getCommonFilterParams getter.
* It subtracts the number of days (based on the state's daysInPast property) from today's date
* and returns the new date.
*/
export const mergedOnAfterDate = state => {
const d = new Date();
return new Date(d.setTime(d.getTime() - state.daysInPast * 24 * 60 * 60 * 1000)).toISOString();
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -9,7 +9,7 @@ export const fetchMergeRequests = ({ dispatch, state, rootState, rootGetters })
const { sortField, sortOrder, pageInfo } = state;
const params = {
...rootGetters['filters/getCommonFilterParams'],
...rootGetters['filters/getCommonFilterParams'](),
days_to_merge: rootState.charts.charts.main.selected,
sort: `${sortField}_${sortOrder}`,
page: pageInfo ? pageInfo.page : null,
......
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { median } from '~/lib/utils/number_utils';
/**
* Gets the labels endpoint for a given group or project
* @param {String} namespacePath - The group's namespace.
* @param {String} projectPathWithNamespace - The project's name including the group's namespace
* @returns {String} - The labels endpoint path
*/
export const getLabelsEndpoint = (namespacePath, projectPathWithNamespace) => {
if (projectPathWithNamespace) {
return `/${projectPathWithNamespace}/-/labels`;
......@@ -6,6 +15,12 @@ export const getLabelsEndpoint = (namespacePath, projectPathWithNamespace) => {
return `/groups/${namespacePath}/-/labels`;
};
/**
* Gets the milestones endpoint for a given group or project
* @param {String} namespacePath - The group's namespace.
* @param {String} projectPathWithNamespace - The project's name including the group's namespace
* @returns {String} - The milestones endpoint path
*/
export const getMilestonesEndpoint = (namespacePath, projectPathWithNamespace) => {
if (projectPathWithNamespace) {
return `/${projectPathWithNamespace}/-/milestones`;
......@@ -13,3 +28,68 @@ export const getMilestonesEndpoint = (namespacePath, projectPathWithNamespace) =
return `/groups/${namespacePath}/-/milestones`;
};
/**
* Transforms a given data object into an array
* which will be used as series data for the scatterplot chart.
* It eliminates items which were merged before a "dateInPast" and sorts
* the result by date (ascending)
*
* Takes an object of the form
* {
* "1": { "metric": 138", merged_at": "2019-07-09T14:58:07.756Z" },
* "2": { "metric": 139, "merged_at": "2019-07-10T11:13:23.557Z" },
* "3": { "metric": 24, "merged_at": "2019-07-01T07:06:23.193Z" }
* }
*
* and creates the following two-dimensional array
* where the first value is the "merged_at" date and the second value is the metric:
*
* [
* ["2019-07-01T07:06:23.193Z", 24],
* ["2019-07-09T14:58:07.756Z", 138],
* ["2019-07-10T11:13:23.557Z", 139],
* ]
*
* @param {Object} data The raw data which will be transformed
* @param {String} dateInPast Date string in ISO format
* @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))
.map(key => [data[key].merged_at, data[key].metric])
.sort((a, b) => new Date(a[0]) - new Date(b[0]));
/**
* Computes the moving median line data.
* It takes the raw data object (which contains historical data) and the scatterData (from getScatterPlotData)
* and computes the median for every date in scatterData.
* The median for a given date in scatterData (called item) is computed by taking all metrics of the raw data into account
* which are before (or eqaul to) the the item's merged_at date
* and after (or equal to) the item's merged_at date minus a given "daysOffset" (e.g., 30 days for "30 day rolling median")
*
* i.e., moving median for a given DAY is the median the range of values (DAY-30 ... DAY)
*
* @param {Object} data The raw data which will be used for computing the median
* @param {Array} scatterData The transformed data from getScatterPlotData
* @param {Number} daysOffset The number of days that is substracted from each date in scatterData (e.g. 30 days in the past)
* @returns {Array} An array with each item being another arry of two items (date, computed median)
*/
export const getMedianLineData = (data, scatterData, daysOffset) =>
scatterData.map(item => {
const [dateString] = item;
const values = Object.keys(data)
.filter(key => {
const mergedAtDate = new Date(data[key].merged_at);
const itemDate = new Date(dateString);
return (
mergedAtDate <= itemDate && mergedAtDate >= new Date(getDateInPast(itemDate, daysOffset))
);
})
.map(key => data[key].metric);
const computedMedian = values.length ? median(values) : 0;
return [dateString, computedMedian];
});
<script>
import dateFormat from 'dateformat';
import { GlDiscreteScatterChart } from '@gitlab/ui/dist/charts';
import { scatterChartLineProps, defaultDateFormat, defaultDateTimeFormat } from '../constants';
export default {
components: {
GlDiscreteScatterChart,
},
props: {
xAxisTitle: {
type: String,
required: true,
},
yAxisTitle: {
type: String,
required: true,
},
scatterData: {
type: Array,
required: true,
},
medianLineData: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
tooltipTitle: '',
tooltipContent: '',
chartOption: {
xAxis: {
axisLabel: {
formatter: date => dateFormat(date, defaultDateFormat),
},
},
dataZoom: [
{
type: 'slider',
bottom: 10,
start: 0,
},
],
},
};
},
computed: {
chartData() {
const result = [
{
type: 'scatter',
data: this.scatterData,
},
];
if (this.medianLineData.length) {
result.push({
data: this.medianLineData,
...scatterChartLineProps.default,
});
}
return result;
},
},
methods: {
renderTooltip({ data }) {
const [xValue, yValue] = data;
this.tooltipTitle = yValue;
this.tooltipContent = dateFormat(xValue, defaultDateTimeFormat);
},
},
};
</script>
<template>
<gl-discrete-scatter-chart
:data="chartData"
:option="chartOption"
:y-axis-title="yAxisTitle"
:x-axis-title="xAxisTitle"
:format-tooltip-text="renderTooltip"
>
<div slot="tooltipTitle">{{ tooltipTitle }}</div>
<div slot="tooltipContent">{{ tooltipContent }}</div>
</gl-discrete-scatter-chart>
</template>
export const defaultDateFormat = 'mmm d, yyyy';
export const defaultDateTimeFormat = 'mmm d, yyyy h:MMtt';
/**
* #1f78d1 --> $blue-500 (see variables.scss)
*/
export const scatterChartLineColor = '#1f78d1';
export const scatterChartLineProps = {
default: {
type: 'line',
showSymbol: false,
lineStyle: { color: scatterChartLineColor },
},
};
---
title: 'Productivity analytics: Add scatterplot'
merge_request: 15569
author:
type: other
......@@ -56,6 +56,7 @@ describe('ProductivityApp component', () => {
const findSecondaryChartsSection = () => wrapper.find({ ref: 'secondaryCharts' });
const findTimeBasedMetricChart = () => wrapper.find({ ref: 'timeBasedChart' });
const findCommitBasedMetricChart = () => wrapper.find({ ref: 'commitBasedChart' });
const findScatterplotMetricChart = () => wrapper.find({ ref: 'scatterplot' });
const findMrTableSortSection = () => wrapper.find('.js-mr-table-sort');
const findSortFieldDropdown = () => findMrTableSortSection().find(GlDropdown);
const findSortOrderToggle = () => findMrTableSortSection().find(GlButton);
......@@ -246,6 +247,47 @@ describe('ProductivityApp component', () => {
});
});
describe('Scatterplot', () => {
it('renders a metric chart component', () => {
expect(findScatterplotMetricChart().exists()).toBe(true);
});
describe('when chart finished loading', () => {
describe('and the chart has data', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('charts/receiveChartDataSuccess', {
chartKey: chartKeys.scatterplot,
data: {
1: { metric: 2, merged_at: '2019-07-01T07:06:23.193Z' },
2: { metric: 3, merged_at: '2019-07-05T08:27:42.411Z' },
},
});
});
it('sets isLoading=false on the metric chart', () => {
expect(findScatterplotMetricChart().props('isLoading')).toBe(false);
});
it('passes non-empty chartData to the metric chart', () => {
expect(findScatterplotMetricChart().props('chartData')).not.toEqual([]);
});
describe('when the user changes the metric', () => {
beforeEach(() => {
findScatterplotMetricChart().vm.$emit('metricTypeChange', 'loc_per_commit');
});
it('should call setMetricType when `metricTypeChange` is emitted on the metric chart', () => {
expect(actionSpies.setMetricType).toHaveBeenCalledWith({
metricType: 'loc_per_commit',
chartKey: chartKeys.scatterplot,
});
});
});
});
});
});
describe('MR table', () => {
describe('when table is loading', () => {
beforeEach(() => {
......
......@@ -75,3 +75,86 @@ export const mockHistogramData = {
'40': 40,
'41': 41,
};
export const mockScatterplotData = {
'1': {
metric: 139,
merged_at: '2019-08-18T22:00:00.000Z',
},
'2': {
metric: 138,
merged_at: '2019-08-17T22:00:00.000Z',
},
'3': {
metric: 24,
merged_at: '2019-08-16T22:00:00.000Z',
},
'4': {
metric: 56,
merged_at: '2019-08-15T22:00:00.000Z',
},
'5': {
metric: 46,
merged_at: '2019-08-14T22:00:00.000Z',
},
'6': {
metric: 43,
merged_at: '2019-08-13T22:00:00.000Z',
},
'7': {
metric: 60,
merged_at: '2019-08-12T22:00:00.000Z',
},
'8': {
metric: 62,
merged_at: '2019-08-11T22:00:00.000Z',
},
'9': {
metric: 46,
merged_at: '2019-08-10T22:00:00.000Z',
},
'10': {
metric: 44,
merged_at: '2019-08-09T22:00:00.000Z',
},
'11': {
metric: 57,
merged_at: '2019-08-08T22:00:00.000Z',
},
'12': {
metric: 51,
merged_at: '2019-08-07T22:00:00.000Z',
},
'13': {
metric: 54,
merged_at: '2019-08-06T22:00:00.000Z',
},
'14': {
metric: 64,
merged_at: '2019-08-05T22:00:00.000Z',
},
'15': {
metric: 52,
merged_at: '2019-08-04T22:00:00.000Z',
},
'16': {
metric: 56,
merged_at: '2019-08-03T22:00:00.000Z',
},
'17': {
metric: 47,
merged_at: '2019-08-02T22:00:00.000Z',
},
'18': {
metric: 49,
merged_at: '2019-08-01T22:00:00.000Z',
},
'19': {
metric: 46,
merged_at: '2019-07-31T22:00:00.000Z',
},
'20': {
metric: 57,
merged_at: '2019-07-30T22:00:00.000Z',
},
};
......@@ -200,11 +200,7 @@ describe('Productivity analytics chart actions', () => {
{ chartKey, item },
mockedContext.state,
[{ type: types.UPDATE_SELECTED_CHART_ITEMS, payload: { chartKey, item } }],
[
{ type: 'fetchChartData', payload: chartKeys.timeBasedHistogram },
{ type: 'fetchChartData', payload: chartKeys.commitBasedHistogram },
{ type: 'table/fetchMergeRequests', payload: null },
],
[{ type: 'fetchSecondaryChartData' }, { type: 'table/fetchMergeRequests', payload: null }],
done,
);
});
......
......@@ -4,8 +4,15 @@ import {
chartKeys,
columnHighlightStyle,
maxColumnChartItemsPerPage,
scatterPlotAddonQueryDays,
} from 'ee/analytics/productivity_analytics/constants';
import { mockHistogramData } from '../../../mock_data';
import { getScatterPlotData, getMedianLineData } from 'ee/analytics/productivity_analytics/utils';
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;
......@@ -27,8 +34,8 @@ describe('Productivity analytics chart getters', () => {
});
});
describe('getChartData', () => {
it("parses the chart's data and adds a color property to selected items", () => {
describe('getColumnChartData', () => {
it("parses the column chart's data and adds a color property to selected items", () => {
const chartKey = chartKeys.main;
state.charts[chartKey] = {
data: {
......@@ -43,7 +50,44 @@ describe('Productivity analytics chart getters', () => {
{ value: ['5', 17], itemStyle: columnHighlightStyle },
];
expect(getters.getChartData(state)(chartKey)).toEqual(chartData);
expect(getters.getColumnChartData(state)(chartKey)).toEqual(chartData);
});
});
describe('getScatterPlotMainData', () => {
it('calls getScatterPlotData with the raw scatterplot data and the date in past', () => {
state.charts.scatterplot.data = mockScatterplotData;
const rootState = {
filters: {
daysInPast: 30,
},
};
getters.getScatterPlotMainData(state, null, rootState);
expect(getScatterPlotData).toHaveBeenCalledWith(
mockScatterplotData,
'2019-07-16T00:00:00.00Z',
);
});
});
describe('getScatterPlotMedianData', () => {
it('calls getMedianLineData with the raw scatterplot data, the getScatterPlotMainData getter and the an additional days offset', () => {
state.charts.scatterplot.data = mockScatterplotData;
const mockGetters = {
getScatterPlotMainData: jest.fn(),
};
getters.getScatterPlotMedianData(state, mockGetters);
expect(getMedianLineData).toHaveBeenCalledWith(
mockScatterplotData,
mockGetters.getScatterPlotMainData,
scatterPlotAddonQueryDays,
);
});
});
......@@ -62,9 +106,12 @@ describe('Productivity analytics chart getters', () => {
describe('getFilterParams', () => {
const rootGetters = {};
rootGetters['filters/getCommonFilterParams'] = {
group_id: groupNamespace,
project_id: projectPath,
rootGetters['filters/getCommonFilterParams'] = () => {
const params = {
group_id: groupNamespace,
project_id: projectPath,
};
return params;
};
describe('main chart', () => {
......@@ -157,11 +204,6 @@ describe('Productivity analytics chart getters', () => {
start: 0,
end: intervalEnd,
},
{
type: 'inside',
start: 0,
end: intervalEnd,
},
],
};
......
import createState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
import * as getters from 'ee/analytics/productivity_analytics/store/modules/filters/getters';
import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
describe('Productivity analytics filter getters', () => {
let state;
......@@ -9,42 +10,47 @@ describe('Productivity analytics filter getters', () => {
});
describe('getCommonFilterParams', () => {
it('returns an object with group_id, project_id and all relevant params from the filters string', () => {
beforeEach(() => {
state = {
groupNamespace: 'gitlab-org',
projectPath: 'gitlab-org/gitlab-test',
filters: '?author_username=root&milestone_title=foo&label_name[]=labelxyz',
daysInPast: 30,
};
const mockGetters = { mergedOnAfterDate: '2019-07-16T00:00:00.00Z' };
const expected = {
author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '2019-07-16T00:00:00.00Z',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
const result = getters.getCommonFilterParams(state, mockGetters);
expect(result).toEqual(expected);
});
});
describe('mergedOnAfterDate', () => {
beforeEach(() => {
const mockedTimestamp = 1563235200000; // 2019-07-16T00:00:00.00Z
jest.spyOn(Date.prototype, 'getTime').mockReturnValue(mockedTimestamp);
describe('when chart is not scatterplot', () => {
it('returns an object with common filter params', () => {
const expected = {
author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '30days',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
const result = getters.getCommonFilterParams(state)(chartKeys.main);
expect(result).toEqual(expected);
});
});
it('returns the correct date in the past', () => {
state = {
daysInPast: 90,
};
const mergedOnAfterDate = getters.mergedOnAfterDate(state);
expect(mergedOnAfterDate).toBe('2019-04-17T00:00:00.000Z');
describe('when chart is scatterplot', () => {
it('returns an object with common filter params and adds additional days to the merged_at_after property', () => {
const expected = {
author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '60days',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
const result = getters.getCommonFilterParams(state)(chartKeys.scatterplot);
expect(result).toEqual(expected);
});
});
});
});
......@@ -56,9 +56,12 @@ describe('Productivity analytics table actions', () => {
},
rootGetters: {
// eslint-disable-next-line no-useless-computed-key
['filters/getCommonFilterParams']: {
group_id: groupNamespace,
project_id: projectPath,
['filters/getCommonFilterParams']: () => {
const params = {
group_id: groupNamespace,
project_id: projectPath,
};
return params;
},
},
state: getInitialState(),
......
import {
getLabelsEndpoint,
getMilestonesEndpoint,
getScatterPlotData,
getMedianLineData,
} from 'ee/analytics/productivity_analytics/utils';
import { mockScatterplotData } from './mock_data';
describe('Productivity Analytics utils', () => {
const namespacePath = 'gitlab-org';
const projectWithNamespace = 'gitlab-org/gitlab-test';
......@@ -30,4 +34,43 @@ 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 result = getScatterPlotData(mockScatterplotData, dateInPast);
const expected = [
['2019-08-09T22:00:00.000Z', 44],
['2019-08-10T22:00:00.000Z', 46],
['2019-08-11T22:00:00.000Z', 62],
['2019-08-12T22:00:00.000Z', 60],
['2019-08-13T22:00:00.000Z', 43],
['2019-08-14T22:00:00.000Z', 46],
['2019-08-15T22:00:00.000Z', 56],
['2019-08-16T22:00:00.000Z', 24],
['2019-08-17T22:00:00.000Z', 138],
['2019-08-18T22:00:00.000Z', 139],
];
expect(result).toEqual(expected);
});
});
describe('getMedianLineData', () => {
const daysOffset = 10;
it(`computes the median for every item in the scatterData array for the past ${daysOffset} days`, () => {
const scatterData = [
['2019-08-16T22:00:00.000Z', 24],
['2019-08-17T22:00:00.000Z', 138],
['2019-08-18T22:00:00.000Z', 139],
];
const result = getMedianLineData(mockScatterplotData, scatterData, daysOffset);
const expected = [
['2019-08-16T22:00:00.000Z', 51],
['2019-08-17T22:00:00.000Z', 51],
['2019-08-18T22:00:00.000Z', 56],
];
expect(result).toEqual(expected);
});
});
});
......@@ -4752,6 +4752,9 @@ msgstr ""
msgid "Days"
msgstr ""
msgid "Days to merge"
msgstr ""
msgid "Debug"
msgstr ""
......@@ -8096,9 +8099,6 @@ msgstr ""
msgid "Hook was successfully updated."
msgstr ""
msgid "Hours"
msgstr ""
msgid "Housekeeping"
msgstr ""
......@@ -11599,15 +11599,42 @@ msgstr ""
msgid "Productivity analytics can help identify the problems that are delaying your team"
msgstr ""
msgid "ProductivityAanalytics|Merge requests"
msgstr ""
msgid "ProductivityAnalytics|Ascending"
msgstr ""
msgid "ProductivityAnalytics|Days"
msgstr ""
msgid "ProductivityAnalytics|Days to merge"
msgstr ""
msgid "ProductivityAnalytics|Descending"
msgstr ""
msgid "ProductivityAnalytics|Hours"
msgstr ""
msgid "ProductivityAnalytics|List"
msgstr ""
msgid "ProductivityAnalytics|Merge Requests"
msgstr ""
msgid "ProductivityAnalytics|Merge date"
msgstr ""
msgid "ProductivityAnalytics|Merge requests"
msgstr ""
msgid "ProductivityAnalytics|Time to merge"
msgstr ""
msgid "ProductivityAnalytics|Trendline"
msgstr ""
msgid "Profile"
msgstr ""
......
......@@ -426,3 +426,13 @@ describe('newDate', () => {
expect(initialDate instanceof Date).toBe(true);
});
});
describe('getDateInPast', () => {
it('returns the correct date in the past', () => {
const date = new Date(1563235200000); // 2019-07-16T00:00:00.00Z
const daysInPast = 90;
const dateInPast = datetimeUtility.getDateInPast(date, daysInPast);
expect(dateInPast).toBe('2019-04-17T00:00:00.000Z');
});
});
......@@ -6,6 +6,7 @@ import {
numberToHumanSize,
sum,
isOdd,
median,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => {
......@@ -109,4 +110,16 @@ describe('Number Utils', () => {
expect(isOdd(1)).toEqual(1);
});
});
describe('median', () => {
it('computes the median for a given array with odd length', () => {
const items = [10, 27, 20, 5, 19];
expect(median(items)).toBe(19);
});
it('computes the median for a given array with even length', () => {
const items = [10, 27, 20, 5, 19, 4];
expect(median(items)).toBe(14.5);
});
});
});
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