Commit 9a470bed authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '329635-vsa-refactor-metrics-component' into 'master'

Refactor VSA metrics component

See merge request gitlab-org/gitlab!61869
parents 4072eeb8 df77e527
<script>
import { OVERVIEW_METRICS } from '../constants';
import TimeMetricsCard from './time_metrics_card.vue';
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import Api from 'ee/api';
import createFlash from '~/flash';
import { sprintf, __, s__ } from '~/locale';
import { OVERVIEW_METRICS, METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
const requestData = ({ requestType, groupPath, requestParams }) => {
return requestType === OVERVIEW_METRICS.TIME_SUMMARY
? Api.cycleAnalyticsTimeSummaryData(groupPath, requestParams)
: Api.cycleAnalyticsSummaryData(groupPath, requestParams);
};
export default {
name: 'OverviewActivity',
components: {
TimeMetricsCard,
GlSkeletonLoading,
GlSingleStat,
GlPopover,
},
props: {
groupPath: {
......@@ -17,22 +30,88 @@ export default {
required: true,
},
},
overviewMetrics: OVERVIEW_METRICS,
data() {
return {
metrics: [],
isLoading: false,
};
},
watch: {
requestParams() {
this.fetchData();
},
},
mounted() {
this.fetchData();
},
methods: {
fetchData() {
removeFlash();
this.isLoading = true;
Promise.all([
this.fetchMetricsByType(OVERVIEW_METRICS.TIME_SUMMARY),
this.fetchMetricsByType(OVERVIEW_METRICS.RECENT_ACTIVITY),
])
.then(([timeSummaryData = [], recentActivityData = []]) => {
this.metrics = [
...prepareTimeMetricsData(timeSummaryData, METRICS_POPOVER_CONTENT),
...prepareTimeMetricsData(recentActivityData, METRICS_POPOVER_CONTENT),
];
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
});
},
fetchMetricsByType(requestType) {
return requestData({
requestType,
groupPath: this.groupPath,
requestParams: this.requestParams,
})
.then(({ data }) => data)
.catch(() => {
const requestTypeName =
requestType === OVERVIEW_METRICS.TIME_SUMMARY
? __('time summary')
: __('recent activity');
createFlash({
message: sprintf(
s__(
'There was an error while fetching value stream analytics %{requestTypeName} data.',
),
{ requestTypeName },
),
});
});
},
},
};
</script>
<template>
<div class="gl-display-flex gl-sm-flex-direction-column" data-testid="vsa-time-metrics">
<time-metrics-card
class="gl-display-flex gl-my-6"
:group-path="groupPath"
:additional-params="requestParams"
:request-type="$options.overviewMetrics.TIME_SUMMARY"
/>
<time-metrics-card
class="gl-display-flex gl-my-6"
:group-path="groupPath"
:additional-params="requestParams"
:request-type="$options.overviewMetrics.RECENT_ACTIVITY"
<div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics">
<div v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6">
<gl-skeleton-loading />
</div>
<template v-else>
<div v-for="metric in metrics" :key="metric.key" class="gl-my-6 gl-pr-9">
<gl-single-stat
:id="metric.key"
:value="`${metric.value}`"
:title="metric.label"
:unit="metric.unit || ''"
:should-animate="true"
tabindex="0"
/>
<gl-popover :target="metric.key" placement="bottom">
<template #title>
<span class="gl-display-block gl-text-left">{{ metric.label }}</span>
</template>
<span v-if="metric.description">{{ metric.description }}</span>
</gl-popover>
</div>
</template>
</div>
</template>
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import Api from 'ee/api';
import createFlash from '~/flash';
import { sprintf, __, s__ } from '~/locale';
import { OVERVIEW_METRICS } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
const POPOVER_CONTENT = {
'lead-time': {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
},
'cycle-time': {
description: s__(
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
),
},
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
'deployment-frequency': {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
};
const requestData = ({ requestType, groupPath, additionalParams }) => {
return requestType === OVERVIEW_METRICS.TIME_SUMMARY
? Api.cycleAnalyticsTimeSummaryData(groupPath, additionalParams)
: Api.cycleAnalyticsSummaryData(groupPath, additionalParams);
};
export default {
name: 'TimeMetricsCard',
components: {
GlSkeletonLoading,
GlSingleStat,
GlPopover,
},
props: {
groupPath: {
type: String,
required: true,
},
additionalParams: {
type: Object,
required: false,
default: () => ({}),
},
requestType: {
type: String,
required: true,
validator: (t) => OVERVIEW_METRICS[t],
},
},
data() {
return {
data: [],
loading: false,
};
},
watch: {
additionalParams() {
this.fetchData();
},
},
mounted() {
this.fetchData();
},
methods: {
fetchData() {
removeFlash();
this.loading = true;
return requestData(this)
.then(({ data }) => {
this.data = prepareTimeMetricsData(data, POPOVER_CONTENT);
})
.catch(() => {
const requestTypeName =
this.requestType === OVERVIEW_METRICS.TIME_SUMMARY
? __('time summary')
: __('recent activity');
createFlash({
message: sprintf(
s__(
'There was an error while fetching value stream analytics %{requestTypeName} data.',
),
{ requestTypeName },
),
});
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<template>
<div>
<div v-if="loading" class="gl-h-auto gl-py-3 gl-pr-9">
<gl-skeleton-loading />
</div>
<template v-else>
<div v-for="metric in data" :key="metric.key" class="gl-pr-9">
<gl-single-stat
:id="metric.key"
:value="`${metric.value}`"
:title="metric.label"
:unit="metric.unit || ''"
:should-animate="true"
tabindex="0"
/>
<gl-popover :target="metric.key" placement="bottom">
<template #title>
<span class="gl-display-block gl-text-left">{{ metric.label }}</span>
</template>
<span v-if="metric.description">{{ metric.description }}</span>
</gl-popover>
</div>
</template>
</div>
</template>
......@@ -69,3 +69,19 @@ export const PAGINATION_SORT_FIELD_END_EVENT = 'end_event';
export const PAGINATION_SORT_FIELD_DURATION = 'duration';
export const PAGINATION_SORT_DIRECTION_DESC = 'desc';
export const PAGINATION_SORT_DIRECTION_ASC = 'asc';
export const METRICS_POPOVER_CONTENT = {
'lead-time': {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
},
'cycle-time': {
description: s__(
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
),
},
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
'deployment-frequency': {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
};
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Metrics renders the recent activity 1`] = `"<time-metrics-card-stub grouppath=\\"foo\\" additionalparams=\\"[object Object]\\" requesttype=\\"RECENT_ACTIVITY\\" class=\\"gl-display-flex gl-my-6\\"></time-metrics-card-stub>"`;
exports[`Metrics renders the time summary 1`] = `"<time-metrics-card-stub grouppath=\\"foo\\" additionalparams=\\"[object Object]\\" requesttype=\\"TIME_SUMMARY\\" class=\\"gl-display-flex gl-my-6\\"></time-metrics-card-stub>"`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TimeMetricsCard Recent activity renders the Recent activity metric 1`] = `
"<div>
<div class=\\"gl-pr-9\\">
<gl-single-stat-stub title=\\"New Issues\\" value=\\"4\\" unit=\\"\\" variant=\\"muted\\" shouldanimate=\\"true\\" animationdecimalplaces=\\"0\\" id=\\"new-issues\\" tabindex=\\"0\\"></gl-single-stat-stub>
<gl-popover-stub cssclasses=\\"\\" target=\\"new-issues\\" placement=\\"bottom\\"> <span>Number of new issues created.</span></gl-popover-stub>
</div>
<div class=\\"gl-pr-9\\">
<gl-single-stat-stub title=\\"Deploys\\" value=\\"-\\" unit=\\"\\" variant=\\"muted\\" shouldanimate=\\"true\\" animationdecimalplaces=\\"0\\" id=\\"deploys\\" tabindex=\\"0\\"></gl-single-stat-stub>
<gl-popover-stub cssclasses=\\"\\" target=\\"deploys\\" placement=\\"bottom\\"> <span>Total number of deploys to production.</span></gl-popover-stub>
</div>
<div class=\\"gl-pr-9\\">
<gl-single-stat-stub title=\\"Deployment Frequency\\" value=\\"-\\" unit=\\"per day\\" variant=\\"muted\\" shouldanimate=\\"true\\" animationdecimalplaces=\\"0\\" id=\\"deployment-frequency\\" tabindex=\\"0\\"></gl-single-stat-stub>
<gl-popover-stub cssclasses=\\"\\" target=\\"deployment-frequency\\" placement=\\"bottom\\"> <span>Average number of deployments to production per day.</span></gl-popover-stub>
</div>
</div>"
`;
exports[`TimeMetricsCard Time summary renders the Time summary metric 1`] = `
"<div>
<div class=\\"gl-pr-9\\">
<gl-single-stat-stub title=\\"Lead Time\\" value=\\"4.5\\" unit=\\"days\\" variant=\\"muted\\" shouldanimate=\\"true\\" animationdecimalplaces=\\"0\\" id=\\"lead-time\\" tabindex=\\"0\\"></gl-single-stat-stub>
<gl-popover-stub cssclasses=\\"\\" target=\\"lead-time\\" placement=\\"bottom\\"> <span>Median time from issue created to issue closed.</span></gl-popover-stub>
</div>
<div class=\\"gl-pr-9\\">
<gl-single-stat-stub title=\\"Cycle Time\\" value=\\"3.0\\" unit=\\"days\\" variant=\\"muted\\" shouldanimate=\\"true\\" animationdecimalplaces=\\"0\\" id=\\"cycle-time\\" tabindex=\\"0\\"></gl-single-stat-stub>
<gl-popover-stub cssclasses=\\"\\" target=\\"cycle-time\\" placement=\\"bottom\\"> <span>Median time from issue first merge request created to issue closed.</span></gl-popover-stub>
</div>
</div>"
`;
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Metrics from 'ee/analytics/cycle_analytics/components/metrics.vue';
import TimeMetricsCard from 'ee/analytics/cycle_analytics/components/time_metrics_card.vue';
import { OVERVIEW_METRICS } from 'ee/analytics/cycle_analytics/constants';
import { group } from '../mock_data';
import Api from 'ee/api';
import createFlash from '~/flash';
import { group, timeMetricsData, recentActivityData } from '../mock_data';
jest.mock('~/flash');
describe('Metrics', () => {
const { full_path: groupPath } = group;
......@@ -17,23 +21,92 @@ describe('Metrics', () => {
});
};
beforeEach(() => {
wrapper = createComponent();
});
const findAllMetrics = () => wrapper.findAllComponents(GlSingleStat);
afterEach(() => {
wrapper.destroy();
});
const findTimeMetricsAtIndex = (index) => wrapper.findAll(TimeMetricsCard).at(index);
describe('with successful requests', () => {
beforeEach(async () => {
jest.spyOn(Api, 'cycleAnalyticsTimeSummaryData').mockResolvedValue({ data: timeMetricsData });
jest.spyOn(Api, 'cycleAnalyticsSummaryData').mockResolvedValue({ data: recentActivityData });
wrapper = createComponent();
await nextTick;
});
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'fetches data for the %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, {});
},
);
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
requestParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
await nextTick;
});
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'sends additional parameters as query paremeters in %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
});
},
);
});
describe('metrics', () => {
it.each`
metric | index | requestType
${'time summary'} | ${0} | ${OVERVIEW_METRICS.TIME_SUMMARY}
${'recent activity'} | ${1} | ${OVERVIEW_METRICS.RECENT_ACTIVITY}
`('renders the $metric', ({ index, requestType }) => {
const card = findTimeMetricsAtIndex(index);
expect(card.props('requestType')).toBe(requestType);
expect(card.html()).toMatchSnapshot();
index | value | title | unit
${0} | ${timeMetricsData[0].value} | ${timeMetricsData[0].title} | ${timeMetricsData[0].unit}
${1} | ${timeMetricsData[1].value} | ${timeMetricsData[1].title} | ${timeMetricsData[1].unit}
${2} | ${recentActivityData[0].value} | ${recentActivityData[0].title} | ${recentActivityData[0].unit}
${3} | ${recentActivityData[1].value} | ${recentActivityData[1].title} | ${recentActivityData[1].unit}
${4} | ${recentActivityData[2].value} | ${recentActivityData[2].title} | ${recentActivityData[2].unit}
`(
'renders a single stat component for the $title with value and unit',
({ index, value, title, unit }) => {
const metric = findAllMetrics().at(index);
const expectedUnit = unit ?? '';
expect(metric.props('value')).toBe(value);
expect(metric.props('title')).toBe(title);
expect(metric.props('unit')).toBe(expectedUnit);
},
);
});
});
describe.each`
metric | failedRequest | succesfulRequest
${'time summary'} | ${'cycleAnalyticsTimeSummaryData'} | ${'cycleAnalyticsSummaryData'}
${'recent activity'} | ${'cycleAnalyticsSummaryData'} | ${'cycleAnalyticsTimeSummaryData'}
`('with the $failedRequest request failing', ({ metric, failedRequest, succesfulRequest }) => {
beforeEach(async () => {
jest.spyOn(Api, failedRequest).mockRejectedValue();
jest.spyOn(Api, succesfulRequest).mockResolvedValue(Promise.resolve({}));
wrapper = createComponent();
await wrapper.vm.$nextTick();
});
it('it should render a error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: `There was an error while fetching value stream analytics ${metric} data.`,
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import TimeMetricsCard from 'ee/analytics/cycle_analytics/components/time_metrics_card.vue';
import { OVERVIEW_METRICS } from 'ee/analytics/cycle_analytics/constants';
import Api from 'ee/api';
import createFlash from '~/flash';
import { group, timeMetricsData, recentActivityData } from '../mock_data';
jest.mock('~/flash');
describe('TimeMetricsCard', () => {
const { full_path: groupPath } = group;
let wrapper;
const createComponent = ({ additionalParams = {}, requestType } = {}) => {
return shallowMount(TimeMetricsCard, {
propsData: {
groupPath,
additionalParams,
requestType,
},
});
};
describe.each`
metric | requestType | request | data
${'Recent activity'} | ${OVERVIEW_METRICS.RECENT_ACTIVITY} | ${'cycleAnalyticsSummaryData'} | ${recentActivityData}
${'Time summary'} | ${OVERVIEW_METRICS.TIME_SUMMARY} | ${'cycleAnalyticsTimeSummaryData'} | ${timeMetricsData}
`('$metric', ({ requestType, request, data, metric }) => {
beforeEach(() => {
jest.spyOn(Api, request).mockResolvedValue({ data });
wrapper = createComponent({ requestType });
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it(`renders the ${metric} metric`, () => {
expect(wrapper.html()).toMatchSnapshot();
});
it('fetches the metric data', () => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, {});
});
describe('with a failing request', () => {
beforeEach(() => {
jest.spyOn(Api, request).mockRejectedValue();
wrapper = createComponent({ requestType });
});
it('should render an error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: `There was an error while fetching value stream analytics ${metric.toLowerCase()} data.`,
});
});
});
describe('with additional params', () => {
beforeEach(() => {
wrapper = createComponent({
requestType,
additionalParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
});
it('sends additional parameters as query paremeters', () => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
});
});
});
});
});
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