Commit b154d5e0 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch...

Merge branch '34751-fetch-cycle-analytics-stage-and-median-data-from-dedicated-endpoints' into 'master'

"New cycle analytics stage and median data endpoints"

Closes #34751

See merge request gitlab-org/gitlab!19278
parents 56c2e005 9fcd04f8
......@@ -69,6 +69,7 @@ export default {
'startDate',
'endDate',
'tasksByType',
'medians',
]),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']),
shouldRenderEmptyState() {
......@@ -242,6 +243,7 @@ export default {
class="js-stage-table"
:current-stage="selectedStage"
:stages="stages"
:medians="medians"
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-saving-custom-stage="isSavingCustomStage"
......
......@@ -40,13 +40,13 @@ export default {
<limit-warning :count="events.length" />
</div>
<stage-build-item
v-if="isCurrentStage(stage.slug, STAGE_NAME_TEST)"
v-if="isCurrentStage(stage.title, STAGE_NAME_TEST)"
:stage="stage"
:events="events"
:with-build-status="true"
/>
<stage-build-item
v-else-if="isCurrentStage(stage.slug, STAGE_NAME_STAGING)"
v-else-if="isCurrentStage(stage.title, STAGE_NAME_STAGING)"
:stage="stage"
:events="events"
/>
......
<script>
import { GlButton } from '@gitlab/ui';
import StageCardListItem from './stage_card_list_item.vue';
import { approximateDuration } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
import StageCardListItem from './stage_card_list_item.vue';
export default {
name: 'StageNavItem',
......@@ -26,8 +27,8 @@ export default {
required: true,
},
value: {
type: String,
default: '',
type: Number,
default: 0,
required: false,
},
canEdit: {
......@@ -43,7 +44,10 @@ export default {
},
computed: {
hasValue() {
return this.value && this.value.length > 0;
return this.value;
},
median() {
return approximateDuration(this.value);
},
editable() {
return this.canEdit;
......@@ -74,7 +78,7 @@ export default {
{{ title }}
</div>
<div class="stage-nav-item-cell stage-median mr-4">
<span v-if="hasValue">{{ value }}</span>
<span v-if="hasValue">{{ median }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</div>
<div v-show="canEdit && isHover" ref="dropdown" class="dropdown">
......
......@@ -27,6 +27,10 @@ export default {
type: Array,
required: true,
},
medians: {
type: Object,
required: true,
},
currentStage: {
type: Object,
required: true,
......@@ -119,6 +123,11 @@ export default {
return this.isEditingCustomStage ? this.currentStage : {};
},
},
methods: {
medianValue(id) {
return this.medians[id] ? this.medians[id] : null;
},
},
STAGE_ACTIONS,
};
</script>
......@@ -146,7 +155,7 @@ export default {
v-for="stage in stages"
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="stage.value"
:value="medianValue(stage.id)"
:is-active="!isCreatingCustomStage && stage.id === currentStage.id"
:can-edit="canEditStages"
:is-default-stage="!stage.custom"
......
......@@ -29,8 +29,9 @@ export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDat
};
export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA);
export const receiveStageDataSuccess = ({ commit }, data) =>
export const receiveStageDataSuccess = ({ commit }, data) => {
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
};
export const receiveStageDataError = ({ commit }) => {
commit(types.RECEIVE_STAGE_DATA_ERROR);
......@@ -45,15 +46,48 @@ export const fetchStageData = ({ state, dispatch, getters }, slug) => {
dispatch('requestStageData');
return Api.cycleAnalyticsStageEvents(
fullPath,
slug,
nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
)
return Api.cycleAnalyticsStageEvents(fullPath, slug, cycleAnalyticsRequestParams)
.then(({ data }) => dispatch('receiveStageDataSuccess', data))
.catch(error => dispatch('receiveStageDataError', error));
};
export const requestStageMedianValues = ({ commit }) => commit(types.REQUEST_STAGE_MEDIANS);
export const receiveStageMedianValuesSuccess = ({ commit }, data) => {
commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data);
};
export const receiveStageMedianValuesError = ({ commit }) => {
commit(types.RECEIVE_STAGE_MEDIANS_ERROR);
createFlash(__('There was an error fetching median data for stages'));
};
const fetchStageMedian = (currentGroupPath, stageId, params) =>
Api.cycleAnalyticsStageMedian(currentGroupPath, stageId, params).then(({ data }) => ({
id: stageId,
...data,
}));
export const fetchStageMedianValues = ({ state, dispatch, getters }) => {
const {
currentGroupPath,
cycleAnalyticsRequestParams: { created_after, created_before },
} = getters;
const { stages } = state;
const params = {
group_id: currentGroupPath,
created_after,
created_before,
};
dispatch('requestStageMedianValues');
const stageIds = stages.map(s => s.slug);
return Promise.all(stageIds.map(stageId => fetchStageMedian(currentGroupPath, stageId, params)))
.then(data => dispatch('receiveStageMedianValuesSuccess', data))
.catch(err => dispatch('receiveStageMedianValuesError', err));
};
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
export const receiveCycleAnalyticsDataSuccess = ({ state, commit, dispatch }) => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS);
......@@ -76,9 +110,11 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeError();
return dispatch('requestCycleAnalyticsData')
dispatch('requestCycleAnalyticsData');
return Promise.resolve()
.then(() => dispatch('fetchGroupLabels'))
.then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchStageMedianValues'))
.then(() => dispatch('fetchSummaryData'))
.then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
......@@ -216,7 +252,7 @@ export const receiveTasksByTypeDataSuccess = ({ commit }, 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'));
createFlash(__('There was an error fetching data for the tasks by type chart'));
};
export const requestTasksByTypeData = ({ commit }) => commit(types.REQUEST_TASKS_BY_TYPE_DATA);
......
......@@ -15,6 +15,10 @@ export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS';
export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS';
export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR';
export const HIDE_CUSTOM_STAGE_FORM = 'HIDE_CUSTOM_STAGE_FORM';
export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const EDIT_CUSTOM_STAGE = 'EDIT_CUSTOM_STAGE';
......
......@@ -40,11 +40,9 @@ export default {
state.isLoadingStage = true;
state.isEmptyStage = false;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data = {}) {
const { events = [] } = data;
state.currentStageEvents = events.map(({ name = '', ...rest }) =>
convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }),
[types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
state.currentStageEvents = events.map(fields =>
convertObjectPropsToCamelCase(fields, { deep: true }),
);
state.isEmptyStage = !events.length;
state.isLoadingStage = false;
......@@ -60,6 +58,21 @@ export default {
labelIds: [],
};
},
[types.REQUEST_STAGE_MEDIANS](state) {
state.medians = {};
},
[types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians = []) {
state.medians = medians.reduce(
(acc, { id, value }) => ({
...acc,
[id]: value,
}),
{},
);
},
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
},
[types.RECEIVE_GROUP_LABELS_SUCCESS](state, data = []) {
const { tasksByType } = state;
state.labels = data.map(convertObjectPropsToCamelCase);
......@@ -98,24 +111,11 @@ export default {
state.summary = [];
},
[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, data) {
const { stages } = state;
const { summary, stats } = data;
const { summary } = data;
state.summary = summary.map(item => ({
...item,
value: item.value || '-',
}));
/*
* Medians will eventually be fetched from a separate endpoint, which will
* include the median calculations for the custom stages, for now we will
* grab the medians from the group level cycle analytics endpoint, which does
* not include the custom stages
* https://gitlab.com/gitlab-org/gitlab/issues/34751
*/
state.stages = stages.map(stage => {
const stat = stats.find(m => m.name === stage.slug);
return { ...stage, value: stat ? stat.value : null };
});
},
[types.REQUEST_GROUP_STAGES_AND_EVENTS](state) {
state.stages = [];
......
......@@ -27,6 +27,7 @@ export default () => ({
stages: [],
summary: [],
labels: [],
medians: {},
customStageFormEvents: [],
tasksByType: {
......
import { isString } from 'underscore';
import { isString, isNumber } from 'underscore';
import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
......@@ -43,18 +43,19 @@ export const getLabelEventsIdentifiers = (events = []) =>
* default stages get persisted to storage and will have a numeric id. The new numeric
* id should then be used to access stage data
*
* This will be fixed in https://gitlab.com/gitlab-org/gitlab/merge_requests/19278
*/
export const isPersistedStage = ({ custom, id }) => custom || isNumber(id);
export const transformRawStages = (stages = []) =>
stages
.map(({ id, title, custom = false, ...rest }) => ({
.map(({ id, title, name = '', custom = false, ...rest }) => ({
...convertObjectPropsToCamelCase(rest, { deep: true }),
id,
title,
slug: custom ? id : convertToSnakeCase(title),
custom,
name: title, // editing a stage takes 'name' as a parameter, but the api returns title
slug: isPersistedStage({ custom, id }) ? id : convertToSnakeCase(title),
// the name field is used to create a stage, but the get request returns title
name: name.length ? name : title,
}))
.sort((a, b) => a.id > b.id);
......
......@@ -18,7 +18,8 @@ export default {
cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsSummaryDataPath: '/groups/:group_id/-/cycle_analytics',
cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/cycle_analytics/stages',
cycleAnalyticsStageEventsPath: '/groups/:group_id/-/cycle_analytics/events/:stage_id.json',
cycleAnalyticsStageEventsPath: '/-/analytics/cycle_analytics/stages/:stage_id/records',
cycleAnalyticsStageMedianPath: '/-/analytics/cycle_analytics/stages/:stage_id/median',
cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id',
cycleAnalyticsDurationChartPath: '/-/analytics/cycle_analytics/stages/:stage_id/duration_chart',
......@@ -158,11 +159,13 @@ export default {
},
cycleAnalyticsStageEvents(groupId, stageId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsStageEventsPath)
.replace(':group_id', groupId)
.replace(':stage_id', stageId);
const url = Api.buildUrl(this.cycleAnalyticsStageEventsPath).replace(':stage_id', stageId);
return axios.get(url, { params: { ...params, group_id: groupId } });
},
return axios.get(url, { params });
cycleAnalyticsStageMedian(groupId, stageId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsStageMedianPath).replace(':stage_id', stageId);
return axios.get(url, { params: { ...params, group_id: groupId } });
},
cycleAnalyticsCreateStage(groupId, data) {
......
......@@ -167,11 +167,11 @@ describe 'Group Cycle Analytics', :js do
dummy_stages = [
{ title: "Issue", description: "Time before an issue gets scheduled", events_count: 1, median: "5 days" },
{ title: "Plan", description: "Time before an issue starts implementation", events_count: 1, median: "Not enough data" },
{ title: "Code", description: "Time until first merge request", events_count: 1, median: "less than a minute" },
{ title: "Test", description: "Total test time for all commits/merges", events_count: 1, median: "Not enough data" },
{ title: "Review", description: "Time between merge request creation and merge/close", events_count: 1, median: "less than a minute" },
{ title: "Staging", description: "From merge request merge until deploy to production", events_count: 1, median: "less than a minute" },
{ title: "Plan", description: "Time before an issue starts implementation", events_count: 0, median: "Not enough data" },
{ title: "Code", description: "Time until first merge request", events_count: 0, median: "Not enough data" },
{ title: "Test", description: "Total test time for all commits/merges", events_count: 0, median: "Not enough data" },
{ title: "Review", description: "Time between merge request creation and merge/close", events_count: 0, median: "Not enough data" },
{ title: "Staging", description: "From merge request merge until deploy to production", events_count: 0, median: "Not enough data" },
{ title: "Production", description: "From issue creation until deploy to production", events_count: 1, median: "5 days" }
]
......@@ -187,7 +187,11 @@ describe 'Group Cycle Analytics', :js do
dummy_stages.each do |stage|
select_stage(stage[:title])
expect(page.find('.stage-events .events-description').text).to have_text(stage[:description])
if stage[:events_count] == 0
expect(page).not_to have_selector('.stage-events .events-description')
else
expect(page.find('.stage-events .events-description').text).to have_text(stage[:description])
end
end
end
......
......@@ -6,10 +6,10 @@ Array [
"custom": false,
"description": "Time before an issue gets scheduled",
"hidden": false,
"id": "issue",
"id": 1,
"legend": "",
"name": "Issue",
"slug": "issue",
"slug": 1,
"title": "Issue",
},
Object {
......
......@@ -6,6 +6,10 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display
</button>"
`;
exports[`CustomStageForm Empty form Start event with events does not select events with canBeStartEvent=false for the start events dropdown 1`] = `"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__122\\"><option value=\\"\\">Select start event</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"code_stage_start\\">Issue first mentioned in a commit</option><option value=\\"plan_stage_start\\">Issue first associated with a milestone or issue first added to a board</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_created\\">Issue created</option></select>"`;
exports[`CustomStageForm Empty form Start event with events selects events with canBeStartEvent=true for the start events dropdown 1`] = `"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__94\\"><option value=\\"\\">Select start event</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"code_stage_start\\">Issue first mentioned in a commit</option><option value=\\"plan_stage_start\\">Issue first associated with a milestone or issue first added to a board</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_created\\">Issue created</option></select>"`;
exports[`CustomStageForm Empty form isSavingCustomStage=true displays a loading icon 1`] = `
"<button disabled=\\"disabled\\" type=\\"button\\" class=\\"js-save-stage btn btn-success\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" aria-hidden=\\"true\\" class=\\"gl-spinner gl-spinner-orange gl-spinner-sm\\"></span></span>
Add stage
......
......@@ -61,9 +61,7 @@ function createComponent({
...mockData.customizableStagesAndEvents,
});
comp.vm.$store.dispatch('receiveStageDataSuccess', {
events: mockData.issueEvents,
});
comp.vm.$store.dispatch('receiveStageDataSuccess', mockData.issueEvents);
}
return comp;
}
......@@ -355,7 +353,12 @@ describe('Cycle Analytics component', () => {
describe('with failed requests while loading', () => {
const { full_path: groupId } = mockData.group;
function mockRequestCycleAnalyticsData(overrides = {}, includeDurationDataRequests = true) {
function mockRequestCycleAnalyticsData({
overrides = {},
mockFetchStageData = true,
mockFetchStageMedian = true,
mockFetchDurationData = true,
}) {
const defaultStatus = 200;
const defaultRequests = {
fetchSummaryData: {
......@@ -373,12 +376,6 @@ describe('Cycle Analytics component', () => {
endpoint: `/groups/${groupId}/-/labels`,
response: [...mockData.groupLabels],
},
fetchStageData: {
status: defaultStatus,
// default first stage is issue
endpoint: '/groups/foo/-/cycle_analytics/events/issue.json',
response: [...mockData.issueEvents],
},
fetchTasksByTypeData: {
status: defaultStatus,
endpoint: '/-/analytics/type_of_work/tasks_by_type',
......@@ -387,12 +384,22 @@ describe('Cycle Analytics component', () => {
...overrides,
};
if (includeDurationDataRequests) {
mockData.defaultStages.forEach(stage => {
mock
.onGet(`${baseStagesEndpoint}/${stage}/duration_chart`)
.replyOnce(defaultStatus, [...mockData.rawDurationData]);
});
if (mockFetchDurationData) {
mock
.onGet(/analytics\/cycle_analytics\/stages\/\d+\/duration_chart/)
.reply(defaultStatus, [...mockData.rawDurationData]);
}
if (mockFetchStageMedian) {
mock
.onGet(/analytics\/cycle_analytics\/stages\/\d+\/median/)
.reply(defaultStatus, { value: null });
}
if (mockFetchStageData) {
mock
.onGet(/analytics\/cycle_analytics\/stages\/\d+\/records/)
.reply(defaultStatus, mockData.issueEvents);
}
Object.values(defaultRequests).forEach(({ endpoint, status, response }) => {
......@@ -425,10 +432,12 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchSummaryData: {
status: httpStatusCodes.NOT_FOUND,
endpoint: `/groups/${groupId}/-/cycle_analytics`,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
overrides: {
fetchSummaryData: {
status: httpStatusCodes.NOT_FOUND,
endpoint: `/groups/${groupId}/-/cycle_analytics`,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
},
});
......@@ -441,9 +450,11 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchGroupLabels: {
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
overrides: {
fetchGroupLabels: {
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
},
});
......@@ -456,10 +467,12 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchGroupStagesAndEvents: {
endPoint: '/-/analytics/cycle_analytics/stages',
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
overrides: {
fetchGroupStagesAndEvents: {
endPoint: '/-/analytics/cycle_analytics/stages',
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
},
});
......@@ -470,11 +483,7 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchStageData: {
endPoint: `/groups/${groupId}/-/cycle_analytics/events/issue.json`,
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
mockFetchStageData: false,
});
return selectGroupAndFindError('There was an error fetching data for the selected stage');
......@@ -484,27 +493,41 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchTasksByTypeData: {
endPoint: '/-/analytics/type_of_work/tasks_by_type',
status: httpStatusCodes.BAD_REQUEST,
response: { response: { status: httpStatusCodes.BAD_REQUEST } },
overrides: {
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');
return selectGroupAndFindError(
'There was an error fetching data for the tasks by type chart',
);
});
it('will display an error if the fetchDurationData request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({}, false);
mockRequestCycleAnalyticsData({
mockFetchDurationData: false,
});
mockData.defaultStages.forEach(stage => {
mock
.onGet(`${baseStagesEndpoint}/${stage}/duration_chart`)
.replyOnce(httpStatusCodes.NOT_FOUND, {
response: { status: httpStatusCodes.NOT_FOUND },
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().catch(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error while fetching cycle analytics duration data.',
);
});
});
it('will display an error if the fetchStageMedian request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
mockFetchStageMedian: false,
});
wrapper.vm.onGroupSelect(mockData.group);
......
......@@ -97,12 +97,20 @@ describe('CustomStageForm', () => {
it('selects events with canBeStartEvent=true for the start events dropdown', () => {
const select = wrapper.find(sel.startEvent);
expect(select.html()).toMatchSnapshot();
});
startEvents.forEach(ev => {
expect(select.html()).toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`,
);
});
it('does not select events with canBeStartEvent=false for the start events dropdown', () => {
const select = wrapper.find(sel.startEvent);
expect(select.html()).toMatchSnapshot();
stopEvents
.filter(ev => !ev.canBeStartEvent)
.forEach(ev => {
expect(select.html()).not.toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`,
);
});
});
});
......
import { shallowMount, mount } from '@vue/test-utils';
import StageBuildItem from 'ee/analytics/cycle_analytics/components/stage_build_item.vue';
import { renderTotalTime } from '../helpers';
import { testStage as stage, testEvents as events } from '../mock_data';
import { stagingStage as stage, stagingEvents as events } from '../mock_data';
function createComponent(props = {}, shallow = true) {
const func = shallow ? shallowMount : mount;
......@@ -109,6 +109,7 @@ describe('StageBuildItem', () => {
beforeEach(() => {
wrapper = createComponent({ withBuildStatus: true }, false);
});
afterEach(() => {
wrapper.destroy();
});
......
......@@ -102,8 +102,12 @@ describe('Stage', () => {
${'Code'} | ${codeStage} | ${codeEvents}
${'Production'} | ${productionStage} | ${productionEvents}
`('$name stage will render the list of events', ({ stage, eventList }) => {
wrapper = createComponent({ props: { stage, events: eventList } });
eventList.forEach((item, index) => {
// stages generated from fixtures may not have events
const events = eventList.length ? eventList : generateEvents(5);
wrapper = createComponent({
props: { stage, events },
});
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.title);
});
......
// NOTE: more tests will be added in https://gitlab.com/gitlab-org/gitlab/issues/121613
import { shallowMount } from '@vue/test-utils';
import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue';
import { approximateDuration } from '~/lib/utils/datetime_utility';
describe('StageNavItem', () => {
const title = 'Rad stage';
const median = 50;
const $sel = {
title: '.stage-name',
median: '.stage-median',
};
function createComponent(props) {
return shallowMount(StageNavItem, {
propsData: {
title,
value: median,
...props,
},
});
}
let wrapper = null;
const findStageTitle = () => wrapper.find($sel.title);
const findStageMedian = () => wrapper.find($sel.median);
afterEach(() => {
wrapper.destroy();
});
it('with no median value', () => {
wrapper = createComponent({ value: null });
expect(findStageMedian().text()).toEqual('Not enough data');
});
describe('with data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the median value', () => {
expect(findStageMedian().text()).toEqual(approximateDuration(median));
});
it('renders the stage title', () => {
expect(findStageTitle().text()).toEqual(title);
});
});
});
......@@ -7,6 +7,7 @@ import {
allowedStages,
groupLabels,
customStageEvents,
stageMedians as medians,
} from '../mock_data';
let wrapper = null;
......@@ -43,6 +44,7 @@ function createComponent(props = {}, shallow = false) {
noAccessSvgPath,
canEditStages: false,
customStageFormEvents: customStageEvents,
medians,
...props,
},
stubs: {
......
......@@ -14,7 +14,8 @@ import { mockLabels } from '../../../../../spec/javascripts/vue_shared/component
const endpoints = {
cycleAnalyticsData: 'cycle_analytics/mock_data.json', // existing cycle analytics data
customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint
stageEvents: stage => `cycle_analytics/events/${stage}.json`,
stageEvents: stage => `analytics/cycle_analytics/stages/${stage}/records.json`,
stageMedian: stage => `analytics/cycle_analytics/stages/${stage}/median.json`,
};
export const groupLabels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title }));
......@@ -27,7 +28,8 @@ export const group = {
avatar_url: `${TEST_HOST}/images/home/nasa.svg`,
};
const getStageById = (stages, id) => stages.find(stage => stage.id === id) || {};
const getStageByTitle = (stages, title) =>
stages.find(stage => stage.title && stage.title.toLowerCase().trim() === title) || {};
export const cycleAnalyticsData = getJSONFixture(endpoints.cycleAnalyticsData);
......@@ -40,41 +42,47 @@ const dummyState = {};
// prepare the raw stage data for our components
mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](dummyState, customizableStagesAndEvents);
export const issueStage = getStageById(dummyState.stages, 'issue');
export const planStage = getStageById(dummyState.stages, 'plan');
export const reviewStage = getStageById(dummyState.stages, 'review');
export const codeStage = getStageById(dummyState.stages, 'code');
export const testStage = getStageById(dummyState.stages, 'test');
export const stagingStage = getStageById(dummyState.stages, 'staging');
export const productionStage = getStageById(dummyState.stages, 'production');
export const issueStage = getStageByTitle(dummyState.stages, 'issue');
export const planStage = getStageByTitle(dummyState.stages, 'plan');
export const reviewStage = getStageByTitle(dummyState.stages, 'review');
export const codeStage = getStageByTitle(dummyState.stages, 'code');
export const testStage = getStageByTitle(dummyState.stages, 'test');
export const stagingStage = getStageByTitle(dummyState.stages, 'staging');
export const productionStage = getStageByTitle(dummyState.stages, 'production');
export const allowedStages = [issueStage, planStage, codeStage];
const rawIssueEvents = getJSONFixture('cycle_analytics/events/issue.json');
export const rawEvents = rawIssueEvents.events;
const deepCamelCase = obj => convertObjectPropsToCamelCase(obj, { deep: true });
export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging', 'production'];
const stageFixtures = defaultStages.reduce((acc, stage) => {
const { events } = getJSONFixture(endpoints.stageEvents(stage));
const events = getJSONFixture(endpoints.stageEvents(stage));
return {
...acc,
[stage]: deepCamelCase(events),
[stage]: events,
};
}, {});
export const stageMedians = defaultStages.reduce((acc, stage) => {
const { value } = getJSONFixture(endpoints.stageMedian(stage));
return {
...acc,
[stage]: value,
};
}, {});
export const endDate = new Date(2019, 0, 14);
export const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST);
export const issueEvents = stageFixtures.issue;
export const planEvents = stageFixtures.plan;
export const reviewEvents = stageFixtures.review;
export const codeEvents = stageFixtures.code;
export const testEvents = stageFixtures.test;
export const stagingEvents = stageFixtures.staging;
export const productionEvents = stageFixtures.production;
export const rawIssueEvents = stageFixtures.issue;
export const issueEvents = deepCamelCase(stageFixtures.issue);
export const planEvents = deepCamelCase(stageFixtures.plan);
export const reviewEvents = deepCamelCase(stageFixtures.review);
export const codeEvents = deepCamelCase(stageFixtures.code);
export const testEvents = deepCamelCase(stageFixtures.test);
export const stagingEvents = deepCamelCase(stageFixtures.staging);
export const productionEvents = deepCamelCase(stageFixtures.production);
export const rawCustomStage = {
title: 'Coolest beans stage',
hidden: false,
......@@ -86,6 +94,8 @@ export const rawCustomStage = {
end_event_identifier: 'issue_first_added_to_board',
};
export const medians = stageMedians;
const { events: rawCustomStageEvents } = customizableStagesAndEvents;
const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
......@@ -130,12 +140,12 @@ export const rawDurationData = [
export const transformedDurationData = [
{
slug: 'issue',
slug: 1,
selected: true,
data: rawDurationData,
},
{
slug: 'plan',
slug: 2,
selected: true,
data: rawDurationData,
},
......
......@@ -4,7 +4,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
cycleAnalyticsData,
rawEvents,
rawIssueEvents,
issueEvents as transformedEvents,
issueStage,
planStage,
......@@ -58,6 +58,8 @@ describe('Cycle analytics mutations', () => {
${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);
......@@ -87,7 +89,7 @@ describe('Cycle analytics mutations', () => {
describe(`${types.RECEIVE_STAGE_DATA_SUCCESS}`, () => {
it('will set the currentStageEvents state item with the camelCased events', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events: rawEvents });
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, rawIssueEvents);
expect(state.currentStageEvents).toEqual(transformedEvents);
});
......@@ -99,7 +101,7 @@ describe('Cycle analytics mutations', () => {
});
it('will set isEmptyStage=false if currentStageEvents.length > 0', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events: rawEvents });
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, rawIssueEvents);
expect(state.isEmptyStage).toEqual(false);
});
......@@ -170,20 +172,6 @@ describe('Cycle analytics mutations', () => {
mutations[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, {
...cycleAnalyticsData,
summary: [{ value: 0, title: 'New Issues' }, { value: 0, title: 'Deploys' }],
stats: [
{
name: 'issue',
value: '1 day ago',
},
{
name: 'plan',
value: '6 months ago',
},
{
name: 'test',
value: null,
},
],
});
});
......@@ -193,31 +181,6 @@ describe('Cycle analytics mutations', () => {
{ value: '-', title: 'Deploys' },
]);
});
it('will set the median value for each stage', () => {
expect(state.stages).toEqual([
{ slug: 'plan', value: '6 months ago' },
{ slug: 'issue', value: '1 day ago' },
{ slug: 'test', value: null },
]);
});
describe('with hidden stages', () => {
const mockStages = customizableStagesAndEvents.stages;
beforeEach(() => {
mockStages[0].hidden = true;
mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, {
...customizableStagesAndEvents.events,
stages: mockStages,
});
});
it('will only return stages that are not hidden', () => {
expect(state.stages.map(({ id }) => id)).not.toContain(mockStages[0].id);
});
});
});
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR}`, () => {
......@@ -259,4 +222,19 @@ describe('Cycle analytics mutations', () => {
expect(stateWithData.durationData).toBe(transformedDurationData);
});
});
describe(`${types.RECEIVE_STAGE_MEDIANS_SUCCESS}`, () => {
it('sets each id as a key in the median object with the corresponding value', () => {
const stateWithData = {
medians: {},
};
mutations[types.RECEIVE_STAGE_MEDIANS_SUCCESS](stateWithData, [
{ id: 1, value: 20 },
{ id: 2, value: 10 },
]);
expect(stateWithData.medians).toEqual({ '1': 20, '2': 10 });
});
});
});
......@@ -9,6 +9,7 @@ import {
flattenDurationChartData,
getDurationChartData,
transformRawStages,
isPersistedStage,
} from 'ee/analytics/cycle_analytics/utils';
import {
customStageEvents as events,
......@@ -177,5 +178,25 @@ describe('Cycle analytics utils', () => {
expect(t.slug).toEqual(t.id);
});
});
it('sets the name to the value of the stage title if its not set', () => {
const transformed = transformRawStages([issueStage, rawCustomStage]);
transformed.forEach(t => {
expect(t.name.length > 0).toBe(true);
expect(t.name).toEqual(t.title);
});
});
});
describe('isPersistedStage', () => {
it.each`
custom | id | expected
${true} | ${'this-is-a-string'} | ${true}
${true} | ${42} | ${true}
${false} | ${42} | ${true}
${false} | ${'this-is-a-string'} | ${false}
`('with custom=$custom and id=$id', ({ custom, id, expected }) => {
expect(isPersistedStage({ custom, id })).toEqual(expected);
});
});
});
......@@ -422,11 +422,11 @@ describe('Api', () => {
it('fetches stage events', done => {
const response = { events: [] };
const params = {
'cycle_analytics[group_id]': groupId,
'cycle_analytics[created_after]': createdAfter,
'cycle_analytics[created_before]': createdBefore,
group_id: groupId,
created_after: createdAfter,
created_before: createdBefore,
};
const expectedUrl = `${dummyUrlRoot}/groups/${groupId}/-/cycle_analytics/events/${stageId}.json`;
const expectedUrl = `${dummyUrlRoot}/-/analytics/cycle_analytics/stages/${stageId}/records`;
mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsStageEvents(groupId, stageId, params)
......@@ -442,6 +442,30 @@ describe('Api', () => {
});
});
describe('cycleAnalyticsStageMedian', () => {
it('fetches stage events', done => {
const response = { value: '5 days ago' };
const params = {
group_id: groupId,
created_after: createdAfter,
created_before: createdBefore,
};
const expectedUrl = `${dummyUrlRoot}/-/analytics/cycle_analytics/stages/${stageId}/median`;
mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsStageMedian(groupId, stageId, params)
.then(responseObj =>
expectRequestWithCorrectParameters(responseObj, {
response,
params,
expectedUrl,
}),
)
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsCreateStage', () => {
it('submit the custom stage data', done => {
const response = {};
......
......@@ -7,43 +7,85 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
let(:group) { create(:group)}
let(:project) { create(:project, :repository, namespace: group) }
let(:user) { create(:user, :admin) }
let(:issue) { create(:issue, project: project, created_at: 4.days.ago) }
# let(:issue) { create(:issue, project: project, created_at: 4.days.ago) }
let(:milestone) { create(:milestone, project: project) }
# let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:issue) { create(:issue, project: project, created_at: 4.days.ago) }
let(:issue_1) { create(:issue, project: project, created_at: 5.days.ago) }
let(:issue_2) { create(:issue, project: project, created_at: 4.days.ago) }
let(:issue_3) { create(:issue, project: project, created_at: 3.days.ago) }
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(:build) { create(:ci_build, :success, pipeline: pipeline, author: user) }
let(:mr_1) { create(:merge_request, source_project: project, allow_broken: true, created_at: 20.days.ago) }
let(:mr_2) { create(:merge_request, source_project: project, allow_broken: true, created_at: 19.days.ago) }
let(:mr_3) { create(:merge_request, source_project: project, allow_broken: true, created_at: 18.days.ago) }
let!(:issue_1) { create(:issue, project: project, created_at: 5.days.ago) }
let!(:issue_2) { create(:issue, project: project, created_at: 4.days.ago) }
let!(:issue_3) { create(:issue, project: project, created_at: 3.days.ago) }
let(:pipeline_1) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr_1.source_branch, sha: mr_1.source_branch_sha, head_pipeline_of: mr_1) }
let(:pipeline_2) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr_2.source_branch, sha: mr_2.source_branch_sha, head_pipeline_of: mr_2) }
let(:pipeline_3) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr_3.source_branch, sha: mr_3.source_branch_sha, head_pipeline_of: mr_3) }
let!(:mr_1) { create_merge_request_closing_issue(user, project, issue_1) }
let!(:mr_2) { create_merge_request_closing_issue(user, project, issue_2) }
let!(:mr_3) { create_merge_request_closing_issue(user, project, issue_3) }
let(:build_1) { create(:ci_build, :success, pipeline: pipeline_1, author: user) }
let(:build_2) { create(:ci_build, :success, pipeline: pipeline_2, author: user) }
let(:build_3) { create(:ci_build, :success, pipeline: pipeline_3, author: user) }
def prepare_cycle_analytics_data
group.add_maintainer(user)
project.add_maintainer(user)
create_cycle(user, project, issue, mr, milestone, pipeline)
create_cycle(user, project, issue_2, mr_2, milestone, pipeline)
create_cycle(user, project, issue_1, mr_1, milestone, pipeline_1)
create_cycle(user, project, issue_2, mr_2, milestone, pipeline_2)
create_commit_referencing_issue(issue_1)
create_commit_referencing_issue(issue_2)
create_merge_request_closing_issue(user, project, issue_1)
create_merge_request_closing_issue(user, project, issue_2)
merge_merge_requests_closing_issue(user, project, issue_3)
deploy_master(user, project, environment: 'staging')
deploy_master(user, project)
end
def update_metrics
issue_1.metrics.update(first_added_to_board_at: 3.days.ago, first_mentioned_in_commit_at: 2.days.ago)
issue_2.metrics.update(first_added_to_board_at: 2.days.ago, first_mentioned_in_commit_at: 1.day.ago)
mr_1.metrics.update!({
merged_at: 5.days.ago,
first_deployed_to_production_at: 1.day.ago,
latest_build_started_at: 5.days.ago,
latest_build_finished_at: 1.day.ago,
pipeline: build_1.pipeline
})
mr_2.metrics.update!({
merged_at: 10.days.ago,
first_deployed_to_production_at: 5.days.ago,
latest_build_started_at: 9.days.ago,
latest_build_finished_at: 7.days.ago,
pipeline: build_2.pipeline
})
end
def additional_cycle_analytics_metrics
create(:cycle_analytics_group_stage, parent: group)
update_metrics
create_cycle(user, project, issue_1, mr_1, milestone, pipeline_1)
create_cycle(user, project, issue_2, mr_2, milestone, pipeline_2)
create_cycle(user, project, issue_3, mr_3, milestone, pipeline_3)
deploy_master(user, project, environment: 'staging')
end
before(:all) do
clean_frontend_fixtures('analytics/')
clean_frontend_fixtures('cycle_analytics/')
end
default_stages = %w[issue plan review code test staging production]
describe Groups::CycleAnalytics::EventsController, type: :controller do
render_views
......@@ -55,8 +97,6 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
sign_in(user)
end
default_stages = %w[issue plan review code test staging production]
default_stages.each do |endpoint|
it "cycle_analytics/events/#{endpoint}.json" do
get endpoint, params: { group_id: group, format: :json }
......@@ -90,10 +130,21 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
describe Analytics::CycleAnalytics::StagesController, type: :controller do
render_views
let(:params) { { created_after: 3.months.ago, created_before: Time.now, group_id: group.full_path } }
before do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true)
stub_licensed_features(cycle_analytics_for_groups: true)
# Persist the default stages
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |params|
group.cycle_analytics_stages.build(params).save!
end
prepare_cycle_analytics_data
additional_cycle_analytics_metrics
sign_in(user)
end
......@@ -102,6 +153,22 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
expect(response).to be_successful
end
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.each do |stage|
it "analytics/cycle_analytics/stages/#{stage[:name]}/records.json" do
stage_id = group.cycle_analytics_stages.find_by(name: stage[:name]).id
get(:records, params: params.merge({ id: stage_id }), format: :json)
expect(response).to be_successful
end
it "analytics/cycle_analytics/stages/#{stage[:name]}/median.json" do
stage_id = group.cycle_analytics_stages.find_by(name: stage[:name]).id
get(:median, params: params.merge({ id: stage_id }), format: :json)
expect(response).to be_successful
end
end
end
describe Analytics::TasksByTypeController, type: :controller do
......
......@@ -18239,15 +18239,18 @@ msgstr ""
msgid "There was an error fetching cycle analytics stages."
msgstr ""
msgid "There was an error fetching data for the chart"
msgid "There was an error fetching data for the selected stage"
msgstr ""
msgid "There was an error fetching data for the selected stage"
msgid "There was an error fetching data for the tasks by type chart"
msgstr ""
msgid "There was an error fetching label data for the selected group"
msgstr ""
msgid "There was an error fetching median data for stages"
msgstr ""
msgid "There was an error fetching the Designs"
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