Commit 79a3bea4 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '33604-fe-visualize-tasks-by-type-in-customizable-cycle-analytics' into 'master'

[FE] Visualize Tasks by Type in Customizable Cycle Analytics

See merge request gitlab-org/gitlab!19602
parents 44d6830b 2099e33b
......@@ -12,6 +12,7 @@ import StageDropdownFilter from './stage_dropdown_filter.vue';
import SummaryTable from './summary_table.vue';
import StageTable from './stage_table.vue';
import { LAST_ACTIVITY_AT } from '../../shared/constants';
import TasksByTypeChart from './tasks_by_type_chart.vue';
export default {
name: 'CycleAnalytics',
......@@ -25,6 +26,7 @@ export default {
GlDaterangePicker,
StageDropdownFilter,
Scatterplot,
TasksByTypeChart,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -41,24 +43,19 @@ export default {
required: true,
},
},
data() {
return {
multiProjectSelect: true,
dateOptions: [7, 30, 90],
};
},
computed: {
...mapState([
'featureFlags',
'isLoading',
'isLoadingStage',
'isLoadingChartData',
'isLoadingTasksByTypeChart',
'isLoadingDurationChart',
'isEmptyStage',
'isSavingCustomStage',
'isCreatingCustomStage',
'isEditingCustomStage',
'selectedGroup',
'selectedProjectIds',
'selectedStage',
'stages',
'summary',
......@@ -71,7 +68,12 @@ export default {
'tasksByType',
'medians',
]),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']),
...mapGetters([
'hasNoAccessError',
'currentGroupPath',
'durationChartPlottableData',
'tasksByTypeChartData',
]),
shouldRenderEmptyState() {
return !this.selectedGroup;
},
......@@ -84,6 +86,10 @@ export default {
shouldDisplayDurationChart() {
return !this.isLoadingDurationChart && !this.isLoading;
},
shouldDisplayTasksByTypeChart() {
return !this.isLoading && !this.isLoadingTasksByTypeChart;
},
dateRange: {
get() {
return { startDate: this.startDate, endDate: this.endDate };
......@@ -95,6 +101,26 @@ export default {
});
},
},
hasDateRangeSet() {
return this.startDate && this.endDate;
},
selectedTasksByTypeFilters() {
const {
selectedGroup,
startDate,
endDate,
selectedProjectIds,
tasksByType: { subject, labelIds: selectedLabelIds },
} = this;
return {
selectedGroup,
selectedProjectIds,
startDate,
endDate,
subject,
selectedLabelIds,
};
},
},
mounted() {
this.initDateRange();
......@@ -160,6 +186,8 @@ export default {
this.updateSelectedDurationChartStages(stages);
},
},
multiProjectSelect: true,
dateOptions: [7, 30, 90],
groupsQueryParams: {
min_access_level: featureAccessLevel.EVERYONE,
},
......@@ -191,7 +219,7 @@ export default {
class="js-projects-dropdown-filter ml-md-1 mt-1 mt-md-0 dropdown-select"
:group-id="selectedGroup.id"
:query-params="$options.projectsQueryParams"
:multi-select="multiProjectSelect"
:multi-select="$options.multiProjectSelect"
@selected="onProjectsSelect"
/>
<div
......@@ -288,6 +316,17 @@ export default {
</template>
<gl-loading-icon v-else-if="!isLoading" size="md" class="my-4 py-4" />
</template>
<template v-if="featureFlags.hasTasksByTypeChart">
<div class="js-tasks-by-type-chart">
<div v-if="shouldDisplayTasksByTypeChart">
<tasks-by-type-chart
:chart-data="tasksByTypeChartData"
:filters="selectedTasksByTypeFilters"
/>
</div>
<gl-loading-icon v-else size="md" class="my-4 py-4" />
</div>
</template>
</div>
</div>
</template>
<script>
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { s__, sprintf } from '~/locale';
import { formattedDate } from '../../shared/utils';
export default {
name: 'TasksByTypeChart',
components: {
GlStackedColumnChart,
},
props: {
filters: {
type: Object,
required: true,
},
chartData: {
type: Object,
required: true,
},
},
computed: {
hasData() {
return Boolean(this.chartData?.data?.length);
},
selectedFiltersText() {
const { subject, selectedLabelIds } = this.filters;
return sprintf(s__('CycleAnalytics|Showing %{subject} and %{selectedLabelsCount} labels'), {
subject,
selectedLabelsCount: selectedLabelIds.length,
});
},
summaryDescription() {
const {
startDate,
endDate,
selectedProjectIds,
selectedGroup: { name: groupName },
} = this.filters;
const selectedProjectCount = selectedProjectIds.length;
const str =
selectedProjectCount > 0
? s__(
"CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}",
)
: s__(
"CycleAnalytics|Showing data for group '%{groupName}' from %{startDate} to %{endDate}",
);
return sprintf(str, {
startDate: formattedDate(startDate),
endDate: formattedDate(endDate),
groupName,
selectedProjectCount,
});
},
},
chartOptions: {
legend: false,
},
};
</script>
<template>
<div class="row">
<div class="col-12">
<h3>{{ s__('CycleAnalytics|Type of work') }}</h3>
<div v-if="hasData">
<p>{{ summaryDescription }}</p>
<h4>{{ s__('CycleAnalytics|Tasks by type') }}</h4>
<p>{{ selectedFiltersText }}</p>
<gl-stacked-column-chart
:option="$options.chartOptions"
:data="chartData.data"
:group-by="chartData.groupBy"
x-axis-type="category"
:x-axis-title="__('Date')"
:y-axis-title="s__('CycleAnalytics|Number of tasks')"
:series-names="chartData.seriesNames"
/>
</div>
<div v-else class="bs-callout bs-callout-info">
<p>{{ __('There is no data available. Please change your selection.') }}</p>
</div>
</div>
</div>
</template>
......@@ -246,13 +246,15 @@ export const createCustomStage = ({ dispatch, state }, data) => {
.catch(error => dispatch('receiveCreateCustomStageError', { error, data }));
};
export const receiveTasksByTypeDataSuccess = ({ commit }, data) =>
export const receiveTasksByTypeDataSuccess = ({ commit }, data) => {
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, data);
};
export const receiveTasksByTypeDataError = ({ commit }, error) => {
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error);
createFlash(__('There was an error fetching data for the tasks by type chart'));
};
export const requestTasksByTypeData = ({ commit }) => commit(types.REQUEST_TASKS_BY_TYPE_DATA);
export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
......@@ -279,7 +281,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
dispatch('requestTasksByTypeData');
return Api.cycleAnalyticsTasksByType(params)
.then(data => dispatch('receiveTasksByTypeDataSuccess', data))
.then(({ data }) => dispatch('receiveTasksByTypeDataSuccess', data))
.catch(error => dispatch('receiveTasksByTypeDataError', error));
}
return Promise.resolve();
......
import dateFormat from 'dateformat';
import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants';
import { getDurationChartData } from '../utils';
import { getDurationChartData, getTasksByTypeData } from '../utils';
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
......@@ -25,3 +25,14 @@ export const durationChartPlottableData = state => {
return plottableData.length ? plottableData : null;
};
export const tasksByTypeChartData = ({ tasksByType, startDate, endDate }) => {
if (tasksByType && tasksByType.data.length) {
return getTasksByTypeData({
data: tasksByType.data,
startDate,
endDate,
});
}
return { groupBy: [], data: [], seriesNames: [] };
};
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import { transformRawStages } from '../utils';
import { transformRawStages, transformRawTasksByTypeData } from '../utils';
export default {
[types.SET_FEATURE_FLAGS](state, featureFlags) {
......@@ -133,16 +133,16 @@ export default {
);
},
[types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true;
state.isLoadingTasksByTypeChart = true;
},
[types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) {
state.isLoadingChartData = false;
state.isLoadingTasksByTypeChart = false;
},
[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) {
state.isLoadingChartData = false;
state.isLoadingTasksByTypeChart = false;
state.tasksByType = {
...state.tasksByType,
data,
data: transformRawTasksByTypeData(data),
};
},
[types.REQUEST_CREATE_CUSTOM_STAGE](state) {
......
......@@ -8,7 +8,7 @@ export default () => ({
isLoading: false,
isLoadingStage: false,
isLoadingChartData: false,
isLoadingTasksByTypeChart: false,
isLoadingDurationChart: false,
isEmptyStage: false,
......
......@@ -2,9 +2,10 @@ import { isString, isNumber } from 'underscore';
import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { newDate, dayAfter, secondsToDays } from '~/lib/utils/datetime_utility';
import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility';
import { dateFormats } from '../shared/constants';
import { STAGE_NAME } from './constants';
import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label';
......@@ -77,6 +78,11 @@ export const transformRawStages = (stages = []) =>
name: name.length ? name : title,
}));
export const transformRawTasksByTypeData = (data = []) => {
if (!data.length) return [];
return data.map(d => convertObjectPropsToCamelCase(d, { deep: true }));
};
export const nestQueryStringKeys = (obj = null, targetKey = '') => {
if (!obj || !isString(targetKey) || !targetKey.length) return {};
return Object.entries(obj).reduce((prev, [key, value]) => {
......@@ -189,3 +195,86 @@ export const getDurationChartData = (data, startDate, endDate) => {
return eventData;
};
export const orderByDate = (a, b, dateFmt = datetime => new Date(datetime).getTime()) =>
dateFmt(a) - dateFmt(b);
/**
* Takes a dictionary of dates and the associated value, sorts them and returns just the value
*
* @param {Object.<Date, number>} series - Key value pair of dates and the value for that date
* @returns {number[]} The values of each key value pair
*/
export const flattenTaskByTypeSeries = (series = {}) =>
Object.entries(series)
.sort((a, b) => orderByDate(a[0], b[0]))
.map(dataSet => dataSet[1]);
/**
* @typedef {Object} RawTasksByTypeData
* @property {Object} label - Raw data for a group label
* @property {Array} series - Array of arrays with date and associated value ie [ ['2020-01-01', 10],['2020-01-02', 10] ]
* @typedef {Object} TransformedTasksByTypeData
* @property {Array} groupBy - The list of dates for the range of data in each data series
* @property {Array} data - An array of the data values for each series
* @property {Array} seriesNames - Names of the series to be charted ie label names
*/
/**
* Takes the raw tasks by type data and generates an array of data points,
* an array of data series and an array of data labels for the given time period.
*
* Currently the data is transformed to support use in a stacked column chart:
* https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/charts-stacked-column-chart--stacked
*
* @param {Object} obj
* @param {RawTasksByTypeData[]} obj.data - array of raw data, each element contains a label and series
* @param {Date} obj.startDate - start date in ISO date format
* @param {Date} obj.endDate - end date in ISO date format
*
* @returns {TransformedTasksByTypeData} The transformed data ready for use in charts
*/
export const getTasksByTypeData = ({ data = [], startDate = null, endDate = null }) => {
if (!startDate || !endDate || !data.length) {
return {
groupBy: [],
data: [],
seriesNames: [],
};
}
const groupBy = getDatesInRange(startDate, endDate, toYmd).sort(orderByDate);
const zeroValuesForEachDataPoint = groupBy.reduce(
(acc, date) => ({
...acc,
[date]: 0,
}),
{},
);
const transformed = data.reduce(
(acc, curr) => {
const {
label: { title },
series,
} = curr;
acc.seriesNames = [...acc.seriesNames, title];
acc.data = [
...acc.data,
// adds 0 values for each data point and overrides with data from the series
flattenTaskByTypeSeries({ ...zeroValuesForEachDataPoint, ...Object.fromEntries(series) }),
];
return acc;
},
{
data: [],
seriesNames: [],
},
);
return {
...transformed,
groupBy,
};
};
import dateFormat from 'dateformat';
import { dateFormats } from './constants';
export const toYmd = date => dateFormat(date, dateFormats.isoDate);
export default {
toYmd,
};
export const formattedDate = d => dateFormat(d, dateFormats.defaultDate);
......@@ -9,6 +9,8 @@ describe 'Group Cycle Analytics', :js do
let(:milestone) { create(:milestone, project: project) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
let(:label) { create(:group_label, group: group) }
let(:label2) { create(:group_label, group: group) }
stage_nav_selector = '.stage-nav'
......@@ -18,6 +20,7 @@ describe 'Group Cycle Analytics', :js do
before do
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_owner(user)
project.add_maintainer(user)
......@@ -217,6 +220,67 @@ describe 'Group Cycle Analytics', :js do
end
end
describe 'Tasks by type chart', :js do
context 'enabled' do
before do
stub_licensed_features(cycle_analytics_for_groups: true, type_of_work_analytics: true)
sign_in(user)
end
context 'with data available' do
before do
3.times do |i|
create(:labeled_issue, created_at: i.days.ago, project: create(:project, group: group), labels: [label])
create(:labeled_issue, created_at: i.days.ago, project: create(:project, group: group), labels: [label2])
end
visit analytics_cycle_analytics_path
select_group
end
it 'displays the chart' do
expect(page).to have_text('Type of work')
expect(page).to have_text('Tasks by type')
end
it 'has 2 labels selected' do
expect(page).to have_text('Showing Issue and 2 labels')
end
end
context 'no data available' do
before do
visit analytics_cycle_analytics_path
select_group
end
it 'shows the no data available message' do
expect(page).to have_text('Type of work')
expect(page).to have_text('There is no data available. Please change your selection.')
end
end
end
context 'not enabled' do
before do
stub_feature_flags(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG => false)
visit analytics_cycle_analytics_path
select_group
end
it 'will not display the tasks by type chart' do
expect(page).not_to have_selector('.js-tasks-by-type-chart')
expect(page).not_to have_text('Tasks by type')
end
end
end
describe 'Customizable cycle analytics', :js do
custom_stage_name = "Cool beans"
start_event_identifier = :merge_request_created
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TasksByTypeChart no data available should render the no data available message 1`] = `
"<div class=\\"row\\">
<div class=\\"col-12\\">
<h3>Type of work</h3>
<div class=\\"bs-callout bs-callout-info\\">
<p>There is no data available. Please change your selection.</p>
</div>
</div>
</div>"
`;
exports[`TasksByTypeChart with data available should render the loading chart 1`] = `
"<div class=\\"row\\">
<div class=\\"col-12\\">
<h3>Type of work</h3>
<div>
<p>Showing data for group 'Gitlab Org' from Dec 11, 2019 to Jan 10, 2020</p>
<h4>Tasks by type</h4>
<p>Showing Issue and 3 labels</p>
<gl-stacked-column-chart-stub data=\\"0,1,2,5,2,3,2,4,1\\" option=\\"[object Object]\\" presentation=\\"stacked\\" groupby=\\"Group 1,Group 2,Group 3\\" xaxistype=\\"category\\" xaxistitle=\\"Date\\" yaxistitle=\\"Number of tasks\\" seriesnames=\\"Cool label,Normal label\\" legendaveragetext=\\"Avg\\" legendmaxtext=\\"Max\\"></gl-stacked-column-chart-stub>
</div>
</div>
</div>"
`;
......@@ -25,12 +25,20 @@ const baseStagesEndpoint = '/-/analytics/cycle_analytics/stages';
const localVue = createLocalVue();
localVue.use(Vuex);
const defaultStubs = {
'summary-table': true,
'stage-event-list': true,
'stage-nav-item': true,
'tasks-by-type-chart': true,
};
function createComponent({
opts = {},
shallow = true,
withStageSelected = false,
scatterplotEnabled = true,
tasksByTypeChartEnabled = true,
customizableCycleAnalyticsEnabled = false,
} = {}) {
const func = shallow ? shallowMount : mount;
const comp = func(Component, {
......@@ -46,6 +54,7 @@ function createComponent({
glFeatures: {
cycleAnalyticsScatterplotEnabled: scatterplotEnabled,
tasksByTypeChart: tasksByTypeChartEnabled,
customizableCycleAnalytics: customizableCycleAnalyticsEnabled,
},
},
...opts,
......@@ -166,7 +175,7 @@ describe('Cycle Analytics component', () => {
describe('after a filter has been selected', () => {
describe('the user has access to the group', () => {
beforeEach(() => {
wrapper = createComponent({ withStageSelected: true });
wrapper = createComponent({ withStageSelected: true, tasksByTypeChartEnabled: false });
});
it('hides the empty state', () => {
......@@ -180,7 +189,7 @@ describe('Cycle Analytics component', () => {
expect.objectContaining({
queryParams: wrapper.vm.$options.projectsQueryParams,
groupId: mockData.group.id,
multiSelect: wrapper.vm.multiProjectSelect,
multiSelect: wrapper.vm.$options.multiProjectSelect,
}),
);
});
......@@ -218,6 +227,7 @@ describe('Cycle Analytics component', () => {
describe('with durationData', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper.vm.$store.dispatch('setDateRange', {
skipFetch: true,
startDate: mockData.startDate,
......@@ -248,14 +258,10 @@ describe('Cycle Analytics component', () => {
},
shallow: false,
withStageSelected: true,
tasksByTypeChartEnabled: false,
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('has the first stage selected by default', () => {
const first = selectStageNavItem(0);
const second = selectStageNavItem(1);
......@@ -281,6 +287,7 @@ describe('Cycle Analytics component', () => {
describe('the user does not have access to the group', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper.vm.$store.dispatch('setSelectedGroup', {
...mockData.group,
});
......@@ -321,19 +328,12 @@ describe('Cycle Analytics component', () => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: {
'summary-table': true,
'stage-event-list': true,
'stage-nav-item': true,
},
provide: {
glFeatures: {
customizableCycleAnalytics: true,
},
},
stubs: defaultStubs,
},
shallow: false,
withStageSelected: true,
customizableCycleAnalyticsEnabled: true,
tasksByTypeChartEnabled: false,
});
});
......@@ -346,6 +346,53 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(true);
});
});
describe('with tasksByTypeChart=true', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: defaultStubs,
},
shallow: false,
withStageSelected: true,
customizableCycleAnalyticsEnabled: false,
tasksByTypeChartEnabled: true,
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('displays the tasks by type chart', () => {
expect(wrapper.find('.js-tasks-by-type-chart').exists()).toBe(true);
});
});
describe('with tasksByTypeChart=false', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: defaultStubs,
},
shallow: false,
withStageSelected: true,
customizableCycleAnalyticsEnabled: false,
tasksByTypeChartEnabled: false,
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('does not render the tasks by type chart', () => {
expect(wrapper.find('.js-tasks-by-type-chart').exists()).toBe(false);
});
});
});
});
......@@ -357,6 +404,7 @@ describe('Cycle Analytics component', () => {
mockFetchStageData = true,
mockFetchStageMedian = true,
mockFetchDurationData = true,
mockFetchTasksByTypeData = true,
}) {
const defaultStatus = 200;
const defaultRequests = {
......@@ -375,14 +423,15 @@ describe('Cycle Analytics component', () => {
endpoint: `/groups/${groupId}/-/labels`,
response: [...mockData.groupLabels],
},
fetchTasksByTypeData: {
status: defaultStatus,
endpoint: '/-/analytics/type_of_work/tasks_by_type',
response: { ...mockData.tasksByTypeData },
},
...overrides,
};
if (mockFetchTasksByTypeData) {
mock
.onGet(/analytics\/type_of_work\/tasks_by_type/)
.reply(defaultStatus, { ...mockData.tasksByTypeData });
}
if (mockFetchDurationData) {
mock
.onGet(/analytics\/cycle_analytics\/stages\/\d+\/duration_chart/)
......@@ -491,15 +540,7 @@ describe('Cycle Analytics component', () => {
it('will display an error if the fetchTasksByTypeData request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
overrides: {
fetchTasksByTypeData: {
endPoint: '/-/analytics/type_of_work/tasks_by_type',
status: httpStatusCodes.BAD_REQUEST,
response: { response: { status: httpStatusCodes.BAD_REQUEST } },
},
},
});
mockRequestCycleAnalyticsData({ mockFetchTasksByTypeData: false });
return selectGroupAndFindError(
'There was an error fetching data for the tasks by type chart',
......
import { shallowMount } from '@vue/test-utils';
import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type_chart.vue';
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from 'ee/analytics/cycle_analytics/constants';
const seriesNames = ['Cool label', 'Normal label'];
const data = [[0, 1, 2], [5, 2, 3], [2, 4, 1]];
const groupBy = ['Group 1', 'Group 2', 'Group 3'];
const filters = {
selectedGroup: {
id: 22,
name: 'Gitlab Org',
fullName: 'Gitlab Org',
fullPath: 'gitlab-org',
},
selectedProjectIds: [],
startDate: new Date('2019-12-11'),
endDate: new Date('2020-01-10'),
subject: TASKS_BY_TYPE_SUBJECT_ISSUE,
selectedLabelIds: [1, 2, 3],
};
describe('TasksByTypeChart', () => {
function createComponent(props) {
return shallowMount(TasksByTypeChart, {
propsData: {
filters,
chartData: {
groupBy,
data,
seriesNames,
},
...props,
},
});
}
let wrapper = null;
afterEach(() => {
wrapper.destroy();
});
describe('with data available', () => {
beforeEach(() => {
wrapper = createComponent({});
});
it('should render the loading chart', () => {
expect(wrapper.html()).toMatchSnapshot();
});
});
describe('no data available', () => {
beforeEach(() => {
wrapper = createComponent({
chartData: {
groupBy: [],
data: [],
seriesNames: [],
},
});
});
it('should render the no data available message', () => {
expect(wrapper.html()).toMatchSnapshot();
});
});
});
......@@ -4,8 +4,10 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { getDateInPast, getDatesInRange } from '~/lib/utils/datetime_utility';
import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
import { toYmd } from 'ee/analytics/shared/utils';
import { transformRawTasksByTypeData } from 'ee/analytics/cycle_analytics/utils';
const endpoints = {
customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint
......@@ -121,7 +123,21 @@ export const customStageEvents = [
labelStopEvent,
];
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json');
const dateRange = getDatesInRange(startDate, endDate, toYmd);
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json').map(
labelData => {
// add data points for our mock date range
const maxValue = 10;
const series = dateRange.map(date => [date, Math.floor(Math.random() * Math.floor(maxValue))]);
return {
...labelData,
series,
};
},
);
export const transformedTasksByTypeData = transformRawTasksByTypeData(tasksByTypeData);
export const rawDurationData = [
{
......
......@@ -18,6 +18,7 @@ import {
customizableStagesAndEvents,
tasksByTypeData,
transformedDurationData,
transformedTasksByTypeData,
} from '../mock_data';
let state = null;
......@@ -32,34 +33,34 @@ describe('Cycle analytics mutations', () => {
});
it.each`
mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true}
${types.EDIT_CUSTOM_STAGE} | ${'isEditingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'labels'} | ${[]}
${types.RECEIVE_SUMMARY_DATA_ERROR} | ${'summary'} | ${[]}
${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingChartData'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingChartData'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true}
${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true}
${types.EDIT_CUSTOM_STAGE} | ${'isEditingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'labels'} | ${[]}
${types.RECEIVE_SUMMARY_DATA_ERROR} | ${'summary'} | ${[]}
${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingTasksByTypeChart'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingTasksByTypeChart'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true}
${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -189,17 +190,17 @@ describe('Cycle analytics mutations', () => {
});
describe(`${types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS}`, () => {
it('sets isLoadingChartData to false', () => {
it('sets isLoadingTasksByTypeChart to false', () => {
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, {});
expect(state.isLoadingChartData).toEqual(false);
expect(state.isLoadingTasksByTypeChart).toEqual(false);
});
it('sets tasksByType.data to the raw returned chart data', () => {
state = { tasksByType: { data: null } };
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, tasksByTypeData);
expect(state.tasksByType.data).toEqual(tasksByTypeData);
expect(state.tasksByType.data).toEqual(transformedTasksByTypeData);
});
});
......
import { isNumber } from 'underscore';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
import {
isStartEvent,
isLabelEvent,
......@@ -10,7 +12,11 @@ import {
getDurationChartData,
transformRawStages,
isPersistedStage,
getTasksByTypeData,
flattenTaskByTypeSeries,
orderByDate,
} from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils';
import {
customStageEvents as events,
labelStartEvent,
......@@ -23,6 +29,7 @@ import {
endDate,
issueStage,
rawCustomStage,
transformedTasksByTypeData,
} from './mock_data';
const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier);
......@@ -199,4 +206,112 @@ describe('Cycle analytics utils', () => {
expect(isPersistedStage({ custom, id })).toEqual(expected);
});
});
describe('flattenTaskByTypeSeries', () => {
const dummySeries = Object.fromEntries([
['2019-01-16', 40],
['2019-01-14', 20],
['2019-01-12', 10],
['2019-01-15', 30],
]);
let transformedDummySeries = [];
beforeEach(() => {
transformedDummySeries = flattenTaskByTypeSeries(dummySeries);
});
it('extracts the value from an array of datetime / value pairs', () => {
expect(transformedDummySeries.every(isNumber)).toEqual(true);
Object.values(dummySeries).forEach(v => {
expect(transformedDummySeries.includes(v)).toBeTruthy();
});
});
it('sorts the items by the datetime parameter', () => {
expect(transformedDummySeries).toEqual([10, 20, 30, 40]);
});
});
describe('orderByDate', () => {
it('sorts dates from the earliest to latest', () => {
expect(['2019-01-14', '2019-01-12', '2019-01-16', '2019-01-15'].sort(orderByDate)).toEqual([
'2019-01-12',
'2019-01-14',
'2019-01-15',
'2019-01-16',
]);
});
});
describe('getTasksByTypeData', () => {
let transformed = {};
const groupBy = getDatesInRange(startDate, endDate, toYmd);
// only return the values, drop the date which is the first paramater
const extractSeriesValues = ({ series }) => series.map(kv => kv[1]);
const data = transformedTasksByTypeData.map(extractSeriesValues);
const labels = transformedTasksByTypeData.map(d => {
const { label } = d;
return label.title;
});
it('will return blank arrays if given no data', () => {
[{ data: [], startDate, endDate }, [], {}].forEach(chartData => {
transformed = getTasksByTypeData(chartData);
['seriesNames', 'data', 'groupBy'].forEach(key => {
expect(transformed[key]).toEqual([]);
});
});
});
describe('with data', () => {
beforeEach(() => {
transformed = getTasksByTypeData({ data: transformedTasksByTypeData, startDate, endDate });
});
it('will return an object with the properties needed for the chart', () => {
['seriesNames', 'data', 'groupBy'].forEach(key => {
expect(transformed).toHaveProperty(key);
});
});
describe('seriesNames', () => {
it('returns the names of all the labels in the dataset', () => {
expect(transformed.seriesNames).toEqual(labels);
});
});
describe('groupBy', () => {
it('returns the date groupBy as an array', () => {
expect(transformed.groupBy).toEqual(groupBy);
});
it('the start date is the first element', () => {
expect(transformed.groupBy[0]).toEqual(toYmd(startDate));
});
it('the end date is the last element', () => {
expect(transformed.groupBy[transformed.groupBy.length - 1]).toEqual(toYmd(endDate));
});
});
describe('data', () => {
it('returns an array of data points', () => {
expect(transformed.data).toEqual(data);
});
it('contains an array of data for each label', () => {
expect(transformed.data.length).toEqual(labels.length);
});
it('contains a value for each day in the groupBy', () => {
transformed.data.forEach(d => {
expect(d.length).toEqual(transformed.groupBy.length);
});
});
});
});
});
});
......@@ -5639,12 +5639,30 @@ msgstr ""
msgid "CycleAnalytics|No stages selected"
msgstr ""
msgid "CycleAnalytics|Number of tasks"
msgstr ""
msgid "CycleAnalytics|Showing %{subject} and %{selectedLabelsCount} labels"
msgstr ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}"
msgstr ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{startDate} to %{endDate}"
msgstr ""
msgid "CycleAnalytics|Stages"
msgstr ""
msgid "CycleAnalytics|Tasks by type"
msgstr ""
msgid "CycleAnalytics|Total days to completion"
msgstr ""
msgid "CycleAnalytics|Type of work"
msgstr ""
msgid "CycleAnalytics|group dropdown filter"
msgstr ""
......@@ -5687,6 +5705,9 @@ msgstr ""
msgid "Data is still calculating..."
msgstr ""
msgid "Date"
msgstr ""
msgid "Date picker"
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