Commit 44b9f494 authored by Nathan Friend's avatar Nathan Friend

Refactor pipeline charts

This commit updates the pipeline charts to centralize all logic related
to the component inside the `pipeline_charts.vue` file, rather
than being spread over `app.vue` and `pipeline_charts.vue`.
parent 2bfd2a93
<script>
import { GlAlert, GlTabs, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql';
import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql';
import { GlTabs, GlTab } from '@gitlab/ui';
import PipelineCharts from './pipeline_charts.vue';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import {
DEFAULT,
LOAD_ANALYTICS_FAILURE,
LOAD_PIPELINES_FAILURE,
PARSE_FAILURE,
UNSUPPORTED_DATA,
} from '../constants';
const defaultAnalyticsValues = {
weekPipelinesTotals: [],
weekPipelinesLabels: [],
weekPipelinesSuccessful: [],
monthPipelinesLabels: [],
monthPipelinesTotals: [],
monthPipelinesSuccessful: [],
yearPipelinesLabels: [],
yearPipelinesTotals: [],
yearPipelinesSuccessful: [],
pipelineTimesLabels: [],
pipelineTimesValues: [],
};
const defaultCountValues = {
totalPipelines: {
count: 0,
},
successfulPipelines: {
count: 0,
},
};
const charts = ['pipelines', 'deployments'];
export default {
components: {
GlAlert,
GlTabs,
GlTab,
PipelineCharts,
......@@ -53,10 +18,6 @@ export default {
type: Boolean,
default: false,
},
projectPath: {
type: String,
default: '',
},
},
data() {
const [chart] = getParameterValues('chart') || charts;
......@@ -64,169 +25,27 @@ export default {
return {
chart,
selectedTab: tab >= 0 ? tab : 0,
showFailureAlert: false,
failureType: null,
analytics: { ...defaultAnalyticsValues },
counts: { ...defaultCountValues },
};
},
apollo: {
counts: {
query: getPipelineCountByStatus,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
return data?.project;
},
error() {
this.reportFailure(LOAD_PIPELINES_FAILURE);
},
},
analytics: {
query: getProjectPipelineStatistics,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
return data?.project?.pipelineAnalytics;
},
error() {
this.reportFailure(LOAD_ANALYTICS_FAILURE);
},
},
},
computed: {
failure() {
switch (this.failureType) {
case LOAD_ANALYTICS_FAILURE:
return {
text: this.$options.errorTexts[LOAD_ANALYTICS_FAILURE],
variant: 'danger',
};
case PARSE_FAILURE:
return {
text: this.$options.errorTexts[PARSE_FAILURE],
variant: 'danger',
};
case UNSUPPORTED_DATA:
return {
text: this.$options.errorTexts[UNSUPPORTED_DATA],
variant: 'info',
};
default:
return {
text: this.$options.errorTexts[DEFAULT],
variant: 'danger',
};
}
},
lastWeekChartData() {
return {
labels: this.analytics.weekPipelinesLabels,
totals: this.analytics.weekPipelinesTotals,
success: this.analytics.weekPipelinesSuccessful,
};
},
lastMonthChartData() {
return {
labels: this.analytics.monthPipelinesLabels,
totals: this.analytics.monthPipelinesTotals,
success: this.analytics.monthPipelinesSuccessful,
};
},
lastYearChartData() {
return {
labels: this.analytics.yearPipelinesLabels,
totals: this.analytics.yearPipelinesTotals,
success: this.analytics.yearPipelinesSuccessful,
};
},
timesChartData() {
return {
labels: this.analytics.pipelineTimesLabels,
values: this.analytics.pipelineTimesValues,
};
},
successRatio() {
const { successfulPipelines, failedPipelines } = this.counts;
const successfulCount = successfulPipelines?.count;
const failedCount = failedPipelines?.count;
const ratio = (successfulCount / (successfulCount + failedCount)) * 100;
return failedCount === 0 ? 100 : ratio;
},
formattedCounts() {
const { totalPipelines, successfulPipelines, failedPipelines } = this.counts;
return {
total: totalPipelines?.count,
success: successfulPipelines?.count,
failed: failedPipelines?.count,
successRatio: this.successRatio,
};
},
},
methods: {
hideAlert() {
this.showFailureAlert = false;
},
reportFailure(type) {
this.showFailureAlert = true;
this.failureType = type;
},
onTabChange(index) {
this.selectedTab = index;
const path = mergeUrlParams({ chart: charts[index] }, window.location.pathname);
updateHistory({ url: path });
},
},
errorTexts: {
[LOAD_ANALYTICS_FAILURE]: s__(
'PipelineCharts|An error has ocurred when retrieving the analytics data',
),
[LOAD_PIPELINES_FAILURE]: s__(
'PipelineCharts|An error has ocurred when retrieving the pipelines data',
),
[PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'),
[DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'),
},
};
</script>
<template>
<div>
<gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">{{
failure.text
}}</gl-alert>
<gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange">
<gl-tab :title="__('Pipelines')">
<pipeline-charts
:counts="formattedCounts"
:last-week="lastWeekChartData"
:last-month="lastMonthChartData"
:last-year="lastYearChartData"
:times-chart="timesChartData"
:loading="$apollo.queries.counts.loading"
@report-failure="reportFailure"
/>
<pipeline-charts />
</gl-tab>
<gl-tab :title="__('Deployments')">
<deployment-frequency-charts />
</gl-tab>
</gl-tabs>
<pipeline-charts
v-else
:counts="formattedCounts"
:last-week="lastWeekChartData"
:last-month="lastMonthChartData"
:last-year="lastYearChartData"
:times-chart="timesChartData"
:loading="$apollo.queries.counts.loading"
@report-failure="reportFailure"
/>
<pipeline-charts v-else />
</div>
</template>
<script>
import dateFormat from 'dateformat';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import { GlSkeletonLoader } from '@gitlab/ui';
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql';
import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql';
import { __, s__, sprintf } from '~/locale';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import {
DEFAULT,
CHART_CONTAINER_HEIGHT,
CHART_DATE_FORMAT,
INNER_CHART_HEIGHT,
......@@ -13,51 +16,167 @@ import {
X_AXIS_LABEL_ROTATION,
X_AXIS_TITLE_OFFSET,
PARSE_FAILURE,
LOAD_ANALYTICS_FAILURE,
LOAD_PIPELINES_FAILURE,
UNSUPPORTED_DATA,
} from '../constants';
import StatisticsList from './statistics_list.vue';
import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue';
const defaultAnalyticsValues = {
weekPipelinesTotals: [],
weekPipelinesLabels: [],
weekPipelinesSuccessful: [],
monthPipelinesLabels: [],
monthPipelinesTotals: [],
monthPipelinesSuccessful: [],
yearPipelinesLabels: [],
yearPipelinesTotals: [],
yearPipelinesSuccessful: [],
pipelineTimesLabels: [],
pipelineTimesValues: [],
};
const defaultCountValues = {
totalPipelines: {
count: 0,
},
successfulPipelines: {
count: 0,
},
};
export default {
components: {
GlAlert,
GlColumnChart,
GlSkeletonLoader,
StatisticsList,
CiCdAnalyticsAreaChart,
},
props: {
inject: {
projectPath: {
type: String,
default: '',
},
},
data() {
return {
showFailureAlert: false,
failureType: null,
analytics: { ...defaultAnalyticsValues },
counts: { ...defaultCountValues },
};
},
apollo: {
counts: {
required: true,
type: Object,
query: getPipelineCountByStatus,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
return data?.project;
},
error() {
this.reportFailure(LOAD_PIPELINES_FAILURE);
},
},
loading: {
required: false,
default: false,
type: Boolean,
analytics: {
query: getProjectPipelineStatistics,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
return data?.project?.pipelineAnalytics;
},
error() {
this.reportFailure(LOAD_ANALYTICS_FAILURE);
},
},
lastWeek: {
required: true,
type: Object,
},
computed: {
loading() {
return this.$apollo.queries.counts.loading;
},
lastMonth: {
required: true,
type: Object,
failure() {
switch (this.failureType) {
case LOAD_ANALYTICS_FAILURE:
return {
text: this.$options.errorTexts[LOAD_ANALYTICS_FAILURE],
variant: 'danger',
};
case PARSE_FAILURE:
return {
text: this.$options.errorTexts[PARSE_FAILURE],
variant: 'danger',
};
case UNSUPPORTED_DATA:
return {
text: this.$options.errorTexts[UNSUPPORTED_DATA],
variant: 'info',
};
default:
return {
text: this.$options.errorTexts[DEFAULT],
variant: 'danger',
};
}
},
lastYear: {
required: true,
type: Object,
lastWeekChartData() {
return {
labels: this.analytics.weekPipelinesLabels,
totals: this.analytics.weekPipelinesTotals,
success: this.analytics.weekPipelinesSuccessful,
};
},
timesChart: {
required: true,
type: Object,
lastMonthChartData() {
return {
labels: this.analytics.monthPipelinesLabels,
totals: this.analytics.monthPipelinesTotals,
success: this.analytics.monthPipelinesSuccessful,
};
},
lastYearChartData() {
return {
labels: this.analytics.yearPipelinesLabels,
totals: this.analytics.yearPipelinesTotals,
success: this.analytics.yearPipelinesSuccessful,
};
},
timesChartData() {
return {
labels: this.analytics.pipelineTimesLabels,
values: this.analytics.pipelineTimesValues,
};
},
successRatio() {
const { successfulPipelines, failedPipelines } = this.counts;
const successfulCount = successfulPipelines?.count;
const failedCount = failedPipelines?.count;
const ratio = (successfulCount / (successfulCount + failedCount)) * 100;
return failedCount === 0 ? 100 : ratio;
},
formattedCounts() {
const { totalPipelines, successfulPipelines, failedPipelines } = this.counts;
return {
total: totalPipelines?.count,
success: successfulPipelines?.count,
failed: failedPipelines?.count,
successRatio: this.successRatio,
};
},
},
computed: {
areaCharts() {
const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles;
const charts = [
{ title: lastWeek, data: this.lastWeek },
{ title: lastMonth, data: this.lastMonth },
{ title: lastYear, data: this.lastYear },
{ title: lastWeek, data: this.lastWeekChartData },
{ title: lastMonth, data: this.lastMonthChartData },
{ title: lastYear, data: this.lastYearChartData },
];
let areaChartsData = [];
......@@ -65,7 +184,7 @@ export default {
areaChartsData = charts.map(this.buildAreaChartData);
} catch {
areaChartsData = [];
this.vm.$emit('report-failure', PARSE_FAILURE);
this.reportFailure(PARSE_FAILURE);
}
return areaChartsData;
......@@ -74,12 +193,19 @@ export default {
return [
{
name: 'full',
data: this.mergeLabelsAndValues(this.timesChart.labels, this.timesChart.values),
data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
},
];
},
},
methods: {
hideAlert() {
this.showFailureAlert = false;
},
reportFailure(type) {
this.showFailureAlert = true;
this.failureType = type;
},
mergeLabelsAndValues(labels, values) {
return labels.map((label, index) => [label, values[index]]);
},
......@@ -121,6 +247,16 @@ export default {
minInterval: 1,
},
},
errorTexts: {
[LOAD_ANALYTICS_FAILURE]: s__(
'PipelineCharts|An error has ocurred when retrieving the analytics data',
),
[LOAD_PIPELINES_FAILURE]: s__(
'PipelineCharts|An error has ocurred when retrieving the pipelines data',
),
[PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'),
[DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'),
},
get chartTitles() {
const today = dateFormat(new Date(), CHART_DATE_FORMAT);
const pastDate = (timeScale) =>
......@@ -141,6 +277,9 @@ export default {
</script>
<template>
<div>
<gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">{{
failure.text
}}</gl-alert>
<div class="gl-mb-3">
<h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
</div>
......@@ -148,7 +287,7 @@ export default {
<div class="row">
<div class="col-md-6">
<gl-skeleton-loader v-if="loading" :lines="5" />
<statistics-list v-else :counts="counts" />
<statistics-list v-else :counts="formattedCounts" />
</div>
<div v-if="!loading" class="col-md-6">
<strong>{{ __('Duration for the last 30 commits') }}</strong>
......
import { merge } from 'lodash';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import { GlTabs, GlTab } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import Component from '~/projects/pipelines/charts/components/app.vue';
import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_charts.vue';
import getPipelineCountByStatus from '~/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql';
import getProjectPipelineStatistics from '~/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql';
import { mockPipelineCount, mockPipelineStatistics } from '../mock_data';
jest.mock('~/lib/utils/url_utility');
const projectPath = 'gitlab-org/gitlab';
const localVue = createLocalVue();
localVue.use(VueApollo);
const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} };
describe('ProjectsPipelinesChartsApp', () => {
let wrapper;
function createMockApolloProvider() {
const requestHandlers = [
[getPipelineCountByStatus, jest.fn().mockResolvedValue(mockPipelineCount)],
[getProjectPipelineStatistics, jest.fn().mockResolvedValue(mockPipelineStatistics)],
];
return createMockApollo(requestHandlers);
}
function createComponent(mountOptions = {}) {
wrapper = shallowMount(
Component,
......@@ -39,11 +21,8 @@ describe('ProjectsPipelinesChartsApp', () => {
{},
{
provide: {
projectPath,
shouldRenderDeploymentFrequencyCharts: false,
},
localVue,
apolloProvider: createMockApolloProvider(),
stubs: {
DeploymentFrequencyCharts: DeploymentFrequencyChartsStub,
},
......@@ -62,52 +41,15 @@ describe('ProjectsPipelinesChartsApp', () => {
wrapper = null;
});
describe('pipelines charts', () => {
it('displays the pipeline charts', () => {
const chart = wrapper.find(PipelineCharts);
const analytics = mockPipelineStatistics.data.project.pipelineAnalytics;
const {
totalPipelines: total,
successfulPipelines: success,
failedPipelines: failed,
} = mockPipelineCount.data.project;
expect(chart.exists()).toBe(true);
expect(chart.props()).toMatchObject({
counts: {
failed: failed.count,
success: success.count,
total: total.count,
successRatio: (success.count / (success.count + failed.count)) * 100,
},
lastWeek: {
labels: analytics.weekPipelinesLabels,
totals: analytics.weekPipelinesTotals,
success: analytics.weekPipelinesSuccessful,
},
lastMonth: {
labels: analytics.monthPipelinesLabels,
totals: analytics.monthPipelinesTotals,
success: analytics.monthPipelinesSuccessful,
},
lastYear: {
labels: analytics.yearPipelinesLabels,
totals: analytics.yearPipelinesTotals,
success: analytics.yearPipelinesSuccessful,
},
timesChart: {
labels: analytics.pipelineTimesLabels,
values: analytics.pipelineTimesValues,
},
});
});
});
const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub);
const findGlTabs = () => wrapper.find(GlTabs);
const findAllGlTab = () => wrapper.findAll(GlTab);
const findGlTabAt = (i) => findAllGlTab().at(i);
const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub);
const findPipelineCharts = () => wrapper.find(PipelineCharts);
it('renders the pipeline charts', () => {
expect(findPipelineCharts().exists()).toBe(true);
});
describe('when shouldRenderDeploymentFrequencyCharts is true', () => {
beforeEach(() => {
......
import { shallowMount } from '@vue/test-utils';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import createMockApollo from 'helpers/mock_apollo_helper';
import StatisticsList from '~/projects/pipelines/charts/components/statistics_list.vue';
import CiCdAnalyticsAreaChart from '~/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue';
import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_charts.vue';
import {
counts,
timesChartData as timesChart,
areaChartData as lastWeek,
areaChartData as lastMonth,
lastYearChartData as lastYear,
} from '../mock_data';
import getPipelineCountByStatus from '~/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql';
import getProjectPipelineStatistics from '~/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql';
import { mockPipelineCount, mockPipelineStatistics } from '../mock_data';
describe('ProjectsPipelinesChartsApp', () => {
const projectPath = 'gitlab-org/gitlab';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('~/projects/pipelines/charts/components/pipeline_charts.vue', () => {
let wrapper;
function createMockApolloProvider() {
const requestHandlers = [
[getPipelineCountByStatus, jest.fn().mockResolvedValue(mockPipelineCount)],
[getProjectPipelineStatistics, jest.fn().mockResolvedValue(mockPipelineStatistics)],
];
return createMockApollo(requestHandlers);
}
beforeEach(() => {
wrapper = shallowMount(PipelineCharts, {
propsData: {
counts,
timesChart,
lastWeek,
lastMonth,
lastYear,
},
provide: {
projectPath: 'test/project',
shouldRenderDeploymentFrequencyCharts: true,
},
stubs: {
DeploymentFrequencyCharts: true,
projectPath,
},
localVue,
apolloProvider: createMockApolloProvider(),
});
});
......@@ -43,7 +45,12 @@ describe('ProjectsPipelinesChartsApp', () => {
const list = wrapper.find(StatisticsList);
expect(list.exists()).toBe(true);
expect(list.props('counts')).toBe(counts);
expect(list.props('counts')).toEqual({
total: 34,
success: 23,
failed: 1,
successRatio: (23 / (23 + 1)) * 100,
});
});
it('displays the commit duration chart', () => {
......
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