Commit 83eacbca authored by Martin Wortschack's avatar Martin Wortschack Committed by Pavel Shutsin

Productivity analytics FE

- Introduce Vuex store
- Add bar chart component
- Add reusable constants
- Pass endpoint and empty state svg path via data props
- Update app component
- Add store modules for charts and global_filters
- Introduce store modules
- Resolve UX discussions
parent 5a4f5cd3
...@@ -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)"
> >
<span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedSortField(key),
}"
name="mobile-issue-close"
/>
{{ value }} {{ 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)"
> >
<span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedMetric(key),
}"
name="mobile-issue-close"
/>
{{ value }} {{ 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;
......
- 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/security-dashboard-empty-state.svg') } } .js-productivity-analytics-app-container{ data: { endpoint: analytics_productivity_analytics_path, empty_state_svg_path: image_path('illustrations/productivity-analytics-empty-state.svg') } }
...@@ -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);
});
});
});
...@@ -33,14 +33,14 @@ describe GlobalPolicy do ...@@ -33,14 +33,14 @@ describe GlobalPolicy do
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 describe 'view_productivity_analytics' do
context 'for admins' do context 'for anonymous' do
let(:current_user) { create(:admin) } let(:current_user) { nil }
it { is_expected.to be_allowed(:view_productivity_analytics) } it { is_expected.not_to be_allowed(:view_productivity_analytics) }
end end
context 'for non-admins' do context 'for authenticated users' do
it { is_expected.not_to be_allowed(:view_productivity_analytics) } it { is_expected.to be_allowed(:view_productivity_analytics) }
end end
end end
end end
...@@ -405,7 +405,7 @@ describe GroupPolicy do ...@@ -405,7 +405,7 @@ describe GroupPolicy do
end end
describe 'view_productivity_analytics' do describe 'view_productivity_analytics' do
%w[admin owner].each do |role| %w[admin owner maintainer developer reporter].each do |role|
context "for #{role}" do context "for #{role}" do
let(:current_user) { public_send(role) } let(:current_user) { public_send(role) }
...@@ -413,7 +413,7 @@ describe GroupPolicy do ...@@ -413,7 +413,7 @@ describe GroupPolicy do
end end
end end
%w[maintainer developer reporter guest].each do |role| %w[guest].each do |role|
context "for #{role}" do context "for #{role}" do
let(:current_user) { public_send(role) } let(:current_user) { public_send(role) }
......
...@@ -4614,6 +4614,9 @@ msgstr "" ...@@ -4614,6 +4614,9 @@ msgstr ""
msgid "DayTitle|W" msgid "DayTitle|W"
msgstr "" msgstr ""
msgid "Days"
msgstr ""
msgid "Days to merge" msgid "Days to merge"
msgstr "" msgstr ""
...@@ -7919,6 +7922,9 @@ msgstr "" ...@@ -7919,6 +7922,9 @@ msgstr ""
msgid "Hook was successfully updated." msgid "Hook was successfully updated."
msgstr "" msgstr ""
msgid "Hours"
msgstr ""
msgid "Housekeeping" msgid "Housekeeping"
msgstr "" msgstr ""
...@@ -17662,6 +17668,9 @@ msgstr "" ...@@ -17662,6 +17668,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