Commit d389ade9 authored by Fatih Acet's avatar Fatih Acet

Merge branch '33604-tasks-by-type-chart-endpoint-integration' into 'master'

API Integration - CA Tasks by type chart

See merge request gitlab-org/gitlab!18252
parents 1546d946 4d969ea6
......@@ -47,6 +47,7 @@ export default {
...mapState([
'isLoading',
'isLoadingStage',
'isLoadingChartData',
'isEmptyStage',
'isAddingCustomStage',
'isSavingCustomStage',
......@@ -61,6 +62,7 @@ export default {
'errorCode',
'startDate',
'endDate',
'tasksByType',
]),
...mapGetters(['currentStage', 'defaultStage', 'hasNoAccessError', 'currentGroupPath']),
shouldRenderEmptyState() {
......@@ -101,6 +103,7 @@ export default {
'showCustomStageForm',
'setDateRange',
'createCustomStage',
'fetchTasksByTypeData',
]),
onGroupSelect(group) {
this.setCycleAnalyticsDataEndpoint(group.full_path);
......
......@@ -29,3 +29,6 @@ export const EMPTY_STAGE_TEXT = {
'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
),
};
export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
import axios from '~/lib/utils/axios_utils';
import createFlash, { hideFlash } from '~/flash';
import { __ } from '~/locale';
import Api from '~/api';
import Api from 'ee/api';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { nestQueryStringKeys } from '../utils';
......@@ -70,10 +70,12 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeError();
return dispatch('requestCycleAnalyticsData')
.then(() => dispatch('fetchGroupLabels')) // fetch group label data
.then(() => dispatch('fetchGroupStagesAndEvents')) // fetch stage data
.then(() => dispatch('fetchSummaryData')) // fetch summary data and stage medians
.then(() => dispatch('fetchGroupLabels'))
.then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchSummaryData'))
.then(() => dispatch('fetchTasksByTypeData'))
.then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
};
......@@ -192,3 +194,42 @@ export const createCustomStage = ({ dispatch, state }, data) => {
.then(response => dispatch('receiveCreateCustomStageSuccess', response))
.catch(error => dispatch('receiveCreateCustomStageError', { error, 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 chart'));
};
export const requestTasksByTypeData = ({ commit }) => commit(types.REQUEST_TASKS_BY_TYPE_DATA);
export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
const {
currentGroupPath,
cycleAnalyticsRequestParams: { created_after, created_before, project_ids },
} = getters;
const {
tasksByType: { labelIds, subject },
} = state;
// dont request if we have no labels selected...for now
if (labelIds.length) {
const params = {
group_id: currentGroupPath,
created_after,
created_before,
project_ids,
subject,
label_ids: labelIds,
};
dispatch('requestTasksByTypeData');
return Api.cycleAnalyticsTasksByType(params)
.then(data => dispatch('receiveTasksByTypeDataSuccess', data))
.catch(error => dispatch('receiveTasksByTypeDataError', error));
}
return Promise.resolve();
};
......@@ -32,3 +32,10 @@ export const RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR = 'RECEIVE_GROUP_STAGES_AND_E
export const REQUEST_CREATE_CUSTOM_STAGE = 'REQUEST_CREATE_CUSTOM_STAGE';
export const RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE = 'RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE';
export const SET_TASKS_BY_TYPE_SUBJECT = 'SET_TASKS_BY_TYPE_SUBJECT';
export const SET_TASKS_BY_TYPE_LABELS = 'SET_TASKS_BY_TYPE_LABELS';
export const REQUEST_TASKS_BY_TYPE_DATA = 'REQUEST_TASKS_BY_TYPE_DATA';
export const RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS = 'RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS';
export const RECEIVE_TASKS_BY_TYPE_DATA_ERROR = 'RECEIVE_TASKS_BY_TYPE_DATA_ERROR';
......@@ -60,12 +60,26 @@ export default {
},
[types.REQUEST_GROUP_LABELS](state) {
state.labels = [];
state.tasksByType = {
...state.tasksByType,
labelIds: [],
};
},
[types.RECEIVE_GROUP_LABELS_SUCCESS](state, data = []) {
const { tasksByType } = state;
state.labels = data.map(convertObjectPropsToCamelCase);
state.tasksByType = {
...tasksByType,
labelIds: data.map(({ id }) => id),
};
},
[types.RECEIVE_GROUP_LABELS_ERROR](state) {
const { tasksByType } = state;
state.labels = [];
state.tasksByType = {
...tasksByType,
labelIds: [],
};
},
[types.HIDE_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = false;
......@@ -126,4 +140,17 @@ export default {
[types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE](state) {
state.isSavingCustomStage = false;
},
[types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true;
},
[types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) {
state.isLoadingChartData = false;
},
[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) {
state.isLoadingChartData = false;
state.tasksByType = {
...state.tasksByType,
data,
};
},
};
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from '../constants';
export default () => ({
endpoints: {
cycleAnalyticsData: null,
......@@ -11,6 +13,7 @@ export default () => ({
isLoading: false,
isLoadingStage: false,
isLoadingChartData: false,
isEmptyStage: false,
errorCode: null,
......@@ -29,4 +32,9 @@ export default () => ({
labels: [],
customStageFormEvents: [],
tasksByType: {
subject: TASKS_BY_TYPE_SUBJECT_ISSUE,
labelIds: [],
data: [],
},
});
......@@ -18,6 +18,7 @@ export default {
groupPackagesPath: '/api/:version/groups/:id/packages',
projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type',
userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
......@@ -135,4 +136,9 @@ export default {
const url = this.buildProjectPackageUrl(projectId, packageId);
return axios.delete(url);
},
cycleAnalyticsTasksByType(params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsTasksByTypePath);
return axios.get(url, { params });
},
};
......@@ -315,7 +315,7 @@ describe('Cycle Analytics component', () => {
fetchGroupLabels: {
status: defaultStatus,
endpoint: `/groups/${groupId}/-/labels`,
response: { ...mockData.groupLabels },
response: [...mockData.groupLabels],
},
fetchStageData: {
status: defaultStatus,
......@@ -323,6 +323,11 @@ describe('Cycle Analytics component', () => {
endpoint: '/groups/foo/-/cycle_analytics/events/issue.json',
response: { ...mockData.issueEvents },
},
fetchTasksByTypeData: {
status: defaultStatus,
endpoint: '/-/analytics/type_of_work/tasks_by_type',
response: { ...mockData.tasksByTypeData },
},
...overrides,
};
......@@ -344,6 +349,13 @@ describe('Cycle Analytics component', () => {
});
const findFlashError = () => document.querySelector('.flash-container .flash-text');
const selectGroupAndFindError = msg => {
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual(msg);
});
};
it('will display an error if the fetchSummaryData request fails', () => {
expect(findFlashError()).toBeNull();
......@@ -356,13 +368,9 @@ describe('Cycle Analytics component', () => {
},
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error while fetching cycle analytics summary data.',
);
});
return selectGroupAndFindError(
'There was an error while fetching cycle analytics summary data.',
);
});
it('will display an error if the fetchGroupLabels request fails', () => {
......@@ -375,13 +383,9 @@ describe('Cycle Analytics component', () => {
},
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error fetching label data for the selected group',
);
});
return selectGroupAndFindError(
'There was an error fetching label data for the selected group',
);
});
it('will display an error if the fetchGroupStagesAndEvents request fails', () => {
......@@ -395,13 +399,7 @@ describe('Cycle Analytics component', () => {
},
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error fetching cycle analytics stages.',
);
});
return selectGroupAndFindError('There was an error fetching cycle analytics stages.');
});
it('will display an error if the fetchStageData request fails', () => {
......@@ -415,13 +413,21 @@ describe('Cycle Analytics component', () => {
},
});
wrapper.vm.onGroupSelect(mockData.group);
return selectGroupAndFindError('There was an error fetching data for the selected stage');
});
return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error fetching data for the selected stage',
);
it('will display an error if the fetchTasksByTypeData request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchTasksByTypeData: {
endPoint: '/-/analytics/type_of_work/tasks_by_type',
status: httpStatusCodes.BAD_REQUEST,
response: { response: { status: httpStatusCodes.BAD_REQUEST } },
},
});
return selectGroupAndFindError('There was an error fetching data for the chart');
});
});
});
......@@ -103,3 +103,5 @@ export const customStageEvents = [
labelStartEvent,
labelStopEvent,
];
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json');
......@@ -16,6 +16,7 @@ import {
startDate,
endDate,
customizableStagesAndEvents,
tasksByTypeData,
} from '../mock_data';
let state = null;
......@@ -47,6 +48,8 @@ describe('Cycle analytics mutations', () => {
${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}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -100,6 +103,19 @@ describe('Cycle analytics mutations', () => {
});
});
describe.each`
mutation | value
${types.REQUEST_GROUP_LABELS} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${[]}
`('$mutation', ({ mutation, value }) => {
it(`will set tasksByType.labelIds to ${value}`, () => {
state = { tasksByType: {} };
mutations[mutation](state);
expect(state.tasksByType.labelIds).toEqual(value);
});
});
describe(`${types.RECEIVE_GROUP_LABELS_SUCCESS}`, () => {
it('will set the labels state item with the camelCased group labels', () => {
mutations[types.RECEIVE_GROUP_LABELS_SUCCESS](state, groupLabels);
......@@ -193,4 +209,19 @@ describe('Cycle analytics mutations', () => {
expect(state.errorCode).toBe(errorCode);
});
});
describe(`${types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS}`, () => {
it('sets isLoadingChartData to false', () => {
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, {});
expect(state.isLoadingChartData).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);
});
});
});
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Api from 'ee/api';
import * as cycleAnalyticsConstants from 'ee/analytics/cycle_analytics/constants';
describe('Api', () => {
const dummyApiVersion = 'v3000';
......@@ -283,4 +284,51 @@ describe('Api', () => {
});
});
});
describe('Cycle analytics', () => {
const groupId = 'counting-54321';
const createdBefore = '2019-11-18';
const createdAfter = '2019-08-18';
describe('cycleAnalyticsTasksByType', () => {
it('fetches tasks by type data', done => {
const tasksByTypeResponse = [
{
label: {
id: 9,
title: 'Thursday',
color: '#7F8C8D',
description: 'What are you waiting for?',
group_id: 2,
project_id: null,
template: false,
text_color: '#FFFFFF',
created_at: '2019-08-20T05:22:49.046Z',
updated_at: '2019-08-20T05:22:49.046Z',
},
series: [['2019-11-03', 5]],
},
];
const labelIds = [10, 9, 8, 7];
const params = {
group_id: groupId,
created_after: createdAfter,
created_before: createdBefore,
project_ids: null,
subject: cycleAnalyticsConstants.TASKS_BY_TYPE_SUBJECT_ISSUE,
label_ids: labelIds,
};
const expectedUrl = `${dummyUrlRoot}/-/analytics/type_of_work/tasks_by_type`;
mock.onGet(expectedUrl).reply(200, tasksByTypeResponse);
Api.cycleAnalyticsTasksByType({ params })
.then(({ data, config: { params: reqParams } }) => {
expect(data).toEqual(tasksByTypeResponse);
expect(reqParams.params).toEqual(params);
})
.then(done)
.catch(done.fail);
});
});
});
});
......@@ -103,4 +103,35 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
expect(response).to be_successful
end
end
describe Analytics::TasksByTypeController, type: :controller do
render_views
let(:label) { create(:group_label, group: group) }
let(:label2) { create(:group_label, group: group) }
let(:label3) { create(:group_label, group: group) }
before do
5.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])
create(:labeled_issue, created_at: i.days.ago, project: create(:project, group: group), labels: [label3])
end
stub_licensed_features(type_of_work_analytics: true)
stub_feature_flags(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG => true)
group.add_maintainer(user)
sign_in(user)
end
it 'analytics/type_of_work/tasks_by_type.json' do
params = { group_id: group.full_path, label_ids: [label.id, label2.id, label3.id], created_after: 10.days.ago, subject: 'Issue' }
get(:show, params: params, format: :json)
expect(response).to be_successful
end
end
end
......@@ -17422,6 +17422,9 @@ msgstr ""
msgid "There was an error fetching cycle analytics stages."
msgstr ""
msgid "There was an error fetching data for the chart"
msgstr ""
msgid "There was an error fetching data for the selected stage"
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