Commit 150da612 authored by Nathan Friend's avatar Nathan Friend Committed by Brandon Labuschagne

Add group-level release statistics

This commit replaces the placeholder content in the group-level CI/CD
Analytics page with two release-related statistics: total release count
and the percentage of sub-projects that contain at least one release.
parent 1982e005
......@@ -143,3 +143,17 @@
flex-direction: column !important;
}
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1165
.gl-xs-mb-4 {
@media (max-width: $breakpoint-sm) {
margin-bottom: $gl-spacing-scale-4;
}
}
// Same as above
.gl-xs-mb-4\! {
@media (max-width: $breakpoint-sm) {
margin-bottom: $gl-spacing-scale-4 !important;
}
}
<script>
import ReleaseStatsCard from './release_stats_card.vue';
export default {
name: 'CiCdAnalyticsApp',
components: {
ReleaseStatsCard,
},
};
</script>
<template>
<div>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<h1 class="gl-font-size-h2">This page is a placeholder</h1>
<p>
If you're seeing this page, it's because you've enabled the
<code>group_ci_cd_analytics_page</code> feature flag.
</p>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
<release-stats-card class="gl-mt-5" />
</div>
</template>
<script>
import { GlCard, GlSkeletonLoader } from '@gitlab/ui';
import { sprintf, n__, s__ } from '~/locale';
import createFlash from '~/flash';
import groupReleaseStatsQuery from '../graphql/group_release_stats.query.graphql';
import { STAT_ERROR_PLACEHOLDER } from '../constants';
export default {
name: 'ReleaseStatsCard',
components: {
GlCard,
GlSkeletonLoader,
},
inject: {
fullPath: {
default: '',
},
},
apollo: {
rawStats: {
query: groupReleaseStatsQuery,
variables() {
return {
fullPath: this.fullPath,
};
},
update(data) {
return data.group?.stats?.releaseStats || {};
},
error(error) {
this.errored = true;
createFlash({
message: s__('CICDAnalytics|Something went wrong while fetching release statistics'),
captureError: true,
error,
});
},
},
},
data() {
return {
errored: false,
};
},
computed: {
isLoading() {
return this.$apollo.queries.rawStats.loading;
},
releasesCountStat() {
if (this.errored) {
return STAT_ERROR_PLACEHOLDER;
}
return this.rawStats?.releasesCount.toString() || '';
},
releasesPercentageStat() {
if (this.errored) {
return STAT_ERROR_PLACEHOLDER;
}
if (this.rawStats?.releasesPercentage) {
return sprintf(s__('CICDAnalytics|%{percent}%{percentSymbol}'), {
percent: this.rawStats?.releasesPercentage,
percentSymbol: '%',
});
}
return '';
},
formattedStats() {
return [
{
id: 'releases-count',
stat: this.releasesCountStat,
title: n__(
'CICDAnalytics|Release',
'CICDAnalytics|Releases',
this.rawStats?.releasesCount || 0,
),
},
{
id: 'releases-percentage',
stat: this.releasesPercentageStat,
title: s__('CICDAnalytics|Projects with releases'),
},
];
},
},
};
</script>
<template>
<gl-card data-testid="release-stats-card">
<template #header>
<header class="gl-display-flex gl-align-items-baseline">
<h1 class="gl-m-0 gl-mr-5 gl-font-lg">{{ s__('CICDAnalytics|Releases') }}</h1>
<h2 class="gl-m-0 gl-font-base gl-text-gray-500 gl-font-weight-normal">
{{ s__('CICDAnalytics|All time') }}
</h2>
</header>
</template>
<div
class="gl-display-flex gl-flex-direction-column gl-flex-direction-column gl-sm-flex-direction-row"
data-testid="stats-container"
>
<div
v-for="(stat, index) of formattedStats"
:key="stat.id"
class="gl-flex-grow-1 gl-h-11 gl-flex-basis-0 gl-display-flex gl-align-items-center gl-flex-direction-column"
:class="{ 'gl-xs-mb-4': index != formattedStats.length - 1 }"
>
<gl-skeleton-loader v-if="isLoading">
<rect x="0" y="21" rx="3" ry="3" width="400" height="48" />
<rect x="50" y="94" rx="3" ry="3" width="300" height="31" />
</gl-skeleton-loader>
<template v-else>
<span class="gl-font-size-h-display">{{ stat.stat }}</span>
{{ stat.title }}
</template>
</div>
</div>
</gl-card>
</template>
query GroupReleaseStats($fullPath: ID!) {
group(fullPath: $fullPath) {
stats {
releaseStats {
releasesCount
releasesPercentage
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import CiCdAnalyticsApp from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const el = document.querySelector('#js-group-ci-cd-analytics-app');
if (!el) return false;
const { fullPath } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
fullPath,
},
render: (createElement) => createElement(CiCdAnalyticsApp),
});
};
- page_title _("CI / CD Analytics")
#js-group-ci-cd-analytics-app
#js-group-ci-cd-analytics-app{ data: { full_path: @group.full_path } }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Group CI/CD Analytics', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group ) }
let_it_be(:project_1) { create(:project, group: group) }
let_it_be(:project_2) { create(:project, group: group) }
let_it_be(:project_3) { create(:project, group: subgroup) }
let_it_be(:unrelated_project) { create(:project) }
let_it_be(:releases) { create_list(:release, 10, project: project_1) }
let_it_be(:releases) { create_list(:release, 5, project: project_3) }
let_it_be(:unrelated_release) { create(:release, project: unrelated_project) }
before do
stub_licensed_features(group_ci_cd_analytics: true)
group.add_reporter(user)
sign_in(user)
visit group_analytics_ci_cd_analytics_path(group)
wait_for_requests
end
it 'renders statistics about release within the group', :aggregate_failures do
within '[data-testid="release-stats-card"]' do
expect(page).to have_content 'Releases All time'
expect(page).to have_content '15 Releases 67% Projects with releases'
end
end
end
import { shallowMount } from '@vue/test-utils';
import CiCdAnalyticsApp from 'ee/analytics/group_ci_cd_analytics/components/app.vue';
import ReleaseStatsCard from 'ee/analytics/group_ci_cd_analytics/components/release_stats_card.vue';
describe('ee/analytics/group_ci_cd_analytics/components/app.vue', () => {
let wrapper;
......@@ -8,9 +9,8 @@ describe('ee/analytics/group_ci_cd_analytics/components/app.vue', () => {
wrapper = shallowMount(CiCdAnalyticsApp);
};
it('renders without errors', () => {
it('renders the release stats card', () => {
createComponent();
expect(wrapper).toBeTruthy();
expect(wrapper.find(ReleaseStatsCard).exists()).toBe(true);
});
});
export const groupReleaseStatsQueryResponse = {
data: {
group: {
stats: {
releaseStats: {
releasesCount: 2811,
releasesPercentage: 9,
},
},
},
},
};
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlSkeletonLoader, GlCard } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import ReleaseStatsCard from 'ee/analytics/group_ci_cd_analytics/components/release_stats_card.vue';
import groupReleaseStatsQuery from 'ee/analytics/group_ci_cd_analytics/graphql/group_release_stats.query.graphql';
import { groupReleaseStatsQueryResponse } from './mock_data';
const localVue = createLocalVue();
Vue.use(VueApollo);
describe('Release stats card', () => {
let wrapper;
const createComponent = ({ apolloProvider }) => {
wrapper = shallowMount(ReleaseStatsCard, {
localVue,
apolloProvider,
stubs: {
GlCard,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findLoadingIndicators = () => wrapper.findAll(GlSkeletonLoader);
const findStats = () => wrapper.find('[data-testid="stats-container"]');
const expectLoadingIndicators = () => {
expect(findLoadingIndicators()).toHaveLength(2);
};
const expectNoLoadingIndicators = () => {
expect(findLoadingIndicators()).toHaveLength(0);
};
describe('when the component is loading data', () => {
beforeEach(() => {
const apolloProvider = createMockApollo([
[groupReleaseStatsQuery, jest.fn().mockReturnValueOnce(new Promise(() => {}))],
]);
createComponent({ apolloProvider });
});
it('renders loading indicators', () => {
expectLoadingIndicators();
});
});
describe('when the data has successfully loaded', () => {
beforeEach(() => {
const apolloProvider = createMockApollo([
[groupReleaseStatsQuery, jest.fn().mockResolvedValueOnce(groupReleaseStatsQueryResponse)],
]);
createComponent({ apolloProvider });
});
it('does not render loading indicators', () => {
expectNoLoadingIndicators();
});
it('renders the card header', () => {
const header = wrapper.find('header');
expect(header.find('h1').text()).toMatchInterpolatedText('Releases');
expect(header.find('h2').text()).toMatchInterpolatedText('All time');
});
it('renders the statistics', () => {
expect(findStats().text()).toMatchInterpolatedText('2811 Releases 9% Projects with releases');
});
});
describe('when an error occurs while loading data', () => {
beforeEach(() => {
const apolloProvider = createMockApollo([
[groupReleaseStatsQuery, jest.fn().mockRejectedValueOnce(new Error('network error'))],
]);
createComponent({ apolloProvider });
});
it('does not render loading indicators', () => {
expectNoLoadingIndicators();
});
it('renders questions marks in place of the numbers', () => {
expect(findStats().text()).toMatchInterpolatedText('- Releases - Projects with releases');
});
});
});
......@@ -5034,6 +5034,26 @@ msgstr ""
msgid "CI/CD settings"
msgstr ""
msgid "CICDAnalytics|%{percent}%{percentSymbol}"
msgstr ""
msgid "CICDAnalytics|All time"
msgstr ""
msgid "CICDAnalytics|Projects with releases"
msgstr ""
msgid "CICDAnalytics|Release"
msgid_plural "CICDAnalytics|Releases"
msgstr[0] ""
msgstr[1] ""
msgid "CICDAnalytics|Releases"
msgstr ""
msgid "CICDAnalytics|Something went wrong while fetching release statistics"
msgstr ""
msgid "CICD|Add a %{base_domain_link_start}base domain%{link_end} to your %{kubernetes_cluster_link_start}Kubernetes cluster%{link_end} for your deployment strategy to work."
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