Commit f44b386a authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '214841-fe-time-metrics-api-integration' into 'master'

[FE] Add time metrics group-level Value Stream Analytics

Closes #214841

See merge request gitlab-org/gitlab!31559
parents f9ac8102 816c58c5
......@@ -14,9 +14,11 @@ import TypeOfWorkCharts from './type_of_work_charts.vue';
import UrlSyncMixin from '../../shared/mixins/url_sync_mixin';
import { toYmd } from '../../shared/utils';
import RecentActivityCard from './recent_activity_card.vue';
import TimeMetricsCard from './time_metrics_card.vue';
import StageTableNav from './stage_table_nav.vue';
import CustomStageForm from './custom_stage_form.vue';
import PathNavigation from './path_navigation.vue';
import MetricCard from '../../shared/components/metric_card.vue';
export default {
name: 'CycleAnalytics',
......@@ -30,9 +32,11 @@ export default {
StageTable,
TypeOfWorkCharts,
RecentActivityCard,
TimeMetricsCard,
CustomStageForm,
StageTableNav,
PathNavigation,
MetricCard,
},
mixins: [glFeatureFlagsMixin(), UrlSyncMixin],
props: {
......@@ -265,11 +269,22 @@ export default {
"
/>
<div v-else-if="!errorCode">
<div class="js-recent-activity mt-3">
<recent-activity-card
:group-path="currentGroupPath"
:additional-params="cycleAnalyticsRequestParams"
/>
<div class="js-recent-activity gl-mt-3 gl-display-flex">
<div class="gl-flex-fill-1 gl-pr-2">
<time-metrics-card
#default="{ metrics, loading }"
:group-path="currentGroupPath"
:additional-params="cycleAnalyticsRequestParams"
>
<metric-card :title="__('Time')" :metrics="metrics" :is-loading="loading" />
</time-metrics-card>
</div>
<div class="gl-flex-fill-1 gl-pl-2">
<recent-activity-card
:group-path="currentGroupPath"
:additional-params="cycleAnalyticsRequestParams"
/>
</div>
</div>
<div v-if="isLoading">
<gl-loading-icon class="mt-4" size="md" />
......
<script>
import Api from 'ee/api';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { slugify } from '~/lib/utils/text_utility';
import MetricCard from '../../shared/components/metric_card.vue';
import { timeMetricsData as mockTimeMetricsData } from '../../../../../../spec/frontend/analytics/cycle_analytics/mock_data';
import { removeFlash } from '../utils';
export default {
name: 'TimeMetricsCard',
......@@ -15,7 +19,7 @@ export default {
additionalParams: {
type: Object,
required: false,
default: null,
default: () => ({}),
},
},
data() {
......@@ -34,7 +38,24 @@ export default {
},
methods: {
fetchData() {
this.data = mockTimeMetricsData;
removeFlash();
this.loading = true;
return Api.cycleAnalyticsTimeSummaryData(this.groupPath, this.additionalParams)
.then(({ data }) => {
this.data = data.map(({ title: label, ...rest }) => ({
...rest,
label,
key: slugify(label),
}));
})
.catch(() => {
createFlash(
__('There was an error while fetching value stream analytics time summary data.'),
);
})
.finally(() => {
this.loading = false;
});
},
},
render() {
......
......@@ -26,12 +26,11 @@ export default {
valueText(metric) {
const { value = null, unit = null } = metric;
if (!value) return '-';
return unit ? `${value} ${unit}` : value;
return unit && value ? `${value} ${unit}` : value;
},
},
};
</script>
<template>
<gl-card>
<template #header>
......
......@@ -16,6 +16,7 @@ export default {
cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsTopLabelsPath: '/groups/:id/-/analytics/type_of_work/tasks_by_type/top_labels',
cycleAnalyticsSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/summary',
cycleAnalyticsTimeSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/time_summary',
cycleAnalyticsGroupStagesAndEventsPath: '/groups/:id/-/analytics/value_stream_analytics/stages',
cycleAnalyticsStageEventsPath:
'/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id/records',
......@@ -154,6 +155,12 @@ export default {
return axios.get(url, { params });
},
cycleAnalyticsTimeSummaryData(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsTimeSummaryDataPath).replace(':id', groupId);
return axios.get(url, { params });
},
cycleAnalyticsGroupStagesAndEvents(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath).replace(':id', groupId);
......
---
title: Add lead time and cycle time to value stream analytics
merge_request: 31559
author:
type: added
......@@ -203,10 +203,15 @@ describe 'Group Value Stream Analytics', :js do
context 'summary table', :js do
it 'will display recent activity' do
page.within(find('.js-recent-activity')) do
expect(page).to have_selector('.card-header')
expect(page).to have_content(_('Recent Activity'))
end
end
it 'will display time metrics' do
page.within(find('.js-recent-activity')) do
expect(page).to have_content(_('Time'))
end
end
end
context 'stage panel' do
......@@ -261,14 +266,14 @@ describe 'Group Value Stream Analytics', :js do
it_behaves_like 'group value stream analytics'
it 'displays the number of issues' do
issue_count = page.all(card_metric_selector).first
issue_count = page.all(card_metric_selector)[2]
expect(issue_count).to have_content(n_('New Issue', 'New Issues', 3))
expect(issue_count).to have_content('3')
end
it 'displays the number of deploys' do
deploys_count = page.all(card_metric_selector)[1]
deploys_count = page.all(card_metric_selector)[3]
expect(deploys_count).to have_content(n_('Deploy', 'Deploys', 0))
expect(deploys_count).to have_content('-')
......@@ -280,6 +285,20 @@ describe 'Group Value Stream Analytics', :js do
expect(deployment_frequency).to have_content(_('Deployment Frequency'))
expect(deployment_frequency).to have_content('-')
end
it 'displays the lead time' do
lead_time = page.all(card_metric_selector).first
expect(lead_time).to have_content(_('Lead Time'))
expect(lead_time).to have_content('-')
end
it 'displays the cycle time' do
cycle_time = page.all(card_metric_selector)[1]
expect(cycle_time).to have_content(_('Cycle Time'))
expect(cycle_time).to have_content('-')
end
end
context 'with a sub group selected' do
......
......@@ -27,7 +27,7 @@ exports[`RecentActivityCard matches the snapshot 1`] = `
<h3
class="my-2"
>
3
4
</h3>
<p
......
......@@ -8,6 +8,7 @@ import MockAdapter from 'axios-mock-adapter';
import GroupsDropdownFilter from 'ee/analytics/shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
import RecentActivityCard from 'ee/analytics/cycle_analytics/components/recent_activity_card.vue';
import TimeMetricsCard from 'ee/analytics/cycle_analytics/components/time_metrics_card.vue';
import PathNavigation from 'ee/analytics/cycle_analytics/components/path_navigation.vue';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import 'bootstrap';
......@@ -123,6 +124,10 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(RecentActivityCard).exists()).toBe(flag);
};
const displaysTimeMetricsCard = flag => {
expect(wrapper.find(TimeMetricsCard).exists()).toBe(flag);
};
const displaysStageTable = flag => {
expect(wrapper.find(StageTable).exists()).toBe(flag);
};
......@@ -179,10 +184,14 @@ describe('Cycle Analytics component', () => {
displaysDateRangePicker(false);
});
it('does not display the recent activity table', () => {
it('does not display the recent activity card', () => {
displaysRecentActivityCard(false);
});
it('does not display the time metrics card', () => {
displaysTimeMetricsCard(false);
});
it('does not display the stage table', () => {
displaysStageTable(false);
});
......@@ -243,10 +252,14 @@ describe('Cycle Analytics component', () => {
displaysDateRangePicker(true);
});
it('displays the recent activity table', () => {
it('displays the recent activity card', () => {
displaysRecentActivityCard(true);
});
it('displays the time metrics card', () => {
displaysTimeMetricsCard(true);
});
it('displays the stage table', () => {
displaysStageTable(true);
});
......@@ -352,10 +365,14 @@ describe('Cycle Analytics component', () => {
displaysDateRangePicker(false);
});
it('does not display the recent activity table', () => {
it('does not display the recent activity card', () => {
displaysRecentActivityCard(false);
});
it('does not display the time metrics card', () => {
displaysTimeMetricsCard(false);
});
it('does not display the stage table', () => {
displaysStageTable(false);
});
......
import { shallowMount } from '@vue/test-utils';
import createFlash from '~/flash';
import TimeMetricsCard from 'ee/analytics/cycle_analytics/components/time_metrics_card.vue';
import { group, timeMetricsData } from '../mock_data';
import Api from 'ee/api';
jest.mock('~/flash');
describe('TimeMetricsCard', () => {
const { full_path: groupPath } = group;
let wrapper;
const createComponent = ({ additionalParams = {} } = {}) => {
return shallowMount(TimeMetricsCard, {
propsData: {
groupPath,
additionalParams,
},
slots: {
default: 'mockMetricCard',
},
});
};
beforeEach(() => {
jest.spyOn(Api, 'cycleAnalyticsTimeSummaryData').mockResolvedValue({ data: timeMetricsData });
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('fetches the time metrics data', () => {
expect(Api.cycleAnalyticsTimeSummaryData).toHaveBeenCalledWith(groupPath, {});
});
describe('with a failing request', () => {
beforeEach(() => {
jest.spyOn(Api, 'cycleAnalyticsTimeSummaryData').mockRejectedValue();
wrapper = createComponent();
});
it('should render an error message', () => {
expect(createFlash).toHaveBeenCalledWith(
'There was an error while fetching value stream analytics time summary data.',
);
});
});
describe('with additional params', () => {
beforeEach(() => {
wrapper = createComponent({
additionalParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
});
it('sends additional parameters as query paremeters', () => {
expect(Api.cycleAnalyticsTimeSummaryData).toHaveBeenCalledWith(groupPath, {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
});
});
});
});
......@@ -21,12 +21,14 @@ const fixtureEndpoints = {
stageEvents: stage => `analytics/value_stream_analytics/stages/${stage}/records.json`,
stageMedian: stage => `analytics/value_stream_analytics/stages/${stage}/median.json`,
recentActivityData: 'analytics/value_stream_analytics/summary.json',
timeMetricsData: 'analytics/value_stream_analytics/time_summary.json',
groupLabels: 'api/group_labels.json',
};
export const endpoints = {
groupLabels: /groups\/[A-Z|a-z|\d|\-|_]+\/-\/labels.json/,
recentActivityData: /analytics\/value_stream_analytics\/summary/,
timeMetricsData: /analytics\/value_stream_analytics\/time_summary/,
durationData: /analytics\/value_stream_analytics\/stages\/\d+\/duration_chart/,
stageData: /analytics\/value_stream_analytics\/stages\/\d+\/records/,
stageMedian: /analytics\/value_stream_analytics\/stages\/\d+\/median/,
......@@ -53,10 +55,7 @@ const getStageByTitle = (stages, title) =>
stages.find(stage => stage.title && stage.title.toLowerCase().trim() === title) || {};
export const recentActivityData = getJSONFixture(fixtureEndpoints.recentActivityData);
export const timeMetricsData = [
{ label: 'Lead time', value: '2', unit: 'days' },
{ label: 'Cycle time', value: '1.5', unit: 'days' },
];
export const timeMetricsData = getJSONFixture(fixtureEndpoints.timeMetricsData);
export const customizableStagesAndEvents = getJSONFixture(
fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents,
......
......@@ -379,6 +379,32 @@ describe('Api', () => {
});
});
describe('cycleAnalyticsTimeSummaryData', () => {
it('fetches value stream analytics summary data', done => {
const response = [
{ value: '10.0', title: 'Lead time', unit: 'per day' },
{ value: '2.0', title: 'Cycle Time', unit: 'per day' },
];
const params = {
...defaultParams,
};
const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/time_summary`;
mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsTimeSummaryData(groupId, params)
.then(responseObj =>
expectRequestWithCorrectParameters(responseObj, {
response,
params,
expectedUrl,
}),
)
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsGroupStagesAndEvents', () => {
it('fetches custom stage events and all stages', done => {
const response = { events: [], stages: [] };
......
......@@ -7,9 +7,7 @@ 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(: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) }
......@@ -189,11 +187,22 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
let(:params) { { created_after: 3.months.ago, created_before: Time.now, group_id: group.full_path } }
def prepare_cycle_time_data
issue.update!(created_at: 5.days.ago)
issue.metrics.update!(first_mentioned_in_commit_at: 4.days.ago)
issue.update!(closed_at: 3.days.ago)
issue_1.update!(created_at: 8.days.ago)
issue_1.metrics.update!(first_mentioned_in_commit_at: 6.days.ago)
issue_1.update!(closed_at: 1.day.ago)
end
before do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true)
stub_licensed_features(cycle_analytics_for_groups: true)
prepare_cycle_analytics_data
prepare_cycle_time_data
sign_in(user)
end
......@@ -203,6 +212,12 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
expect(response).to be_successful
end
it 'analytics/value_stream_analytics/time_summary.json' do
get(:time_summary, params: params, format: :json)
expect(response).to be_successful
end
end
describe Analytics::TasksByTypeController, type: :controller do
......
......@@ -21680,6 +21680,9 @@ msgstr ""
msgid "There was an error while fetching value stream analytics recent activity data."
msgstr ""
msgid "There was an error while fetching value stream analytics time summary data."
msgstr ""
msgid "There was an error with the reCAPTCHA. Please solve the reCAPTCHA again."
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