Commit f61bc8a4 authored by Robert Speicher's avatar Robert Speicher

Merge branch '12079-productivity-analytics-mvp' into 'master'

Resolve "Productivity Analytics MVC"

Closes #12217 and #12079

See merge request gitlab-org/gitlab-ee!14772
parents f0913527 83eacbca
...@@ -8,9 +8,10 @@ import { ...@@ -8,9 +8,10 @@ import {
GlButton, GlButton,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import MergeRequestTable from './mr_table.vue'; import MergeRequestTable from './mr_table.vue';
import { chartKeys } from '../constants'; import { chartKeys, metricTypes } from '../constants';
export default { export default {
components: { components: {
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlColumnChart,
GlButton, GlButton,
Icon, Icon,
MergeRequestTable, MergeRequestTable,
...@@ -49,12 +51,20 @@ export default { ...@@ -49,12 +51,20 @@ export default {
'sortFields', 'sortFields',
'columnMetric', 'columnMetric',
]), ]),
...mapGetters('charts', [
'chartLoading',
'getChartData',
'getColumnChartOption',
'getMetricDropdownLabel',
'isSelectedMetric',
]),
...mapGetters('table', [ ...mapGetters('table', [
'sortFieldDropdownLabel', 'sortFieldDropdownLabel',
'sortIcon', 'sortIcon',
'sortTooltipTitle', 'sortTooltipTitle',
'getColumnOptions', 'getColumnOptions',
'columnMetricLabel', 'columnMetricLabel',
'isSelectedSortField',
]), ]),
}, },
mounted() { mounted() {
...@@ -63,12 +73,20 @@ export default { ...@@ -63,12 +73,20 @@ export default {
methods: { methods: {
...mapActions(['setEndpoint']), ...mapActions(['setEndpoint']),
...mapActions('filters', ['setProjectPath']), ...mapActions('filters', ['setProjectPath']),
...mapActions('charts', ['fetchChartData', 'setMetricType', 'chartItemClicked']),
...mapActions('table', [ ...mapActions('table', [
'setSortField', 'setSortField',
'setMergeRequestsPage', 'setMergeRequestsPage',
'toggleSortOrder', 'toggleSortOrder',
'setColumnMetric', 'setColumnMetric',
]), ]),
onMainChartItemClicked({ params }) {
const itemValue = params.data.value[0];
this.chartItemClicked({ chartKey: this.chartKeys.main, item: itemValue });
},
getMetricTypes(chartKey) {
return metricTypes.filter(m => m.chart === chartKey);
},
}, },
}; };
</script> </script>
...@@ -89,6 +107,119 @@ export default { ...@@ -89,6 +107,119 @@ export default {
" "
/> />
<template v-else> <template v-else>
<h4>{{ __('Merge Requests') }}</h4>
<div class="qa-time-to-merge mb-4">
<h5>{{ __('Time to merge') }}</h5>
<gl-loading-icon v-if="chartLoading(chartKeys.main)" size="md" class="my-4 py-4" />
<template v-else>
<p class="text-muted">
{{ __('You can filter by "days to merge" by clicking on the columns in the chart.') }}
</p>
<gl-column-chart
:data="getChartData(chartKeys.main)"
:option="getColumnChartOption(chartKeys.main)"
:y-axis-title="__('Merge requests')"
:x-axis-title="__('Days')"
x-axis-type="category"
@chartItemClicked="onMainChartItemClicked"
/>
</template>
</div>
<div class="row">
<div class="qa-time-based col-lg-6 col-sm-12 mb-4">
<gl-dropdown
class="mb-4 metric-dropdown"
toggle-class="dropdown-menu-toggle w-100"
menu-class="w-100 mw-100"
:text="getMetricDropdownLabel(chartKeys.timeBasedHistogram)"
>
<gl-dropdown-item
v-for="metric in getMetricTypes(chartKeys.timeBasedHistogram)"
:key="metric.key"
active-class="is-active"
class="w-100"
@click="
setMetricType({ metricType: metric.key, chartKey: chartKeys.timeBasedHistogram })
"
>
<span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedMetric({
metric: metric.key,
chartKey: chartKeys.timeBasedHistogram,
}),
}"
name="mobile-issue-close"
/>
{{ metric.label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<gl-loading-icon
v-if="chartLoading(chartKeys.timeBasedHistogram)"
size="md"
class="my-4 py-4"
/>
<gl-column-chart
v-else
:data="getChartData(chartKeys.timeBasedHistogram)"
:option="getColumnChartOption(chartKeys.timeBasedHistogram)"
:y-axis-title="__('Merge requests')"
:x-axis-title="__('Hours')"
x-axis-type="category"
/>
</div>
<div class="qa-commit-based col-lg-6 col-sm-12 mb-4">
<gl-dropdown
class="mb-4 metric-dropdown"
toggle-class="dropdown-menu-toggle w-100"
menu-class="w-100 mw-100"
:text="getMetricDropdownLabel(chartKeys.commitBasedHistogram)"
>
<gl-dropdown-item
v-for="metric in getMetricTypes(chartKeys.commitBasedHistogram)"
:key="metric.key"
active-class="is-active"
class="w-100"
@click="
setMetricType({ metricType: metric.key, chartKey: chartKeys.commitBasedHistogram })
"
>
<span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedMetric({
metric: metric.key,
chartKey: chartKeys.commitBasedHistogram,
}),
}"
name="mobile-issue-close"
/>
{{ metric.label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<gl-loading-icon
v-if="chartLoading(chartKeys.commitBasedHistogram)"
size="md"
class="my-4 py-4"
/>
<gl-column-chart
v-else
:data="getChartData(chartKeys.commitBasedHistogram)"
:option="getColumnChartOption(chartKeys.commitBasedHistogram)"
:y-axis-title="__('Merge requests')"
:x-axis-title="__('Commits')"
x-axis-type="category"
/>
</div>
</div>
<div <div
class="qa-mr-table-sort d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2" class="qa-mr-table-sort d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2"
> >
...@@ -108,7 +239,16 @@ export default { ...@@ -108,7 +239,16 @@ export default {
class="w-100" class="w-100"
@click="setSortField(key)" @click="setSortField(key)"
> >
{{ value }} <span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedSortField(key),
}"
name="mobile-issue-close"
/>
{{ value }}
</span>
</gl-dropdown-item> </gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
<gl-button v-gl-tooltip.hover :title="sortTooltipTitle" @click="toggleSortOrder"> <gl-button v-gl-tooltip.hover :title="sortTooltipTitle" @click="toggleSortOrder">
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue'; import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import { accessLevelReporter } from '../constants';
export default { export default {
components: { components: {
...@@ -11,6 +12,9 @@ export default { ...@@ -11,6 +12,9 @@ export default {
data() { data() {
return { return {
groupId: null, groupId: null,
groupsQueryParams: {
min_access_level: accessLevelReporter,
},
}; };
}, },
computed: { computed: {
...@@ -36,8 +40,12 @@ export default { ...@@ -36,8 +40,12 @@ export default {
</script> </script>
<template> <template>
<div class="d-flex flex-column flex-md-row"> <div class="dropdown-container d-flex flex-column flex-lg-row">
<groups-dropdown-filter class="group-select" @selected="onGroupSelected" /> <groups-dropdown-filter
class="group-select"
:query-params="groupsQueryParams"
@selected="onGroupSelected"
/>
<projects-dropdown-filter <projects-dropdown-filter
v-if="showProjectsDropdownFilter" v-if="showProjectsDropdownFilter"
:key="groupId" :key="groupId"
......
<script> <script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import MergeRequestTableRow from './mr_table_row.vue'; import MergeRequestTableRow from './mr_table_row.vue';
import Pagination from '~/vue_shared/components/pagination_links.vue'; import Pagination from '~/vue_shared/components/pagination_links.vue';
...@@ -7,6 +8,7 @@ export default { ...@@ -7,6 +8,7 @@ export default {
components: { components: {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
Icon,
MergeRequestTableRow, MergeRequestTableRow,
Pagination, Pagination,
}, },
...@@ -44,6 +46,9 @@ export default { ...@@ -44,6 +46,9 @@ export default {
onPageChange(page) { onPageChange(page) {
this.$emit('pageChange', page); this.$emit('pageChange', page);
}, },
isSelectedMetric(metric) {
return this.metricType === metric;
},
}, },
}; };
</script> </script>
...@@ -73,7 +78,16 @@ export default { ...@@ -73,7 +78,16 @@ export default {
class="w-100" class="w-100"
@click="$emit('columnMetricChange', key)" @click="$emit('columnMetricChange', key)"
> >
{{ value }} <span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedMetric(key),
}"
name="mobile-issue-close"
/>
{{ value }}
</span>
</gl-dropdown-item> </gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
</div> </div>
......
...@@ -17,7 +17,7 @@ export default { ...@@ -17,7 +17,7 @@ export default {
<template> <template>
<div <div
v-if="groupNamespace" v-if="groupNamespace"
class="d-flex flex-column flex-md-row align-items-md-center justify-content-md-end" 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> <label class="mb-0 mr-1">{{ s__('Analytics|Timeframe') }}</label>
<date-range-dropdown :default-selected="daysInPast" @selected="setDaysInPast" /> <date-range-dropdown :default-selected="daysInPast" @selected="setDaysInPast" />
......
...@@ -7,6 +7,11 @@ export const chartKeys = { ...@@ -7,6 +7,11 @@ export const chartKeys = {
scatterplot: 'scatterplot', scatterplot: 'scatterplot',
}; };
export const chartTypes = {
histogram: 'histogram',
scatterplot: 'scatterplot',
};
export const metricTypes = [ export const metricTypes = [
{ {
key: 'time_to_first_comment', key: 'time_to_first_comment',
...@@ -40,6 +45,17 @@ export const metricTypes = [ ...@@ -40,6 +45,17 @@ export const metricTypes = [
}, },
]; ];
export const tableSortFields = metricTypes.reduce(
(acc, curr) => {
const { key, label, chart } = curr;
if (chart === chartKeys.timeBasedHistogram) {
acc[key] = label;
}
return acc;
},
{ days_to_merge: __('Days to merge') },
);
export const tableSortOrder = { export const tableSortOrder = {
asc: { asc: {
title: s__('ProductivityAnalytics|Ascending'), title: s__('ProductivityAnalytics|Ascending'),
...@@ -54,3 +70,28 @@ export const tableSortOrder = { ...@@ -54,3 +70,28 @@ export const tableSortOrder = {
}; };
export const timeToMergeMetric = 'time_to_merge'; export const timeToMergeMetric = 'time_to_merge';
export const defaultMaxColumnChartItemsPerPage = 20;
export const maxColumnChartItemsPerPage = {
[chartKeys.main]: 40,
};
export const dataZoomOptions = [
{
type: 'slider',
bottom: 10,
start: 0,
},
{
type: 'inside',
start: 0,
},
];
/**
* #418cd8 --> $blue-400 (see variables.scss)
*/
export const columnHighlightStyle = { color: '#418cd8', opacity: 0.8 };
export const accessLevelReporter = 20;
...@@ -4,6 +4,7 @@ import state from './state'; ...@@ -4,6 +4,7 @@ import state from './state';
import * as actions from './actions'; import * as actions from './actions';
import mutations from './mutations'; import mutations from './mutations';
import filters from './modules/filters/index'; import filters from './modules/filters/index';
import charts from './modules/charts/index';
import table from './modules/table/index'; import table from './modules/table/index';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -15,6 +16,7 @@ const createStore = () => ...@@ -15,6 +16,7 @@ const createStore = () =>
mutations, mutations,
modules: { modules: {
filters, filters,
charts,
table, table,
}, },
}); });
......
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import { chartKeys } from '../../../constants';
export const fetchAllChartData = ({ commit, state, dispatch }) => {
// let's reset any data on the main chart first
// since any selected items will be used as query params for other charts)
commit(types.RESET_CHART_DATA, chartKeys.main);
Object.keys(state.charts).forEach(chartKey => {
dispatch('fetchChartData', chartKey);
});
};
export const requestChartData = ({ commit }, chartKey) =>
commit(types.REQUEST_CHART_DATA, chartKey);
export const fetchChartData = ({ dispatch, getters, rootState }, chartKey) => {
dispatch('requestChartData', chartKey);
const params = getters.getFilterParams(chartKey);
return axios
.get(rootState.endpoint, { params })
.then(response => {
const { data } = response;
dispatch('receiveChartDataSuccess', { chartKey, data });
})
.catch(() => {
dispatch('receiveChartDataError', chartKey);
});
};
export const receiveChartDataSuccess = ({ commit }, { chartKey, data = {} }) => {
commit(types.RECEIVE_CHART_DATA_SUCCESS, { chartKey, data });
};
export const receiveChartDataError = ({ commit }, chartKey) => {
commit(types.RECEIVE_CHART_DATA_ERROR, chartKey);
};
export const setMetricType = ({ commit, dispatch }, { chartKey, metricType }) => {
commit(types.SET_METRIC_TYPE, { chartKey, metricType });
dispatch('fetchChartData', chartKey);
};
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 table
dispatch('table/fetchMergeRequests', null, { root: true });
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import {
chartKeys,
metricTypes,
columnHighlightStyle,
defaultMaxColumnChartItemsPerPage,
maxColumnChartItemsPerPage,
dataZoomOptions,
} from '../../../constants';
export const chartLoading = state => chartKey => state.charts[chartKey].isLoading;
/**
* Creates a series object for the column chart with the given chartKey.
*
* Takes an object of the form { "1": 10, "2", 20, "3": 30 } (where the key is the x axis value)
* and transforms it into into the following structure:
*
* {
* "full": [
* { value: ['1', 10], itemStyle: {} },
* { value: ['2', 20], itemStyle: {} },
* { value: ['3', 30], itemStyle: {} },
* ]
* }
*
* The first item in each value array is the x axis value, the second item is the y axis value.
* If a value is selected (i.e., set on the state's selected array),
* the itemStyle will be set accordingly in order to highlight the relevant bar.
*
*/
export const getChartData = state => chartKey => {
const dataWithSelected = Object.keys(state.charts[chartKey].data).map(key => {
const dataArr = [key, state.charts[chartKey].data[key]];
let itemStyle = {};
if (state.charts[chartKey].selected.indexOf(key) !== -1) {
itemStyle = columnHighlightStyle;
}
return {
value: dataArr,
itemStyle,
};
});
return {
full: dataWithSelected,
};
};
export const getMetricDropdownLabel = state => chartKey =>
metricTypes.find(m => m.key === state.charts[chartKey].params.metricType).label;
export const getFilterParams = (state, getters, rootState, rootGetters) => chartKey => {
const { params: chartParams = {} } = state.charts[chartKey];
// common filter params
const params = {
...rootGetters['filters/getCommonFilterParams'],
chart_type: chartParams.chartType,
};
// add additional params depending on chart
if (chartKey !== chartKeys.main) {
Object.assign(params, { days_to_merge: state.charts.main.selected });
if (chartParams) {
Object.assign(params, { metric_type: chartParams.metricType });
}
}
return params;
};
/**
* Returns additional options for a given column chart (based on the chartKey)
* Primarily, it computes the end percentage value for echart's dataZoom property
*
* If the number of data items being displayed is below the MAX_ITEMS_PER_PAGE threshold,
* it will return an empty dataZoom property.
*/
export const getColumnChartOption = state => chartKey => {
const { data } = state.charts[chartKey];
const totalItems = Object.keys(data).length;
const MAX_ITEMS_PER_PAGE = maxColumnChartItemsPerPage[chartKey]
? maxColumnChartItemsPerPage[chartKey]
: defaultMaxColumnChartItemsPerPage;
if (totalItems <= MAX_ITEMS_PER_PAGE) {
return {};
}
const intervalEnd = Math.ceil((MAX_ITEMS_PER_PAGE / totalItems) * 100);
return {
dataZoom: dataZoomOptions.map(item => {
const result = {
...item,
end: intervalEnd,
};
return result;
}),
};
};
export const isSelectedMetric = state => ({ metric, chartKey }) =>
state.charts[chartKey].params.metricType === metric;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import state from './state';
import mutations from './mutations';
import * as getters from './getters';
import * as actions from './actions';
export default {
namespaced: true,
state: state(),
mutations,
getters,
actions,
};
export const RESET_CHART_DATA = 'RESET_CHART_DATA';
export const REQUEST_CHART_DATA = 'REQUEST_CHART_DATA';
export const RECEIVE_CHART_DATA_SUCCESS = 'RECEIVE_CHART_DATA_SUCCESS';
export const RECEIVE_CHART_DATA_ERROR = 'RECEIVE_CHART_DATA_ERROR';
export const SET_METRIC_TYPE = 'SET_METRIC_TYPE';
export const UPDATE_SELECTED_CHART_ITEMS = 'UPDATE_SELECTED_CHART_ITEMS';
import * as types from './mutation_types';
export default {
[types.RESET_CHART_DATA](state, chartKey) {
state.charts[chartKey].data = {};
state.charts[chartKey].selected = [];
},
[types.REQUEST_CHART_DATA](state, chartKey) {
state.charts[chartKey].isLoading = true;
},
[types.RECEIVE_CHART_DATA_SUCCESS](state, { chartKey, data }) {
state.charts[chartKey].isLoading = false;
state.charts[chartKey].hasError = false;
state.charts[chartKey].data = data;
},
[types.RECEIVE_CHART_DATA_ERROR](state, chartKey) {
state.charts[chartKey].isLoading = false;
state.charts[chartKey].hasError = true;
state.charts[chartKey].data = {};
},
[types.SET_METRIC_TYPE](state, { chartKey, metricType }) {
state.charts[chartKey].params.metricType = metricType;
},
[types.UPDATE_SELECTED_CHART_ITEMS](state, { chartKey, item }) {
const idx = state.charts[chartKey].selected.indexOf(item);
if (idx === -1) {
state.charts[chartKey].selected.push(item);
} else {
state.charts[chartKey].selected.splice(idx, 1);
}
},
};
import { chartKeys, chartTypes } from '../../../constants';
export default () => ({
charts: {
[chartKeys.main]: {
isLoading: false,
hasError: false,
data: {},
selected: [],
params: {
chartType: chartTypes.histogram,
},
},
[chartKeys.timeBasedHistogram]: {
isLoading: false,
hasError: false,
data: {},
selected: [],
params: {
metricType: 'time_to_first_comment',
chartType: chartTypes.histogram,
},
},
[chartKeys.commitBasedHistogram]: {
isLoading: false,
hasError: false,
data: {},
selected: [],
params: {
metricType: 'commits_count',
chartType: chartTypes.histogram,
},
},
[chartKeys.scatterplot]: {
isLoading: false,
hasError: false,
data: {},
selected: [],
params: {
chartType: chartTypes.scatterplot,
},
},
},
});
...@@ -3,24 +3,28 @@ import * as types from './mutation_types'; ...@@ -3,24 +3,28 @@ import * as types from './mutation_types';
export const setGroupNamespace = ({ commit, dispatch }, groupNamespace) => { export const setGroupNamespace = ({ commit, dispatch }, groupNamespace) => {
commit(types.SET_GROUP_NAMESPACE, groupNamespace); commit(types.SET_GROUP_NAMESPACE, groupNamespace);
dispatch('charts/fetchAllChartData', null, { root: true });
dispatch('table/fetchMergeRequests', null, { root: true }); dispatch('table/fetchMergeRequests', null, { root: true });
}; };
export const setProjectPath = ({ commit, dispatch }, projectPath) => { export const setProjectPath = ({ commit, dispatch }, projectPath) => {
commit(types.SET_PROJECT_PATH, projectPath); commit(types.SET_PROJECT_PATH, projectPath);
dispatch('charts/fetchAllChartData', null, { root: true });
dispatch('table/fetchMergeRequests', null, { root: true }); dispatch('table/fetchMergeRequests', null, { root: true });
}; };
export const setPath = ({ commit, dispatch }, path) => { export const setPath = ({ commit, dispatch }, path) => {
commit(types.SET_PATH, path); commit(types.SET_PATH, path);
dispatch('charts/fetchAllChartData', null, { root: true });
dispatch('table/fetchMergeRequests', null, { root: true }); dispatch('table/fetchMergeRequests', null, { root: true });
}; };
export const setDaysInPast = ({ commit, dispatch }, days) => { export const setDaysInPast = ({ commit, dispatch }, days) => {
commit(types.SET_DAYS_IN_PAST, days); commit(types.SET_DAYS_IN_PAST, days);
dispatch('charts/fetchAllChartData', null, { root: true });
dispatch('table/fetchMergeRequests', null, { root: true }); dispatch('table/fetchMergeRequests', null, { root: true });
}; };
......
...@@ -10,7 +10,7 @@ export const fetchMergeRequests = ({ dispatch, state, rootState, rootGetters }) ...@@ -10,7 +10,7 @@ export const fetchMergeRequests = ({ dispatch, state, rootState, rootGetters })
const params = { const params = {
...rootGetters['filters/getCommonFilterParams'], ...rootGetters['filters/getCommonFilterParams'],
// days_to_merge: rootState.charts.charts.main.selected, days_to_merge: rootState.charts.charts.main.selected,
sort: `${sortField}_${sortOrder}`, sort: `${sortField}_${sortOrder}`,
page: pageInfo ? pageInfo.page : null, page: pageInfo ? pageInfo.page : null,
}; };
......
...@@ -16,5 +16,7 @@ export const getColumnOptions = state => ...@@ -16,5 +16,7 @@ export const getColumnOptions = state =>
export const columnMetricLabel = (state, getters) => getters.getColumnOptions[state.columnMetric]; export const columnMetricLabel = (state, getters) => getters.getColumnOptions[state.columnMetric];
export const isSelectedSortField = state => sortField => state.sortField === sortField;
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
import { __ } from '~/locale'; import { tableSortFields, tableSortOrder } from './../../../constants';
import { chartKeys, tableSortOrder, metricTypes } from './../../../constants';
const sortFields = metricTypes.reduce(
(acc, curr) => {
const { key, label, chart } = curr;
if (chart === chartKeys.timeBasedHistogram) {
acc[key] = label;
}
return acc;
},
{ days_to_merge: __('Days to merge') },
);
export default () => ({ export default () => ({
isLoadingTable: false, isLoadingTable: false,
...@@ -18,7 +6,7 @@ export default () => ({ ...@@ -18,7 +6,7 @@ export default () => ({
mergeRequests: [], mergeRequests: [],
pageInfo: {}, pageInfo: {},
sortOrder: tableSortOrder.asc.value, sortOrder: tableSortOrder.asc.value,
sortFields, sortFields: tableSortFields,
sortField: 'time_to_merge', sortField: 'time_to_merge',
columnMetric: 'time_to_first_comment', columnMetric: 'time_to_first_comment',
}); });
.dropdown-container { .dropdown-container {
flex: 0 0 25%; flex: 0 0 25%;
width: 25%;
@include media-breakpoint-down(md) {
width: 100%;
@include media-breakpoint-down(sm) {
.dropdown { .dropdown {
margin-bottom: $gl-padding-8; margin-bottom: $gl-padding-8;
} }
} }
@include media-breakpoint-up(md) { @include media-breakpoint-up(lg) {
.group-select, .group-select,
.project-select { .project-select {
margin-right: $gl-padding; margin-right: $gl-padding;
flex: 0 1 50%; flex: 1;
width: inherit;
} }
} }
} }
.filter-container { .filter-container {
flex: 1 1 50%; flex: 1 1 50%;
width: 50%;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
width: 100%;
.filtered-search-box { .filtered-search-box {
margin-bottom: 10px; margin-bottom: 10px;
} }
...@@ -31,6 +38,12 @@ ...@@ -31,6 +38,12 @@
} }
} }
.metric-dropdown {
@include media-breakpoint-down(sm) {
width: 100%;
}
}
.mr-table { .mr-table {
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
.gl-responsive-table-row { .gl-responsive-table-row {
...@@ -64,6 +77,7 @@ ...@@ -64,6 +77,7 @@
.metric-col { .metric-col {
flex: 0 0 50%; flex: 0 0 50%;
max-width: 50%;
.time { .time {
font-size: $gl-font-size-large; font-size: $gl-font-size-large;
......
# frozen_string_literal: true # frozen_string_literal: true
class Analytics::ApplicationController < ApplicationController class Analytics::ApplicationController < ApplicationController
include RoutableActions
layout 'analytics' layout 'analytics'
end end
# frozen_string_literal: true # frozen_string_literal: true
class Analytics::ProductivityAnalyticsController < Analytics::ApplicationController class Analytics::ProductivityAnalyticsController < Analytics::ApplicationController
before_action :load_group
before_action :load_project
before_action :check_feature_availability!
before_action :authorize_view_productivity_analytics!
include IssuableCollections
def show
respond_to do |format|
format.html
format.json do
metric = params.fetch('metric_type', ProductivityAnalytics::DEFAULT_TYPE)
data = case params['chart_type']
when 'scatterplot'
productivity_analytics.scatterplot_data(type: metric)
when 'histogram'
productivity_analytics.histogram_data(type: metric)
else
include_relations(paginate(productivity_analytics.merge_requests_extended)).map do |merge_request|
serializer.represent(merge_request, {}, ProductivityAnalyticsMergeRequestEntity)
end
end
render json: data, status: :ok
end
end
end
private
def paginate(merge_requests)
merge_requests.page(params[:page]).per(params[:per_page]).tap do |paginated_data|
response.set_header('X-Per-Page', paginated_data.limit_value.to_s)
response.set_header('X-Page', paginated_data.current_page.to_s)
response.set_header('X-Next-Page', paginated_data.next_page.to_s)
response.set_header('X-Prev-Page', paginated_data.prev_page.to_s)
response.set_header('X-Total', paginated_data.total_count.to_s)
response.set_header('X-Total-Pages', paginated_data.total_pages.to_s)
end
end
def authorize_view_productivity_analytics!
return render_403 unless can?(current_user, :view_productivity_analytics, @group || :global)
end
def check_feature_availability!
return render_404 unless ::License.feature_available?(:productivity_analytics)
return render_404 if @group && !@group.root_ancestor.feature_available?(:productivity_analytics)
end
def load_group
return unless params['group_id']
@group = find_routable!(Group, params['group_id'])
end
def load_project
return unless @group && params['project_id']
@project = find_routable!(@group.projects, params['project_id'])
end
def serializer
@serializer ||= BaseSerializer.new(current_user: current_user)
end
def finder_type
ProductivityAnalyticsFinder
end
def default_state
'merged'
end
def productivity_analytics
@productivity_analytics ||= ProductivityAnalytics.new(merge_requests: finder.execute, sort: params[:sort])
end
# rubocop: disable CodeReuse/ActiveRecord
def include_relations(paginated_mrs)
# Due to Rails bug: https://github.com/rails/rails/issues/34889 we can't use .includes statement
# to avoid N+1 call when we load custom columns.
# So we load relations manually here.
preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(paginated_mrs, { author: [], target_project: { namespace: :route } })
paginated_mrs
end
# rubocop: enable CodeReuse/ActiveRecord
end end
# frozen_string_literal: true
class ProductivityAnalyticsFinder < MergeRequestsFinder
def self.array_params
super.merge(days_to_merge: [])
end
def self.scalar_params
@scalar_params ||= super + [:merged_at_before, :merged_at_after]
end
def filter_items(_items)
items = by_days_to_merge(super)
by_merged_at(items)
end
private
def metrics_table
MergeRequest::Metrics.arel_table.alias(MergeRequest::Metrics.table_name)
end
# rubocop: disable CodeReuse/ActiveRecord
def by_days_to_merge(items)
return items unless params[:days_to_merge].present?
items.joins(:metrics).where("#{days_to_merge_column} IN (?)", params[:days_to_merge].flatten.map(&:to_i))
end
# rubocop: enable CodeReuse/ActiveRecord
def days_to_merge_column
"date_part('day',merge_request_metrics.merged_at - merge_requests.created_at)"
end
# rubocop: disable CodeReuse/ActiveRecord
def by_merged_at(items)
return items unless params[:merged_at_after] || params[:merged_at_before]
items = items.joins(:metrics)
items = items.where(metrics_table[:merged_at].gteq(merged_at_between[:from])) if merged_at_between[:from]
items = items.where(metrics_table[:merged_at].lteq(merged_at_between[:to])) if merged_at_between[:to]
items
end
# rubocop: enable CodeReuse/ActiveRecord
def merged_at_between
@merged_at_between ||= begin
if merged_at_period
{ from: Time.zone.now.ago(merged_at_period.days) }
else
{ from: params[:merged_at_after], to: params[:merged_at_before] }
end
end
end
def merged_at_period
matches = params[:merged_at_after]&.match(/^(?<days>\d+)days?$/)
matches && matches[:days].to_i
end
end
...@@ -83,6 +83,7 @@ class License < ApplicationRecord ...@@ -83,6 +83,7 @@ class License < ApplicationRecord
object_storage object_storage
operations_dashboard operations_dashboard
packages packages
productivity_analytics
project_aliases project_aliases
protected_environments protected_environments
reject_unsigned_commits reject_unsigned_commits
......
# frozen_string_literal: true
class ProductivityAnalytics
attr_reader :merge_requests, :sort
METRIC_COLUMNS = {
'days_to_merge' => "DATE_PART('day', merge_request_metrics.merged_at - merge_requests.created_at)",
'time_to_first_comment' => "DATE_PART('day', merge_request_metrics.first_comment_at - merge_requests.created_at)*24+DATE_PART('hour', merge_request_metrics.first_comment_at - merge_requests.created_at)",
'time_to_last_commit' => "DATE_PART('day', merge_request_metrics.last_commit_at - merge_request_metrics.first_comment_at)*24+DATE_PART('hour', merge_request_metrics.last_commit_at - merge_request_metrics.first_comment_at)",
'time_to_merge' => "DATE_PART('day', merge_request_metrics.merged_at - merge_request_metrics.last_commit_at)*24+DATE_PART('hour', merge_request_metrics.merged_at - merge_request_metrics.last_commit_at)",
'commits_count' => 'commits_count',
'loc_per_commit' => '(diff_size/commits_count)',
'files_touched' => 'modified_paths_size'
}.freeze
METRIC_TYPES = METRIC_COLUMNS.keys.freeze
DEFAULT_TYPE = 'days_to_merge'.freeze
def initialize(merge_requests:, sort: nil)
@merge_requests = merge_requests.joins(:metrics)
@sort = sort
end
def histogram_data(type:)
return unless column = METRIC_COLUMNS[type]
histogram_query(column).map do |data|
[data[:metric]&.to_i, data[:mr_count]]
end.to_h
end
def scatterplot_data(type:)
return unless column = METRIC_COLUMNS[type]
scatterplot_query(column).map do |data|
[data.id, { metric: data[:metric], merged_at: data[:merged_at] }]
end.to_h
end
def merge_requests_extended
columns = METRIC_COLUMNS.map do |type, column|
Arel::Nodes::As.new(Arel.sql(column), Arel.sql(type)).to_sql
end
columns.unshift(MergeRequest.arel_table[Arel.star])
mrs = merge_requests.select(columns)
mrs = mrs.reorder(custom_sorting) if custom_sorting
mrs
end
private
def histogram_query(column)
merge_requests.except(:select).select("#{column} as metric, count(*) as mr_count").group(column).reorder(nil)
end
def scatterplot_query(column)
merge_requests.except(:select).select("#{column} as metric, merge_requests.id, merge_request_metrics.merged_at").reorder("merge_request_metrics.merged_at ASC")
end
def custom_sorting
return unless sort
column, direction = sort.split(/_(asc|desc)$/i)
return unless column.in?(METRIC_TYPES)
Arel.sql("#{column} #{direction}")
end
end
...@@ -16,6 +16,8 @@ module EE ...@@ -16,6 +16,8 @@ module EE
end end
rule { support_bot }.prevent :use_quick_actions rule { support_bot }.prevent :use_quick_actions
rule { ~anonymous }.enable :view_productivity_analytics
end end
end end
end end
...@@ -131,6 +131,8 @@ module EE ...@@ -131,6 +131,8 @@ module EE
rule { ip_enforcement_prevents_access & ~owner }.policy do rule { ip_enforcement_prevents_access & ~owner }.policy do
prevent :read_group prevent :read_group
end end
rule { reporter }.enable :view_productivity_analytics
end end
override :lookup_access_level! override :lookup_access_level!
......
# frozen_string_literal: true
class ProductivityAnalyticsMergeRequestEntity < IssuableEntity
ProductivityAnalytics::METRIC_TYPES.each do |type|
expose(type) { |mr| mr.attributes[type] }
end
expose :author_avatar_url do |merge_request|
merge_request.author&.avatar_url
end
expose :merge_request_url do |merge_request|
project_merge_request_url(merge_request.target_project, merge_request)
end
end
- page_title _('Productivity Analytics') - page_title _('Productivity Analytics')
#js-productivity-analytics #js-productivity-analytics
.row-content-block.second-block.d-flex.flex-column.flex-md-row .row-content-block.second-block.d-flex.flex-column.flex-lg-row
.dropdown-container .js-group-project-select-container
.js-group-project-select-container
.js-search-bar.filter-container.hide .js-search-bar.filter-container.hide
= render 'shared/issuable/search_bar', type: :productivity_analytics = render 'shared/issuable/search_bar', type: :productivity_analytics
.dropdown-container .js-timeframe-container
.js-timeframe-container .js-productivity-analytics-app-container{ data: { endpoint: analytics_productivity_analytics_path, empty_state_svg_path: image_path('illustrations/productivity-analytics-empty-state.svg') } }
.js-productivity-analytics-app-container{ data: { endpoint: analytics_productivity_analytics_path, empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg') } }
...@@ -6,28 +6,30 @@ ...@@ -6,28 +6,30 @@
= sprite_icon('log', size: 24) = sprite_icon('log', size: 24)
.sidebar-context-title= _('Analytics') .sidebar-context-title= _('Analytics')
%ul.sidebar-top-level-items %ul.sidebar-top-level-items
= nav_link(controller: :productivity_analytics) do - if Feature.enabled?(:productivity_analytics)
= link_to analytics_productivity_analytics_path, class: 'qa-sidebar-productivity-analytics' do = nav_link(controller: :productivity_analytics) do
.nav-icon-container = link_to analytics_productivity_analytics_path, class: 'qa-sidebar-productivity-analytics' do
= sprite_icon('comment') .nav-icon-container
%span.nav-item-name = sprite_icon('comment')
= _('Productivity Analytics') %span.nav-item-name
%ul.sidebar-sub-level-items.is-fly-out-only = _('Productivity Analytics')
= nav_link(controller: :productivity_analytics, html_options: { class: "fly-out-top-item qa-sidebar-productivity-analytics-fly-out" } ) do %ul.sidebar-sub-level-items.is-fly-out-only
= link_to analytics_productivity_analytics_path do = nav_link(controller: :productivity_analytics, html_options: { class: "fly-out-top-item qa-sidebar-productivity-analytics-fly-out" } ) do
%strong.fly-out-top-item-name = link_to analytics_productivity_analytics_path do
= _('Productivity Analytics') %strong.fly-out-top-item-name
= _('Productivity Analytics')
= nav_link(controller: :cycle_analytics) do - if Feature.enabled?(:cycle_analytics)
= link_to analytics_cycle_analytics_path, class: 'qa-sidebar-cycle-analytics' do = nav_link(controller: :cycle_analytics) do
.nav-icon-container = link_to analytics_cycle_analytics_path, class: 'qa-sidebar-cycle-analytics' do
= sprite_icon('repeat') .nav-icon-container
%span.nav-item-name = sprite_icon('repeat')
= _('Cycle Analytics') %span.nav-item-name
%ul.sidebar-sub-level-items.is-fly-out-only = _('Cycle Analytics')
= nav_link(controller: :cycle_analytics, html_options: { class: "fly-out-top-item qa-sidebar-cycle-analytics-fly-out" } ) do %ul.sidebar-sub-level-items.is-fly-out-only
= link_to analytics_cycle_analytics_path do = nav_link(controller: :cycle_analytics, html_options: { class: "fly-out-top-item qa-sidebar-cycle-analytics-fly-out" } ) do
%strong.fly-out-top-item-name = link_to analytics_cycle_analytics_path do
= _('Cycle Analytics') %strong.fly-out-top-item-name
= _('Cycle Analytics')
= render 'shared/sidebar_toggle_button' = render 'shared/sidebar_toggle_button'
---
title: add Productivity Analytics page with basic charts
merge_request: 14772
author:
type: added
# frozen_string_literal: true # frozen_string_literal: true
namespace :analytics do namespace :analytics do
root to: redirect('-/analytics/productivity_analytics') constraints(::Constraints::FeatureConstrainer.new(:productivity_analytics)) do
root to: redirect('-/analytics/productivity_analytics')
resource :productivity_analytics, only: :show resource :productivity_analytics, only: :show
resource :cycle_analytics, only: :show end
constraints(::Constraints::FeatureConstrainer.new(:cycle_analytics)) do
resource :cycle_analytics, only: :show
end
end end
# frozen_string_literal: true
require './spec/support/sidekiq'
class Gitlab::Seeder::ProductivityAnalytics
def initialize(project)
@project = project
@user = User.admins.first
@issue_count = 100
end
def seed!
Sidekiq::Worker.skipping_transaction_check do
Sidekiq::Testing.inline! do
Timecop.travel 90.days.ago
issues = create_issues
print '.'
Timecop.travel 10.days.from_now
add_milestones_and_list_labels(issues)
print '.'
Timecop.travel 10.days.from_now
branches = mention_in_commits(issues)
print '.'
Timecop.travel 10.days.from_now
merge_requests = create_merge_requests_closing_issues(issues, branches)
print '.'
Timecop.travel 10.days.from_now
create_notes(merge_requests)
Timecop.travel 10.days.from_now
merge_merge_requests(merge_requests)
print '.'
end
end
print '.'
end
private
def create_issues
Array.new(@issue_count) do
issue_params = {
title: "Productivity Analytics: #{FFaker::Lorem.sentence(6)}",
description: FFaker::Lorem.sentence,
state: 'opened',
assignees: [@project.team.users.sample]
}
Timecop.travel rand(10).days.from_now do
Issues::CreateService.new(@project, @project.team.users.sample, issue_params).execute
end
end
end
def add_milestones_and_list_labels(issues)
issues.shuffle.map.with_index do |issue, index|
Timecop.travel 12.hours.from_now do
if index.even?
issue.update(milestone: @project.milestones.sample)
else
label_name = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}"
list_label = FactoryBot.create(:label, title: label_name, project: issue.project)
FactoryBot.create(:list, board: FactoryBot.create(:board, project: issue.project), label: list_label)
issue.update(labels: [list_label])
end
issue
end
end
end
def mention_in_commits(issues)
issues.map do |issue|
branch_name = filename = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}"
Timecop.travel 12.hours.from_now do
issue.project.repository.add_branch(@user, branch_name, 'master')
commit_sha = issue.project.repository.create_file(@user, filename, "content", message: "Commit for #{issue.to_reference}", branch_name: branch_name)
issue.project.repository.commit(commit_sha)
::Git::BranchPushService.new(
issue.project,
@user,
oldrev: issue.project.repository.commit("master").sha,
newrev: commit_sha,
ref: 'refs/heads/master'
).execute
end
branch_name
end
end
def create_merge_requests_closing_issues(issues, branches)
issues.zip(branches).map do |issue, branch|
opts = {
title: 'Productivity Analytics merge_request',
description: "Fixes #{issue.to_reference}",
source_branch: branch,
target_branch: 'master'
}
Timecop.travel issue.created_at do
MergeRequests::CreateService.new(issue.project, @user, opts).execute
end
end
end
def create_notes(merge_requests)
merge_requests.each do |merge_request|
Timecop.travel merge_request.created_at + rand(5).days do
Note.create!(
author: @user,
project: merge_request.project,
noteable: merge_request,
note: FFaker::Lorem.sentence(rand(5))
)
end
end
end
def merge_merge_requests(merge_requests)
merge_requests.each do |merge_request|
Timecop.travel rand(15).days.from_now do
MergeRequests::MergeService.new(merge_request.project, @user).execute(merge_request)
end
end
end
end
Gitlab::Seeder.quiet do
flag = 'SEED_PRODUCTIVITY_ANALYTICS'
if ENV[flag]
Project.find_each do |project|
# This seed naively assumes that every project has a repository, and every
# repository has a `master` branch, which may be the case for a pristine
# GDK seed, but is almost never true for a GDK that's actually had
# development performed on it.
next unless project.repository_exists? && project.repository.commit('master')
seeder = Gitlab::Seeder::ProductivityAnalytics.new(project)
seeder.seed!
puts "Productivity analytics seeded for project #{project.full_path}"
break
end
else
puts "Skipped. Use the `#{flag}` environment variable to enable."
end
end
...@@ -3,17 +3,125 @@ ...@@ -3,17 +3,125 @@
require 'spec_helper' require 'spec_helper'
describe Analytics::ProductivityAnalyticsController do describe Analytics::ProductivityAnalyticsController do
let(:user) { create(:user) } let(:current_user) { create(:user) }
before do before do
sign_in(user) sign_in(current_user) if current_user
stub_licensed_features(productivity_analytics: true)
end end
describe 'GET show' do describe 'GET show' do
it 'renders `show` template' do subject { get :show }
get :show
it 'checks for premium license' do
stub_licensed_features(productivity_analytics: false)
subject
expect(response.code).to eq '404'
end
it 'authorizes for ability to view analytics' do
expect(Ability).to receive(:allowed?).with(current_user, :view_productivity_analytics, :global).and_return(false)
subject
expect(response.code).to eq '403'
end
it 'renders show template' do
subject
expect(response).to render_template :show expect(response).to render_template :show
end end
end end
describe 'GET show.json' do
subject { get :show, format: :json, params: params }
let(:params) { {} }
let(:analytics_mock) { instance_double('ProductivityAnalytics') }
before do
merge_requests = double
allow_any_instance_of(ProductivityAnalyticsFinder).to receive(:execute).and_return(merge_requests)
allow(ProductivityAnalytics)
.to receive(:new)
.with(merge_requests: merge_requests, sort: params[:sort])
.and_return(analytics_mock)
end
context 'with non-existing group_id' do
let(:params) { { group_id: 'SOMETHING_THAT_DOES_NOT_EXIST' } }
it 'renders 404' do
subject
expect(response.code).to eq '404'
end
end
context 'with non-existing project_id' do
let(:group) { create :group }
let(:params) { { group_id: group.full_path, project_id: 'SOMETHING_THAT_DOES_NOT_EXIST' } }
it 'renders 404' do
subject
expect(response.code).to eq '404'
end
end
context 'for list of MRs' do
let!(:merge_request ) { create :merge_request, :merged}
let(:serializer_mock) { instance_double('BaseSerializer') }
before do
allow(BaseSerializer).to receive(:new).with(current_user: current_user).and_return(serializer_mock)
allow(analytics_mock).to receive(:merge_requests_extended).and_return(MergeRequest.all)
allow(serializer_mock).to receive(:represent)
.with(merge_request, {}, ProductivityAnalyticsMergeRequestEntity)
.and_return('mr_representation')
end
it 'serializes whatever analytics returns with ProductivityAnalyticsMergeRequestEntity' do
subject
expect(response.body).to eq '["mr_representation"]'
end
it 'sets pagination headers' do
subject
expect(response.headers['X-Per-Page']).to eq '20'
expect(response.headers['X-Page']).to eq '1'
expect(response.headers['X-Next-Page']).to eq ''
expect(response.headers['X-Prev-Page']).to eq ''
expect(response.headers['X-Total']).to eq '1'
expect(response.headers['X-Total-Pages']).to eq '1'
end
end
context 'for scatterplot charts' do
let(:params) { { chart_type: 'scatterplot', metric_type: 'commits_count' } }
it 'renders whatever analytics returns for scatterplot' do
allow(analytics_mock).to receive(:scatterplot_data).with(type: 'commits_count').and_return('scatterplot_data')
subject
expect(response.body).to eq 'scatterplot_data'
end
end
context 'for histogram charts' do
let(:params) { { chart_type: 'histogram', metric_type: 'commits_count' } }
it 'renders whatever analytics returns for histogram' do
allow(analytics_mock).to receive(:histogram_data).with(type: 'commits_count').and_return('histogram_data')
subject
expect(response.body).to eq 'histogram_data'
end
end
end
end end
...@@ -30,6 +30,19 @@ FactoryBot.modify do ...@@ -30,6 +30,19 @@ FactoryBot.modify do
merge_user { author } merge_user { author }
end end
trait :with_productivity_metrics do
transient do
metrics_data {}
end
after :build do |mr, evaluator|
next if evaluator.metrics_data.empty?
mr.build_metrics unless mr.metrics
mr.metrics.assign_attributes evaluator.metrics_data
end
end
transient do transient do
approval_groups [] approval_groups []
approval_users [] approval_users []
......
# frozen_string_literal: true
require 'spec_helper'
describe ProductivityAnalyticsFinder do
subject { described_class.new(current_user, search_params.merge(state: :merged)) }
let(:current_user) { create(:admin) }
let(:search_params) { {} }
describe '.array_params' do
subject { described_class.array_params }
it { is_expected.to include(:days_to_merge) }
end
describe '.scalar_params' do
subject { described_class.scalar_params }
it { is_expected.to include(:merged_at_before, :merged_at_after) }
end
describe '#execute' do
let(:long_mr) do
metrics_data = { merged_at: 1.day.ago }
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
let(:short_mr) do
metrics_data = { merged_at: 28.days.ago }
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
context 'allows to filter by days_to_merge' do
let(:search_params) { { days_to_merge: [30] } }
it 'returns all MRs with merged_at - created_at IN specified values' do
Timecop.freeze do
long_mr
short_mr
expect(subject.execute).to match_array([long_mr])
end
end
end
context 'allows to filter by merged_at' do
around do |example|
Timecop.freeze { example.run }
end
context 'with merged_at_after specified as timestamp' do
let(:search_params) do
{
merged_at_after: 25.days.ago.to_s
}
end
it 'returns all MRs with merged date later than specified timestamp' do
long_mr
short_mr
expect(subject.execute).to match_array([long_mr])
end
end
context 'with merged_at_after specified as days-range' do
let(:search_params) do
{
merged_at_after: '11days'
}
end
it 'returns all MRs with merged date later than Xdays ago' do
long_mr
short_mr
expect(subject.execute).to match_array([long_mr])
end
end
context 'with merged_at_after and merged_at_before specified' do
let(:search_params) do
{
merged_at_after: 30.days.ago.to_s,
merged_at_before: 20.days.ago.to_s
}
end
it 'returns all MRs with merged date later than specified timestamp' do
long_mr
short_mr
expect(subject.execute).to match_array([short_mr])
end
end
end
end
end
...@@ -46,25 +46,55 @@ exports[`MergeRequestTable component matches the snapshot 1`] = ` ...@@ -46,25 +46,55 @@ exports[`MergeRequestTable component matches the snapshot 1`] = `
active-class="is-active" active-class="is-active"
class="w-100" class="w-100"
> >
<span
class="d-flex"
>
<icon-stub
class="flex-shrink-0 append-right-4 invisible"
cssclasses=""
name="mobile-issue-close"
size="16"
/>
Time from first commit until first comment
Time from first commit until first comment </span>
</gldropdownitem-stub> </gldropdownitem-stub>
<gldropdownitem-stub <gldropdownitem-stub
active-class="is-active" active-class="is-active"
class="w-100" class="w-100"
> >
<span
class="d-flex"
>
<icon-stub
class="flex-shrink-0 append-right-4"
cssclasses=""
name="mobile-issue-close"
size="16"
/>
Time from first comment to last commit
Time from first comment to last commit </span>
</gldropdownitem-stub> </gldropdownitem-stub>
<gldropdownitem-stub <gldropdownitem-stub
active-class="is-active" active-class="is-active"
class="w-100" class="w-100"
> >
<span
class="d-flex"
>
<icon-stub
class="flex-shrink-0 append-right-4 invisible"
cssclasses=""
name="mobile-issue-close"
size="16"
/>
Time from last commit to merge
Time from last commit to merge </span>
</gldropdownitem-stub> </gldropdownitem-stub>
</gldropdown-stub> </gldropdown-stub>
</div> </div>
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import ProductivityApp from 'ee/analytics/productivity_analytics/components/app.vue';
import MergeRequestTable from 'ee/analytics/productivity_analytics/components/mr_table.vue';
import store from 'ee/analytics/productivity_analytics/store';
import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
import { TEST_HOST } from 'helpers/test_constants';
import { GlEmptyState, GlLoadingIcon, GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import resetStore from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ProductivityApp component', () => {
let wrapper;
const propsData = {
endpoint: TEST_HOST,
emptyStateSvgPath: TEST_HOST,
};
const actionSpies = {
setMetricType: jest.fn(),
setSortField: jest.fn(),
setMergeRequestsPage: jest.fn(),
toggleSortOrder: jest.fn(),
setColumnMetric: jest.fn(),
};
const onMainChartItemClickedMock = jest.fn();
beforeEach(() => {
wrapper = shallowMount(localVue.extend(ProductivityApp), {
localVue,
store,
sync: false,
propsData,
methods: {
onMainChartItemClicked: onMainChartItemClickedMock,
...actionSpies,
},
});
});
afterEach(() => {
wrapper.destroy();
resetStore(store);
});
const findTimeToMergeSection = () => wrapper.find('.qa-time-to-merge');
const findMrTableSortSection = () => wrapper.find('.qa-mr-table-sort');
const findMrTableSection = () => wrapper.find('.qa-mr-table');
const findMrTable = () => findMrTableSection().find(MergeRequestTable);
const findSortFieldDropdown = () => findMrTableSortSection().find(GlDropdown);
const findSortOrderToggle = () => findMrTableSortSection().find(GlButton);
const findTimeBasedSection = () => wrapper.find('.qa-time-based');
const findCommitBasedSection = () => wrapper.find('.qa-commit-based');
describe('template', () => {
describe('without a group being selected', () => {
it('renders the empty state illustration', () => {
const emptyState = wrapper.find(GlEmptyState);
expect(emptyState.exists()).toBe(true);
expect(emptyState.props('svgPath')).toBe(propsData.emptyStateSvgPath);
});
});
describe('with a group being selected', () => {
beforeEach(() => {
store.state.filters.groupNamespace = 'gitlab-org';
});
describe('Time to merge chart', () => {
it('renders the title', () => {
expect(findTimeToMergeSection().text()).toContain('Time to merge');
});
describe('when chart is loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].isLoading = true;
});
it('renders a loading indicator', () => {
expect(
findTimeToMergeSection()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
});
});
describe('when chart finished loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].isLoading = false;
});
it('renders a column chart', () => {
expect(
findTimeToMergeSection()
.find(GlColumnChart)
.exists(),
).toBe(true);
});
it('calls onMainChartItemClicked when chartItemClicked is emitted on the column chart ', () => {
const data = {
chart: null,
params: {
data: {
value: [0, 1],
},
},
};
findTimeToMergeSection()
.find(GlColumnChart)
.vm.$emit('chartItemClicked', data);
expect(onMainChartItemClickedMock).toHaveBeenCalledWith(data);
});
});
});
describe('Time based histogram', () => {
describe('when chart is loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.timeBasedHistogram].isLoading = true;
});
it('renders a loading indicator', () => {
expect(
findTimeBasedSection()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
});
});
describe('when chart finished loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.timeBasedHistogram].isLoading = false;
});
it('renders a metric type dropdown', () => {
expect(
findTimeBasedSection()
.find(GlDropdown)
.exists(),
).toBe(true);
});
it('should change the metric type', () => {
findTimeBasedSection()
.findAll(GlDropdownItem)
.at(0)
.vm.$emit('click');
expect(actionSpies.setMetricType).toHaveBeenCalledWith({
metricType: 'time_to_first_comment',
chartKey: chartKeys.timeBasedHistogram,
});
});
it('renders a column chart', () => {
expect(
findTimeBasedSection()
.find(GlColumnChart)
.exists(),
).toBe(true);
});
});
});
describe('Commit based histogram', () => {
describe('when chart is loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.commitBasedHistogram].isLoading = true;
});
it('renders a loading indicator', () => {
expect(
findCommitBasedSection()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
});
});
describe('when chart finished loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.commitBasedHistogram].isLoading = false;
});
it('renders a metric type dropdown', () => {
expect(
findCommitBasedSection()
.find(GlDropdown)
.exists(),
).toBe(true);
});
it('should change the metric type', () => {
findCommitBasedSection()
.findAll(GlDropdownItem)
.at(0)
.vm.$emit('click');
expect(actionSpies.setMetricType).toHaveBeenCalledWith({
metricType: 'commits_count',
chartKey: chartKeys.commitBasedHistogram,
});
});
it('renders a column chart', () => {
expect(
findCommitBasedSection()
.find(GlColumnChart)
.exists(),
).toBe(true);
});
});
});
describe('MR table', () => {
describe('when isLoadingTable is true', () => {
beforeEach(() => {
store.state.table.isLoadingTable = true;
});
it('renders a loading indicator', () => {
expect(
findMrTableSection()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
});
});
describe('when isLoadingTable is false', () => {
beforeEach(() => {
store.state.table.isLoadingTable = false;
});
it('renders the MR table', () => {
expect(findMrTable().exists()).toBe(true);
});
it('should change the column metric', () => {
findMrTable().vm.$emit('columnMetricChange', 'time_to_first_comment');
expect(actionSpies.setColumnMetric).toHaveBeenCalledWith('time_to_first_comment');
});
it('should change the page', () => {
const page = 2;
findMrTable().vm.$emit('pageChange', page);
expect(actionSpies.setMergeRequestsPage).toHaveBeenCalledWith(page);
});
describe('and there are merge requests available', () => {
beforeEach(() => {
store.state.table.mergeRequests = [{ id: 1 }];
});
describe('sort controls', () => {
it('renders the sort dropdown and button', () => {
expect(findSortFieldDropdown().exists()).toBe(true);
expect(findSortOrderToggle().exists()).toBe(true);
});
it('should change the sort field', () => {
findSortFieldDropdown()
.findAll(GlDropdownItem)
.at(0)
.vm.$emit('click');
expect(actionSpies.setSortField).toHaveBeenCalled();
});
it('should toggle the sort order', () => {
findSortOrderToggle().vm.$emit('click');
expect(actionSpies.toggleSortOrder).toHaveBeenCalled();
});
});
});
});
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import MergeRequestTableRow from 'ee/analytics/productivity_analytics/components/mr_table_row.vue'; import MergeRequestTableRow from 'ee/analytics/productivity_analytics/components/mr_table_row.vue';
import { GlAvatar } from '@gitlab/ui'; import { GlAvatar } from '@gitlab/ui';
import mockMergeRequests from './../mock_data'; import { mockMergeRequests } from '../mock_data';
describe('MergeRequestTableRow component', () => { describe('MergeRequestTableRow component', () => {
let wrapper; let wrapper;
......
...@@ -2,7 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import MergeRequestTable from 'ee/analytics/productivity_analytics/components/mr_table.vue'; import MergeRequestTable from 'ee/analytics/productivity_analytics/components/mr_table.vue';
import MergeRequestTableRow from 'ee/analytics/productivity_analytics/components/mr_table_row.vue'; import MergeRequestTableRow from 'ee/analytics/productivity_analytics/components/mr_table_row.vue';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import mockMergeRequests from './../mock_data'; import { mockMergeRequests } from '../mock_data';
describe('MergeRequestTable component', () => { describe('MergeRequestTable component', () => {
let wrapper; let wrapper;
......
import state from 'ee/analytics/productivity_analytics/store/state';
import filterState from 'ee/analytics/productivity_analytics/store/modules/filters/state'; import filterState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
import chartState from 'ee/analytics/productivity_analytics/store/modules/charts/state';
import tableState from 'ee/analytics/productivity_analytics/store/modules/table/state';
const resetStore = store => { const resetStore = store => {
const newState = { const newState = {
...state(),
filters: filterState(), filters: filterState(),
charts: chartState(),
table: tableState(),
}; };
store.replaceState(newState); store.replaceState(newState);
......
const mockMergeRequests = [ export const mockMergeRequests = [
{ {
id: 34, id: 34,
iid: 10, iid: 10,
...@@ -32,4 +32,46 @@ const mockMergeRequests = [ ...@@ -32,4 +32,46 @@ const mockMergeRequests = [
}, },
]; ];
export default mockMergeRequests; export const mockHistogramData = {
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
'10': 10,
'11': 11,
'12': 12,
'13': 13,
'14': 14,
'15': 15,
'16': 16,
'17': 17,
'18': 18,
'19': 19,
'20': 20,
'21': 21,
'22': 22,
'23': 23,
'24': 24,
'25': 25,
'26': 26,
'27': 27,
'28': 28,
'29': 29,
'30': 30,
'31': 31,
'32': 32,
'33': 33,
'34': 34,
'35': 35,
'36': 36,
'37': 37,
'38': 38,
'39': 39,
'40': 40,
'41': 41,
};
import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/productivity_analytics/store/actions';
import SET_ENDPOINT from 'ee/analytics/productivity_analytics/store/mutation_types';
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
describe('Productivity analytics actions', () => {
describe('setEndpoint', () => {
it('commits the SET_ENDPOINT mutation', done =>
testAction(
actions.setEndpoint,
'endpoint.json',
getInitialState(),
[
{
type: SET_ENDPOINT,
payload: 'endpoint.json',
},
],
[],
done,
));
});
});
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/analytics/productivity_analytics/store/modules/charts/actions';
import * as types from 'ee/analytics/productivity_analytics/store/modules/charts/mutation_types';
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/charts/state';
import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
import { mockHistogramData } from '../../../mock_data';
describe('Productivity analytics chart actions', () => {
let mockedContext;
let mockedState;
let mock;
const chartKey = 'main';
const globalParams = {
group_id: 'gitlab-org',
project_id: 'gitlab-test',
};
beforeEach(() => {
mockedContext = {
dispatch() {},
rootState: {
endpoint: `${TEST_HOST}/analytics/productivity_analytics.json`,
},
getters: {
getFilterParams: () => globalParams,
},
state: getInitialState(),
};
// testAction looks for rootGetters in state,
// so they need to be concatenated here.
mockedState = {
...mockedContext.state,
...mockedContext.getters,
...mockedContext.rootState,
};
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('fetchChartData', () => {
describe('success', () => {
beforeEach(() => {
mock.onGet(mockedState.endpoint).replyOnce(200, mockHistogramData);
});
it('calls API with params', () => {
jest.spyOn(axios, 'get');
actions.fetchChartData(mockedContext, chartKey);
expect(axios.get).toHaveBeenCalledWith(mockedState.endpoint, { params: globalParams });
});
it('dispatches success with received data', done =>
testAction(
actions.fetchChartData,
chartKey,
mockedState,
[],
[
{ type: 'requestChartData', payload: chartKey },
{
type: 'receiveChartDataSuccess',
payload: expect.objectContaining({ chartKey, data: mockHistogramData }),
},
],
done,
));
});
describe('error', () => {
beforeEach(() => {
mock.onGet(mockedState.endpoint).replyOnce(500, chartKey);
});
it('dispatches error', done => {
testAction(
actions.fetchChartData,
chartKey,
mockedState,
[],
[
{
type: 'requestChartData',
payload: chartKey,
},
{
type: 'receiveChartDataError',
payload: chartKey,
},
],
done,
);
});
});
});
describe('requestChartData', () => {
it('should commit the request mutation', done => {
testAction(
actions.requestChartData,
chartKey,
mockedContext.state,
[{ type: types.REQUEST_CHART_DATA, payload: chartKey }],
[],
done,
);
});
});
describe('receiveChartDataSuccess', () => {
it('should commit received data', done => {
testAction(
actions.receiveChartDataSuccess,
{ chartKey, data: mockHistogramData },
mockedContext.state,
[
{
type: types.RECEIVE_CHART_DATA_SUCCESS,
payload: { chartKey, data: mockHistogramData },
},
],
[],
done,
);
});
});
describe('receiveChartDataError', () => {
it('should commit error', done => {
testAction(
actions.receiveChartDataError,
chartKey,
mockedContext.state,
[
{
type: types.RECEIVE_CHART_DATA_ERROR,
payload: chartKey,
},
],
[],
done,
);
});
});
describe('fetchAllChartData', () => {
it('commits reset for the main chart and dispatches fetchChartData for all chart types', done => {
testAction(
actions.fetchAllChartData,
null,
mockedContext.state,
[{ type: types.RESET_CHART_DATA, payload: chartKeys.main }],
[
{ type: 'fetchChartData', payload: chartKeys.main },
{ type: 'fetchChartData', payload: chartKeys.timeBasedHistogram },
{ type: 'fetchChartData', payload: chartKeys.commitBasedHistogram },
{ type: 'fetchChartData', payload: chartKeys.scatterplot },
],
done,
);
});
});
describe('setMetricType', () => {
const metricType = 'time_to_merge';
it('should commit metricType', done => {
testAction(
actions.setMetricType,
{ chartKey, metricType },
mockedContext.state,
[{ type: types.SET_METRIC_TYPE, payload: { chartKey, metricType } }],
[{ type: 'fetchChartData', payload: chartKey }],
done,
);
});
});
describe('chartItemClicked', () => {
const item = 5;
it('should commit selected chart item', done => {
testAction(
actions.chartItemClicked,
{ 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 },
],
done,
);
});
});
});
import createState from 'ee/analytics/productivity_analytics/store/modules/charts/state';
import * as getters from 'ee/analytics/productivity_analytics/store/modules/charts/getters';
import {
chartKeys,
columnHighlightStyle,
maxColumnChartItemsPerPage,
} from 'ee/analytics/productivity_analytics/constants';
import { mockHistogramData } from '../../../mock_data';
describe('Productivity analytics chart getters', () => {
let state;
const groupNamespace = 'gitlab-org';
const projectPath = 'gitlab-test';
beforeEach(() => {
state = createState();
});
describe('chartLoading', () => {
it('returns true', () => {
state.charts[chartKeys.main].isLoading = true;
const result = getters.chartLoading(state)(chartKeys.main);
expect(result).toBe(true);
});
});
describe('getChartData', () => {
it("parses the chart's data and adds a color property to selected items", () => {
const chartKey = chartKeys.main;
state.charts[chartKey] = {
data: {
'1': 32,
'5': 17,
},
selected: ['5'],
};
const chartData = {
full: [
{ value: ['1', 32], itemStyle: {} },
{ value: ['5', 17], itemStyle: columnHighlightStyle },
],
};
expect(getters.getChartData(state)(chartKey)).toEqual(chartData);
});
});
describe('getMetricDropdownLabel', () => {
it('returns the correct label for the "time_to_last_commit" metric', () => {
state.charts[chartKeys.timeBasedHistogram].params = {
metricType: 'time_to_last_commit',
};
expect(getters.getMetricDropdownLabel(state)(chartKeys.timeBasedHistogram)).toBe(
'Time from first comment to last commit',
);
});
});
describe('getFilterParams', () => {
const rootGetters = {};
rootGetters['filters/getCommonFilterParams'] = {
group_id: groupNamespace,
project_id: projectPath,
};
describe('main chart', () => {
it('returns the correct params object', () => {
const expected = {
group_id: groupNamespace,
project_id: projectPath,
chart_type: state.charts[chartKeys.main].params.chartType,
};
expect(getters.getFilterParams(state, null, null, rootGetters)(chartKeys.main)).toEqual(
expected,
);
});
});
describe('timeBasedHistogram charts', () => {
const chartKey = chartKeys.timeBasedHistogram;
describe('main chart has selected items', () => {
it('returns the params object including "days_to_merge"', () => {
state.charts = {
[chartKeys.main]: {
selected: ['5'],
},
[chartKeys.timeBasedHistogram]: {
params: {
chartType: 'histogram',
},
},
};
const expected = {
group_id: groupNamespace,
project_id: projectPath,
chart_type: state.charts[chartKey].params.chartType,
days_to_merge: ['5'],
};
expect(getters.getFilterParams(state, null, null, rootGetters)(chartKey)).toEqual(
expected,
);
});
});
describe('chart has a metricType', () => {
it('returns the params object including metric_type', () => {
state.charts = {
[chartKeys.main]: {
selected: [],
},
[chartKeys.timeBasedHistogram]: {
params: {
chartType: 'histogram',
metricType: 'time_to_first_comment',
},
},
};
const expected = {
group_id: groupNamespace,
project_id: projectPath,
chart_type: state.charts[chartKey].params.chartType,
days_to_merge: [],
metric_type: 'time_to_first_comment',
};
expect(getters.getFilterParams(state, null, null, rootGetters)(chartKey)).toEqual(
expected,
);
});
});
});
});
describe('getColumnChartOption', () => {
const chartKey = chartKeys.main;
describe(`data exceeds threshold of ${maxColumnChartItemsPerPage[chartKey]} items`, () => {
it('returns a dataZoom property and computes the end interval correctly', () => {
state.charts[chartKey].data = mockHistogramData;
const intervalEnd = 98;
const expected = {
dataZoom: [
{
type: 'slider',
bottom: 10,
start: 0,
end: intervalEnd,
},
{
type: 'inside',
start: 0,
end: intervalEnd,
},
],
};
expect(getters.getColumnChartOption(state)(chartKeys.main)).toEqual(expected);
});
});
describe(`does not exceed threshold of ${maxColumnChartItemsPerPage[chartKey]} items`, () => {
it('returns an empty dataZoom property', () => {
state.charts[chartKey].data = { '1': 1, '2': 2, '3': 3 };
expect(getters.getColumnChartOption(state)(chartKeys.main)).toEqual({});
});
});
});
});
import * as types from 'ee/analytics/productivity_analytics/store/modules/charts/mutation_types';
import mutations from 'ee/analytics/productivity_analytics/store/modules/charts/mutations';
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/charts/state';
import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
import { mockHistogramData } from '../../../mock_data';
describe('Productivity analytics chart mutations', () => {
let state;
let chartKey = chartKeys.main;
beforeEach(() => {
state = getInitialState();
});
describe(types.RESET_CHART_DATA, () => {
it('resets the data and selected items', () => {
mutations[types.RESET_CHART_DATA](state, chartKey);
expect(state.charts[chartKey].data).toEqual({});
expect(state.charts[chartKey].selected).toEqual([]);
});
});
describe(types.REQUEST_CHART_DATA, () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_CHART_DATA](state, chartKey);
expect(state.charts[chartKey].isLoading).toBe(true);
});
});
describe(types.RECEIVE_CHART_DATA_SUCCESS, () => {
it('updates relevant chart with data', () => {
mutations[types.RECEIVE_CHART_DATA_SUCCESS](state, { chartKey, data: mockHistogramData });
expect(state.charts[chartKey].isLoading).toBe(false);
expect(state.charts[chartKey].hasError).toBe(false);
expect(state.charts[chartKey].data).toEqual(mockHistogramData);
});
});
describe(types.RECEIVE_CHART_DATA_ERROR, () => {
it('sets isError and clears data', () => {
mutations[types.RECEIVE_CHART_DATA_ERROR](state, chartKey);
expect(state.charts[chartKey].isLoading).toBe(false);
expect(state.charts[chartKey].hasError).toBe(true);
expect(state.charts[chartKey].data).toEqual({});
});
});
describe(types.SET_METRIC_TYPE, () => {
it('updates the metricType on the params', () => {
chartKey = chartKeys.timeBasedHistogram;
const metricType = 'time_to_merge';
mutations[types.SET_METRIC_TYPE](state, { chartKey, metricType });
expect(state.charts[chartKey].params.metricType).toBe(metricType);
});
});
describe(types.UPDATE_SELECTED_CHART_ITEMS, () => {
chartKey = chartKeys.timeBasedHistogram;
const item = 5;
it('adds the item to the list of selected items when not included', () => {
mutations[types.UPDATE_SELECTED_CHART_ITEMS](state, { chartKey, item });
expect(state.charts[chartKey].selected).toEqual([5]);
});
it('removes the item from the list of selected items when already included', () => {
state.charts[chartKey].selected.push(5);
mutations[types.UPDATE_SELECTED_CHART_ITEMS](state, { chartKey, item });
expect(state.charts[chartKey].selected).toEqual([]);
});
});
});
...@@ -4,19 +4,26 @@ import * as types from 'ee/analytics/productivity_analytics/store/modules/filter ...@@ -4,19 +4,26 @@ import * as types from 'ee/analytics/productivity_analytics/store/modules/filter
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/filters/state'; import getInitialState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
describe('Productivity analytics filter actions', () => { describe('Productivity analytics filter actions', () => {
const groupNamespace = 'gitlab-org';
const projectPath = 'gitlab-test';
describe('setGroupNamespace', () => { describe('setGroupNamespace', () => {
it('commits the SET_GROUP_NAMESPACE mutation', done => it('commits the SET_GROUP_NAMESPACE mutation', done =>
testAction( testAction(
actions.setGroupNamespace, actions.setGroupNamespace,
'gitlab-org', groupNamespace,
getInitialState(), getInitialState(),
[ [
{ {
type: types.SET_GROUP_NAMESPACE, type: types.SET_GROUP_NAMESPACE,
payload: 'gitlab-org', payload: groupNamespace,
}, },
], ],
[ [
{
type: 'charts/fetchAllChartData',
payload: null,
},
{ {
type: 'table/fetchMergeRequests', type: 'table/fetchMergeRequests',
payload: null, payload: null,
...@@ -30,15 +37,19 @@ describe('Productivity analytics filter actions', () => { ...@@ -30,15 +37,19 @@ describe('Productivity analytics filter actions', () => {
it('commits the SET_PROJECT_PATH mutation', done => it('commits the SET_PROJECT_PATH mutation', done =>
testAction( testAction(
actions.setProjectPath, actions.setProjectPath,
'gitlab-test', projectPath,
getInitialState(), getInitialState(),
[ [
{ {
type: types.SET_PROJECT_PATH, type: types.SET_PROJECT_PATH,
payload: 'gitlab-test', payload: projectPath,
}, },
], ],
[ [
{
type: 'charts/fetchAllChartData',
payload: null,
},
{ {
type: 'table/fetchMergeRequests', type: 'table/fetchMergeRequests',
payload: null, payload: null,
...@@ -61,6 +72,10 @@ describe('Productivity analytics filter actions', () => { ...@@ -61,6 +72,10 @@ describe('Productivity analytics filter actions', () => {
}, },
], ],
[ [
{
type: 'charts/fetchAllChartData',
payload: null,
},
{ {
type: 'table/fetchMergeRequests', type: 'table/fetchMergeRequests',
payload: null, payload: null,
...@@ -83,6 +98,10 @@ describe('Productivity analytics filter actions', () => { ...@@ -83,6 +98,10 @@ describe('Productivity analytics filter actions', () => {
}, },
], ],
[ [
{
type: 'charts/fetchAllChartData',
payload: null,
},
{ {
type: 'table/fetchMergeRequests', type: 'table/fetchMergeRequests',
payload: null, payload: null,
......
import createState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
import * as getters from 'ee/analytics/productivity_analytics/store/modules/filters/getters';
describe('Productivity analytics filter getters', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('getCommonFilterParams', () => {
it('returns an object with group_id, project_id and all relevant params from the filters string', () => {
state = {
groupNamespace: 'gitlab-org',
projectPath: 'gitlab-test',
filters: '?author_username=root&milestone_title=foo&label_name[]=labelxyz',
};
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-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);
});
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');
});
});
});
...@@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants'; ...@@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/analytics/productivity_analytics/store/modules/table/actions'; import * as actions from 'ee/analytics/productivity_analytics/store/modules/table/actions';
import * as types from 'ee/analytics/productivity_analytics/store/modules/table/mutation_types'; import * as types from 'ee/analytics/productivity_analytics/store/modules/table/mutation_types';
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/table/state'; import getInitialState from 'ee/analytics/productivity_analytics/store/modules/table/state';
import mockMergeRequests from '../../../mock_data'; import { mockMergeRequests } from '../../../mock_data';
describe('Productivity analytics table actions', () => { describe('Productivity analytics table actions', () => {
let mockedContext; let mockedContext;
...@@ -86,8 +86,6 @@ describe('Productivity analytics table actions', () => { ...@@ -86,8 +86,6 @@ describe('Productivity analytics table actions', () => {
mock.onGet(mockedState.endpoint).replyOnce(200, mockMergeRequests, headers); mock.onGet(mockedState.endpoint).replyOnce(200, mockMergeRequests, headers);
}); });
// This gets uncommented with the API changes from https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/14772
/*
it('calls API with pparams', () => { it('calls API with pparams', () => {
jest.spyOn(axios, 'get'); jest.spyOn(axios, 'get');
...@@ -102,7 +100,6 @@ describe('Productivity analytics table actions', () => { ...@@ -102,7 +100,6 @@ describe('Productivity analytics table actions', () => {
}, },
}); });
}); });
*/
it('dispatches success with received data', done => it('dispatches success with received data', done =>
testAction( testAction(
......
...@@ -2,7 +2,7 @@ import * as types from 'ee/analytics/productivity_analytics/store/modules/table/ ...@@ -2,7 +2,7 @@ import * as types from 'ee/analytics/productivity_analytics/store/modules/table/
import mutations from 'ee/analytics/productivity_analytics/store/modules/table/mutations'; import mutations from 'ee/analytics/productivity_analytics/store/modules/table/mutations';
import getInitialState from 'ee/analytics/productivity_analytics/store/modules/table/state'; import getInitialState from 'ee/analytics/productivity_analytics/store/modules/table/state';
import { tableSortOrder } from 'ee/analytics/productivity_analytics/constants'; import { tableSortOrder } from 'ee/analytics/productivity_analytics/constants';
import mockMergeRequests from '../../../mock_data'; import { mockMergeRequests } from '../../../mock_data';
describe('Productivity analytics table mutations', () => { describe('Productivity analytics table mutations', () => {
let state; let state;
......
import SET_ENDPOINT from 'ee/analytics/productivity_analytics/store/mutation_types';
import mutations from 'ee/analytics/productivity_analytics/store/mutations';
import getInitialState from 'ee/analytics/productivity_analytics/store/state';
describe('Productivity analytics mutations', () => {
let state;
beforeEach(() => {
state = getInitialState();
});
describe(SET_ENDPOINT, () => {
it('sets the endpoint', () => {
const endpoint = 'endpoint.json';
mutations[SET_ENDPOINT](state, endpoint);
expect(state.endpoint).toBe(endpoint);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe ProductivityAnalytics do
subject(:analytics) { described_class.new(merge_requests: MergeRequest.all, sort: custom_sort) }
let(:custom_sort) { nil }
let(:long_mr) do
metrics_data = {
merged_at: 1.day.ago,
first_comment_at: 31.days.ago,
last_commit_at: 2.days.ago,
commits_count: 20,
diff_size: 310,
modified_paths_size: 15
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
let(:medium_mr) do
metrics_data = {
merged_at: 1.day.ago,
first_comment_at: 15.days.ago,
last_commit_at: 2.days.ago,
commits_count: 5,
diff_size: 84,
modified_paths_size: 3
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 15.days.ago, metrics_data: metrics_data)
end
let(:short_mr) do
metrics_data = {
merged_at: 28.days.ago,
first_comment_at: 30.days.ago,
last_commit_at: 28.days.ago,
commits_count: 1,
diff_size: 14,
modified_paths_size: 3
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
let(:short_mr_2) do
metrics_data = {
merged_at: 28.days.ago,
first_comment_at: 31.days.ago,
last_commit_at: 29.days.ago,
commits_count: 1,
diff_size: 5,
modified_paths_size: 1
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
before do
Timecop.freeze do
long_mr
medium_mr
short_mr
short_mr_2
end
end
describe '#histogram_data' do
subject { analytics.histogram_data(type: metric) }
context 'days_to_merge metric' do
let(:metric) { 'days_to_merge' }
it 'returns aggregated data per days to merge from MR creation date' do
expect(subject).to eq(3 => 2, 14 => 1, 30 => 1)
end
end
context 'time_to_first_comment metric' do
let(:metric) { 'time_to_first_comment' }
it 'returns aggregated data per hours from MR creation to first comment' do
expect(subject).to eq(0 => 3, 24 => 1)
end
end
context 'time_to_last_commit metric' do
let(:metric) { 'time_to_last_commit' }
it 'returns aggregated data per hours from first comment to last commit' do
expect(subject).to eq(13 * 24 => 1, 29 * 24 => 1, 2 * 24 => 2)
end
end
context 'time_to_merge metric' do
let(:metric) { 'time_to_merge' }
it 'returns aggregated data per hours from last commit to merge' do
expect(subject).to eq(24 => 3, 0 => 1)
end
end
context 'commits_count metric' do
let(:metric) { 'commits_count' }
it 'returns aggregated data per number of commits' do
expect(subject).to eq(1 => 2, 5 => 1, 20 => 1)
end
end
context 'loc_per_commit metric' do
let(:metric) { 'loc_per_commit' }
it 'returns aggregated data per number of LoC/commits_count' do
expect(subject).to eq(15 => 1, 16 => 1, 14 => 1, 5 => 1)
end
end
context 'files_touched metric' do
let(:metric) { 'files_touched' }
it 'returns aggregated data per number of modified files' do
expect(subject).to eq(15 => 1, 3 => 2, 1 => 1)
end
end
context 'for invalid metric' do
let(:metric) { 'something_invalid' }
it { is_expected.to eq nil }
end
end
# Test coverage depends on #histogram_data tests. We want to avoid duplication here, so test only for 1 metric.
describe '#scatterplot_data' do
subject { analytics.scatterplot_data(type: 'days_to_merge') }
it 'returns metric values for each MR' do
expect(subject).to match(
short_mr.id => { metric: 3, merged_at: be_like_time(short_mr.merged_at) },
short_mr_2.id => { metric: 3, merged_at: be_like_time(short_mr_2.merged_at) },
medium_mr.id => { metric: 14, merged_at: be_like_time(medium_mr.merged_at) },
long_mr.id => { metric: 30, merged_at: be_like_time(long_mr.merged_at) }
)
end
end
describe '#merge_requests_extended' do
subject { analytics.merge_requests_extended }
it 'returns MRs data with all the metrics calculated' do
expected_data = {
long_mr.id => {
'days_to_merge' => 30,
'time_to_first_comment' => 0,
'time_to_last_commit' => 29 * 24,
'time_to_merge' => 24,
'commits_count' => 20,
'loc_per_commit' => 15,
'files_touched' => 15
},
medium_mr.id => {
'days_to_merge' => 14,
'time_to_first_comment' => 0,
'time_to_last_commit' => 13 * 24,
'time_to_merge' => 24,
'commits_count' => 5,
'loc_per_commit' => 16,
'files_touched' => 3
},
short_mr.id => {
'days_to_merge' => 3,
'time_to_first_comment' => 24,
'time_to_last_commit' => 2 * 24,
'time_to_merge' => 0,
'commits_count' => 1,
'loc_per_commit' => 14,
'files_touched' => 3
},
short_mr_2.id => {
'days_to_merge' => 3,
'time_to_first_comment' => 0,
'time_to_last_commit' => 2 * 24,
'time_to_merge' => 24,
'commits_count' => 1,
'loc_per_commit' => 5,
'files_touched' => 1
}
}
expected_data.each do |mr_id, expected_attributes|
expect(subject.detect { |mr| mr.id == mr_id}.attributes).to include(expected_attributes)
end
end
context 'with custom sorting' do
let(:custom_sort) { 'loc_per_commit_asc' }
it 'reorders MRs according to custom sorting' do
expect(subject).to eq [short_mr_2, short_mr, long_mr, medium_mr]
end
context 'with unknown sorting' do
let(:custom_sort) { 'weird_stuff' }
it 'does not apply custom sorting' do
expect(subject).to eq [long_mr, medium_mr, short_mr, short_mr_2]
end
end
end
end
end
...@@ -31,4 +31,16 @@ describe GlobalPolicy do ...@@ -31,4 +31,16 @@ describe GlobalPolicy do
it { expect(described_class.new(create(:admin), [user])).to be_allowed(:read_licenses) } it { expect(described_class.new(create(:admin), [user])).to be_allowed(:read_licenses) }
it { expect(described_class.new(create(:admin), [user])).to be_allowed(:destroy_licenses) } it { expect(described_class.new(create(:admin), [user])).to be_allowed(:destroy_licenses) }
describe 'view_productivity_analytics' do
context 'for anonymous' do
let(:current_user) { nil }
it { is_expected.not_to be_allowed(:view_productivity_analytics) }
end
context 'for authenticated users' do
it { is_expected.to be_allowed(:view_productivity_analytics) }
end
end
end end
...@@ -403,4 +403,22 @@ describe GroupPolicy do ...@@ -403,4 +403,22 @@ describe GroupPolicy do
end end
end end
end end
describe 'view_productivity_analytics' do
%w[admin owner maintainer developer reporter].each do |role|
context "for #{role}" do
let(:current_user) { public_send(role) }
it { is_expected.to be_allowed(:view_productivity_analytics) }
end
end
%w[guest].each do |role|
context "for #{role}" do
let(:current_user) { public_send(role) }
it { is_expected.to be_disallowed(:view_productivity_analytics) }
end
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe ProductivityAnalyticsMergeRequestEntity do
subject { described_class.represent(merge_request).as_json.with_indifferent_access }
let(:merge_request) { create(:merge_request) }
before do
ProductivityAnalytics::METRIC_TYPES.each.with_index do |type, i|
allow(merge_request).to receive(type).and_return(i)
end
end
it 'exposes all additional metrics' do
expect(subject.keys).to include(*ProductivityAnalytics::METRIC_TYPES)
end
it 'exposes author_avatar_url' do
expect(subject[:author_avatar_url]).to eq merge_request.author.avatar_url
end
it 'exposes merge_request_url' do
expect(subject[:merge_request_url])
.to eq Gitlab::Routing.url_helpers.project_merge_request_url(merge_request.project, merge_request)
end
end
...@@ -4611,6 +4611,9 @@ msgstr "" ...@@ -4611,6 +4611,9 @@ msgstr ""
msgid "DayTitle|W" msgid "DayTitle|W"
msgstr "" msgstr ""
msgid "Days"
msgstr ""
msgid "Days to merge" msgid "Days to merge"
msgstr "" msgstr ""
...@@ -7925,6 +7928,9 @@ msgstr "" ...@@ -7925,6 +7928,9 @@ msgstr ""
msgid "Hook was successfully updated." msgid "Hook was successfully updated."
msgstr "" msgstr ""
msgid "Hours"
msgstr ""
msgid "Housekeeping" msgid "Housekeeping"
msgstr "" msgstr ""
...@@ -17689,6 +17695,9 @@ msgstr "" ...@@ -17689,6 +17695,9 @@ msgstr ""
msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}" msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
msgstr "" msgstr ""
msgid "You can filter by \"days to merge\" by clicking on the columns in the chart."
msgstr ""
msgid "You can invite a new member to <strong>%{project_name}</strong> or invite another group." msgid "You can invite a new member to <strong>%{project_name}</strong> or invite another group."
msgstr "" 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