Commit fb9c7aaa authored by wortschi's avatar wortschi

VSA: Add deployment frequency links

Changelog: added
EE: true
parent b37098e3
<script>
import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
export default {
name: 'MetricPopover',
components: {
GlPopover,
GlLink,
GlIcon,
},
props: {
metric: {
type: Object,
required: true,
},
target: {
type: String,
required: true,
},
},
computed: {
metricLinks() {
return this.metric.links?.filter((link) => !link.docs_link) || [];
},
docsLink() {
return this.metric.links?.find((link) => link.docs_link);
},
},
};
</script>
<template>
<gl-popover :target="target" placement="bottom">
<template #title>
<span class="gl-display-block gl-text-left" data-testid="metric-label">{{
metric.label
}}</span>
</template>
<div
v-for="(link, idx) in metricLinks"
:key="`link-${idx}`"
class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1"
data-testid="metric-link"
>
<span>{{ link.label }}</span>
<gl-link :href="link.url" class="gl-font-sm">
{{ link.name }}
</gl-link>
</div>
<span v-if="metric.description" data-testid="metric-description">{{ metric.description }}</span>
<gl-link
v-if="docsLink"
:href="docsLink.url"
class="gl-font-sm"
target="_blank"
data-testid="metric-docs-link"
>{{ docsLink.label }}
<gl-icon name="external-link" class="gl-vertical-align-middle" />
</gl-link>
</gl-popover>
</template>
<script> <script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import { METRICS_POPOVER_CONTENT } from '../constants'; import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils'; import { removeFlash, prepareTimeMetricsData } from '../utils';
import MetricPopover from './metric_popover.vue';
const requestData = ({ request, endpoint, path, params, name }) => { const requestData = ({ request, endpoint, path, params, name }) => {
return request({ endpoint, params, requestPath: path }) return request({ endpoint, params, requestPath: path })
...@@ -31,9 +33,9 @@ const fetchMetricsData = (reqs = [], path, params) => { ...@@ -31,9 +33,9 @@ const fetchMetricsData = (reqs = [], path, params) => {
export default { export default {
name: 'ValueStreamMetrics', name: 'ValueStreamMetrics',
components: { components: {
GlPopover,
GlSingleStat, GlSingleStat,
GlSkeletonLoading, GlSkeletonLoading,
MetricPopover,
}, },
props: { props: {
requestPath: { requestPath: {
...@@ -76,6 +78,14 @@ export default { ...@@ -76,6 +78,14 @@ export default {
this.isLoading = false; this.isLoading = false;
}); });
}, },
hasLinks(links) {
return links?.length && links[0].url;
},
clickHandler({ links }) {
if (this.hasLinks(links)) {
redirectTo(links[0].url);
}
},
}, },
}; };
</script> </script>
...@@ -93,14 +103,11 @@ export default { ...@@ -93,14 +103,11 @@ export default {
:unit="metric.unit || ''" :unit="metric.unit || ''"
:should-animate="true" :should-animate="true"
:animation-decimal-places="1" :animation-decimal-places="1"
:class="{ 'gl-hover-cursor-pointer': hasLinks(metric.links) }"
tabindex="0" tabindex="0"
@click="clickHandler(metric)"
/> />
<gl-popover :target="metric.key" placement="bottom"> <metric-popover :metric="metric" :target="metric.key" />
<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> </div>
</template> </template>
</div> </div>
......
...@@ -4,6 +4,7 @@ class AnalyticsSummaryEntity < Grape::Entity ...@@ -4,6 +4,7 @@ class AnalyticsSummaryEntity < Grape::Entity
expose :value, safe: true expose :value, safe: true
expose :title expose :title
expose :unit, if: { with_unit: true } expose :unit, if: { with_unit: true }
expose :links
private private
......
...@@ -29,6 +29,10 @@ module Gitlab ...@@ -29,6 +29,10 @@ module Gitlab
raise NotImplementedError, "Expected #{self.name} to implement end_event_identifier" raise NotImplementedError, "Expected #{self.name} to implement end_event_identifier"
end end
def links
[]
end
private private
def assign_event_identifiers def assign_event_identifiers
......
...@@ -20,6 +20,10 @@ module Gitlab ...@@ -20,6 +20,10 @@ module Gitlab
def value def value
raise NotImplementedError, "Expected #{self.name} to implement value" raise NotImplementedError, "Expected #{self.name} to implement value"
end end
def links
[]
end
end end
end end
end end
......
...@@ -27,6 +27,13 @@ module Gitlab ...@@ -27,6 +27,13 @@ module Gitlab
def unit def unit
_('per day') _('per day')
end end
def links
[
{ "name" => _('Deployment frequency'), "url" => Gitlab::Routing.url_helpers.group_analytics_ci_cd_analytics_path(group, tab: 'deployment-frequency'), "label" => s_('ValueStreamAnalytics|Dashboard') },
{ "name" => _('Deployment frequency'), "url" => Gitlab::Routing.url_helpers.help_page_path('user/analytics/index', anchor: 'definitions'), "docs_link" => true, "label" => s_('ValueStreamAnalytics|Go to docs') }
]
end
end end
end end
end end
......
...@@ -25,6 +25,10 @@ module Gitlab ...@@ -25,6 +25,10 @@ module Gitlab
n_('day', 'days', value) n_('day', 'days', value)
end end
def links
[]
end
private private
attr_reader :stage, :current_user, :options, :from, :to attr_reader :stage, :current_user, :options, :from, :to
......
...@@ -38,7 +38,8 @@ module Gitlab ...@@ -38,7 +38,8 @@ module Gitlab
serialize( serialize(
Summary::DeploymentFrequency.new( Summary::DeploymentFrequency.new(
deployments: deployments_summary.value.raw_value, deployments: deployments_summary.value.raw_value,
options: @options), options: @options,
project: @project),
with_unit: true with_unit: true
) )
end end
......
...@@ -17,6 +17,10 @@ module Gitlab ...@@ -17,6 +17,10 @@ module Gitlab
raise NotImplementedError, "Expected #{self.name} to implement value" raise NotImplementedError, "Expected #{self.name} to implement value"
end end
def links
[]
end
private private
attr_reader :project, :options attr_reader :project, :options
......
...@@ -6,7 +6,7 @@ module Gitlab ...@@ -6,7 +6,7 @@ module Gitlab
class DeploymentFrequency < Base class DeploymentFrequency < Base
include SummaryHelper include SummaryHelper
def initialize(deployments:, options:, project: nil) def initialize(deployments:, options:, project:)
@deployments = deployments @deployments = deployments
super(project: project, options: options) super(project: project, options: options)
...@@ -23,6 +23,13 @@ module Gitlab ...@@ -23,6 +23,13 @@ module Gitlab
def unit def unit
_('per day') _('per day')
end end
def links
[
{ "name" => _('Deployment frequency'), "url" => Gitlab::Routing.url_helpers.charts_project_pipelines_path(project, chart: 'deployment-frequency'), "label" => s_('ValueStreamAnalytics|Dashboard') },
{ "name" => _('Deployment frequency'), "url" => Gitlab::Routing.url_helpers.help_page_path('user/analytics/index', anchor: 'definitions'), "docs_link" => true, "label" => s_('ValueStreamAnalytics|Go to docs') }
]
end
end end
end end
end end
......
...@@ -37648,6 +37648,12 @@ msgstr "" ...@@ -37648,6 +37648,12 @@ msgstr ""
msgid "ValueStreamAnalytics|Average number of deployments to production per day." msgid "ValueStreamAnalytics|Average number of deployments to production per day."
msgstr "" msgstr ""
msgid "ValueStreamAnalytics|Dashboard"
msgstr ""
msgid "ValueStreamAnalytics|Go to docs"
msgstr ""
msgid "ValueStreamAnalytics|Items in Value Stream Analytics are currently filtered by their creation time. There is an %{epic_link_start}epic%{epic_link_end} that will change the Value Stream Analytics date filter to use the end event time for the selected stage." msgid "ValueStreamAnalytics|Items in Value Stream Analytics are currently filtered by their creation time. There is an %{epic_link_start}epic%{epic_link_end} that will change the Value Stream Analytics date filter to use the end event time for the selected stage."
msgstr "" msgstr ""
......
...@@ -14,6 +14,9 @@ ...@@ -14,6 +14,9 @@
}, },
"unit": { "unit": {
"type": "string" "type": "string"
},
"links": {
"type": "array"
} }
}, },
"additionalProperties": false "additionalProperties": false
......
import { GlLink, GlIcon } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import MetricPopover from '~/cycle_analytics/components/metric_popover.vue';
const MOCK_METRIC = {
key: 'deployment-frequency',
label: 'Deployment Frequency',
value: '10.0',
unit: 'per day',
description: 'Average number of deployments to production per day.',
links: [],
};
describe('MetricPopover', () => {
let wrapper;
const createComponent = (props = {}) => {
return shallowMountExtended(MetricPopover, {
propsData: {
target: 'deployment-frequency',
...props,
},
stubs: {
'gl-popover': { template: '<div><slot name="title"></slot><slot></slot></div>' },
},
});
};
const findMetricLabel = () => wrapper.findByTestId('metric-label');
const findAllMetricLinks = () => wrapper.findAll('[data-testid="metric-link"]');
const findMetricDescription = () => wrapper.findByTestId('metric-description');
const findMetricDocsLink = () => wrapper.findByTestId('metric-docs-link');
const findMetricDocsLinkIcon = () => findMetricDocsLink().find(GlIcon);
afterEach(() => {
wrapper.destroy();
});
it('renders the metric label', () => {
wrapper = createComponent({ metric: MOCK_METRIC });
expect(findMetricLabel().text()).toBe(MOCK_METRIC.label);
});
it('renders the metric description', () => {
wrapper = createComponent({ metric: MOCK_METRIC });
expect(findMetricDescription().text()).toBe(MOCK_METRIC.description);
});
describe('with links', () => {
const links = [
{
name: 'Deployment frequency',
url: '/groups/gitlab-org/-/analytics/ci_cd?tab=deployment-frequency',
label: 'Dashboard',
},
{
name: 'Another link',
url: '/groups/gitlab-org/-/analytics/another-link',
label: 'Another link',
},
];
const docsLink = {
name: 'Deployment frequency',
url: '/help/user/analytics/index#definitions',
label: 'Go to docs',
docs_link: true,
};
const linksWithDocs = [...links, docsLink];
describe.each`
hasDocsLink | allLinks | displayedMetricLinks
${true} | ${linksWithDocs} | ${links}
${false} | ${links} | ${links}
`(
'when one link has docs_link=$hasDocsLink',
({ hasDocsLink, allLinks, displayedMetricLinks }) => {
beforeEach(() => {
wrapper = createComponent({ metric: { ...MOCK_METRIC, links: allLinks } });
});
displayedMetricLinks.forEach((link, idx) => {
it(`renders a link for "${link.name}"`, () => {
const allLinkContainers = findAllMetricLinks();
expect(allLinkContainers.at(idx).text()).toContain(link.name);
expect(allLinkContainers.at(idx).find(GlLink).attributes('href')).toBe(link.url);
});
});
it(`${hasDocsLink ? 'renders' : "doesn't render"} a docs link`, () => {
expect(findMetricDocsLink().exists()).toBe(hasDocsLink);
if (hasDocsLink) {
expect(findMetricDocsLink().attributes('href')).toBe(docsLink.url);
expect(findMetricDocsLink().text()).toBe(docsLink.label);
expect(findMetricDocsLinkIcon().attributes('name')).toBe('external-link');
}
});
},
);
});
});
...@@ -6,9 +6,11 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -6,9 +6,11 @@ import waitForPromises from 'helpers/wait_for_promises';
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api'; import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { group } from './mock_data'; import { group } from './mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility');
describe('ValueStreamMetrics', () => { describe('ValueStreamMetrics', () => {
let wrapper; let wrapper;
...@@ -68,19 +70,30 @@ describe('ValueStreamMetrics', () => { ...@@ -68,19 +70,30 @@ describe('ValueStreamMetrics', () => {
expectToHaveRequest({ params: {} }); expectToHaveRequest({ params: {} });
}); });
it.each` describe.each`
index | value | title | unit index | value | title | unit | clickable
${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit} ${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit} | ${false}
${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit} ${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit} | ${false}
${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit} ${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit} | ${false}
${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit} ${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit} | ${true}
`( `('metric tiles', ({ index, value, title, unit, clickable }) => {
'renders a single stat component for the $title with value and unit', it(`renders a single stat component for "${title}" with value and unit`, () => {
({ index, value, title, unit }) => {
const metric = findMetrics().at(index); const metric = findMetrics().at(index);
expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' }); expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' });
}, });
);
it(`${
clickable ? 'redirects' : "doesn't redirect"
} when the user clicks the "${title}" metric`, () => {
const metric = findMetrics().at(index);
metric.vm.$emit('click');
if (clickable) {
expect(redirectTo).toHaveBeenCalledWith(metricsData[index].links[0].url);
} else {
expect(redirectTo).not.toHaveBeenCalled();
}
});
});
it('will not display a loading icon', () => { it('will not display a loading icon', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
......
...@@ -36,7 +36,7 @@ RSpec.describe AnalyticsSummarySerializer do ...@@ -36,7 +36,7 @@ RSpec.describe AnalyticsSummarySerializer do
context 'when representing with unit' do context 'when representing with unit' do
let(:resource) do let(:resource) do
Gitlab::CycleAnalytics::Summary::DeploymentFrequency Gitlab::CycleAnalytics::Summary::DeploymentFrequency
.new(deployments: 10, options: { from: 1.day.ago }) .new(deployments: 10, options: { from: 1.day.ago }, project: project)
end end
subject { described_class.new.represent(resource, with_unit: true) } subject { described_class.new.represent(resource, with_unit: true) }
......
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