Commit e9494c60 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '337561-devops-adoption-add-trend-over-time-graph' into 'master'

DevOps Adoption: Add "trend over time" graph

See merge request gitlab-org/gitlab!70518
parents 4e4ce830 ef874e31
......@@ -9,4 +9,5 @@ export const dateFormats = {
isoDate,
defaultDate: mediumDate,
defaultDateTime: 'mmm d, yyyy h:MMtt',
month: 'mmmm',
};
......@@ -14,6 +14,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - Dependency Scanning metrics [added](https://gitlab.com/gitlab-org/gitlab/-/issues/328034) in GitLab 14.2.
> - Multiselect [added](https://gitlab.com/gitlab-org/gitlab/-/issues/333586) in GitLab 14.2.
> - Overview table [added](https://gitlab.com/gitlab-org/gitlab/-/issues/335638) in GitLab 14.3.
> - Adoption over time chart [added](https://gitlab.com/gitlab-org/gitlab/-/issues/337561) in GitLab 14.4.
Prerequisites:
......@@ -69,6 +70,13 @@ Each group appears as a separate row in the table.
For each row, a feature is considered "adopted" if it has been used in a project in the given group
during the time period (including projects in any subgroups of the given group).
## Adoption over time
The **Adoption over time** chart in the **Overview** tab displays DevOps Adoption over time. The chart displays the total number of adopted features from the previous twelve months,
from when you enabled DevOps Adoption for the group.
The tooltip displays information about the features tracked for individual months.
## When is a feature considered adopted
A feature is considered "adopted" if it has been used anywhere in the group in the specified time.
......
......@@ -7,15 +7,22 @@ import {
I18N_TABLE_HEADER_TEXT,
} from '../constants';
import DevopsAdoptionOverviewCard from './devops_adoption_overview_card.vue';
import DevopsAdoptionOverviewChart from './devops_adoption_overview_chart.vue';
import DevopsAdoptionOverviewTable from './devops_adoption_overview_table.vue';
export default {
name: 'DevopsAdoptionOverview',
components: {
DevopsAdoptionOverviewCard,
DevopsAdoptionOverviewChart,
DevopsAdoptionOverviewTable,
GlLoadingIcon,
},
inject: {
groupGid: {
default: null,
},
},
props: {
loading: {
type: Boolean,
......@@ -81,6 +88,7 @@ export default {
:display-meta="item.displayMeta"
/>
</div>
<devops-adoption-overview-chart v-if="groupGid" class="gl-mb-7" />
<devops-adoption-overview-table :data="data" v-on="$listeners" />
</div>
</template>
<script>
import dateFormat from 'dateformat';
import { isFunction } from 'lodash';
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { dateFormats } from '~/analytics/shared/constants';
import { formatNumber } from '~/locale';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { getAdoptedCountsByCols } from '../utils/helpers';
import {
DEVOPS_ADOPTION_TABLE_CONFIGURATION,
I18N_OVERVIEW_CHART_TITLE,
I18N_OVERVIEW_CHART_Y_AXIS_TITLE,
OVERVIEW_CHART_X_AXIS_TYPE,
OVERVIEW_CHART_Y_AXIS_TYPE,
OVERVIEW_CHART_PRESENTATION,
I18N_NO_FEATURE_META,
CUSTOM_PALETTE,
} from '../constants';
import devopsAdoptionOverviewChartQuery from '../graphql/queries/devops_adoption_overview_chart.query.graphql';
import DevopsAdoptionTableCellFlag from './devops_adoption_table_cell_flag.vue';
export default {
components: {
ChartSkeletonLoader,
GlStackedColumnChart,
DevopsAdoptionTableCellFlag,
},
directives: {
GlResizeObserverDirective,
},
i18n: {
chartTitle: I18N_OVERVIEW_CHART_TITLE,
noFeaturesTracked: I18N_NO_FEATURE_META,
},
presentation: OVERVIEW_CHART_PRESENTATION,
inject: {
groupGid: {
default: null,
},
},
customPalette: CUSTOM_PALETTE,
data() {
return {
chartInstance: null,
devopsAdoptionEnabledNamespaces: null,
tooltipTitle: '',
tooltipContentData: [],
};
},
apollo: {
devopsAdoptionEnabledNamespaces: {
query: devopsAdoptionOverviewChartQuery,
variables() {
return {
displayNamespaceId: this.groupGid ? this.groupGid : null,
startDate: this.getMonthAgo(13),
endDate: this.getMonthAgo(0),
};
},
context: {
isSingleRequest: true,
},
},
},
computed: {
chartOptions() {
return {
xAxisTitle: '',
yAxisTitle: I18N_OVERVIEW_CHART_Y_AXIS_TITLE,
xAxisType: OVERVIEW_CHART_X_AXIS_TYPE,
yAxis: [
{
minInterval: 1,
type: OVERVIEW_CHART_Y_AXIS_TYPE,
axisLabel: {
formatter: (value) => formatNumber(value),
},
},
],
};
},
sortedNodes() {
return [...this.devopsAdoptionEnabledNamespaces?.nodes[0].snapshots?.nodes].reverse();
},
groupBy() {
return this.sortedNodes.map((snapshot) => dateFormat(snapshot.endTime, dateFormats.month));
},
chartData() {
if (!this.devopsAdoptionEnabledNamespaces) return [];
return DEVOPS_ADOPTION_TABLE_CONFIGURATION.map((section) => {
const { cols } = section;
return {
name: section.title,
data: getAdoptedCountsByCols(this.sortedNodes, cols),
};
});
},
},
methods: {
getMonthAgo(ago) {
const date = new Date();
date.setMonth(date.getMonth() - ago);
return dateFormat(date.setDate(1), dateFormats.isoDate);
},
onResize() {
if (isFunction(this.chartInstance?.resize)) {
this.chartInstance.resize();
}
},
formatTooltipText(params) {
const { value, seriesData } = params;
const { dataIndex } = seriesData[0];
const currentNode = this.sortedNodes[dataIndex];
this.tooltipTitle = value;
this.tooltipContentData = DEVOPS_ADOPTION_TABLE_CONFIGURATION.map((item) => ({
...item,
featureMeta: item.cols.map((feature) => ({
title: feature.label,
adopted: Boolean(currentNode[feature.key]) || false,
tracked: currentNode[feature.key] !== null,
})),
}));
},
hasFeaturesAvailable(section) {
return section.featureMeta.some((feature) => feature.tracked);
},
lastItemInList(index, listLength) {
return index === listLength - 1;
},
onChartCreated(chartInstance) {
this.chartInstance = chartInstance;
},
},
};
</script>
<template>
<div>
<h4>{{ $options.i18n.chartTitle }}</h4>
<gl-stacked-column-chart
v-if="!$apollo.queries.devopsAdoptionEnabledNamespaces.loading"
v-gl-resize-observer-directive="onResize"
:bars="chartData"
:presentation="$options.presentation"
:option="chartOptions"
:x-axis-title="chartOptions.xAxisTitle"
:y-axis-title="chartOptions.yAxisTitle"
:x-axis-type="chartOptions.xAxisType"
:group-by="groupBy"
:format-tooltip-text="formatTooltipText"
:custom-palette="$options.customPalette"
@created="onChartCreated"
>
<template #tooltip-title>
{{ tooltipTitle }}
</template>
<template #tooltip-content>
<div
v-for="(section, index) in tooltipContentData"
:key="section.title"
:class="{ 'gl-mb-3': !lastItemInList(index, tooltipContentData.length) }"
>
<div class="gl-font-weight-bold">{{ section.title }}</div>
<template v-if="hasFeaturesAvailable(section)">
<div v-for="feature in section.featureMeta" :key="feature.title">
<template v-if="feature.tracked">
<devops-adoption-table-cell-flag
:enabled="feature.adopted"
:variant="section.variant"
class="gl-mr-3"
/>
{{ feature.title }}
</template>
</div>
</template>
<template v-else>
{{ $options.i18n.noFeaturesTracked }}
</template>
</div>
</template>
</gl-stacked-column-chart>
<chart-skeleton-loader v-else class="gl-mb-8" />
</div>
</template>
......@@ -71,6 +71,20 @@ export const I18N_CELL_FLAG_FALSE_TEXT = s__('DevopsAdoption|Not adopted');
export const I18N_GROUP_COL_LABEL = __('Group');
export const I18N_OVERVIEW_CHART_TITLE = s__('DevopsAdoption|Adoption over time');
export const I18N_OVERVIEW_CHART_Y_AXIS_TITLE = s__(
'DevopsAdoption|Total number of features adopted',
);
export const I18N_NO_FEATURE_META = s__('DevopsAdoption|No tracked features');
export const OVERVIEW_CHART_X_AXIS_TYPE = 'category';
export const OVERVIEW_CHART_Y_AXIS_TYPE = 'value';
export const OVERVIEW_CHART_PRESENTATION = 'tiled';
// $data-viz-orange-600, $data-viz-aqua-500, $data-viz-green-600
export const CUSTOM_PALETTE = ['#b24800', '#0094b6', '#487900'];
export const DEVOPS_ADOPTION_OVERALL_CONFIGURATION = {
title: s__('DevopsAdoption|Overall adoption'),
icon: 'tanuki',
......
query devopsAdoptionEnabledNamespaces(
$displayNamespaceId: NamespaceID
$startDate: Time!
$endDate: Time!
) {
devopsAdoptionEnabledNamespaces(displayNamespaceId: $displayNamespaceId) {
nodes {
id
snapshots(endTimeBefore: $endDate, endTimeAfter: $startDate) {
nodes {
endTime
issueOpened
mergeRequestOpened
mergeRequestApproved
runnerConfigured
pipelineSucceeded
deploySucceeded
recordedAt
codeOwnersUsedCount
sastEnabledCount
dastEnabledCount
coverageFuzzingEnabledCount
dependencyScanningEnabledCount
}
}
}
}
}
......@@ -18,3 +18,22 @@ export const shouldPollTableData = ({ enabledNamespaces, openModal }) => {
return anyPendingEnabledNamespaces;
};
/**
* A helper function which extracts the total feature adoption count for a group
* of snapshot data, filtered out by specific features / columns
*
* @param { Array } snapshots the snapshot data for a given group node
* @param { Array } cols the columns which need to be used for the calculation
*
* @return { Array } an array containing the adopted counts for the given columns
*/
export const getAdoptedCountsByCols = (snapshots, cols) => {
return snapshots.reduce((acc, snapshot) => {
const adoptedCount = cols.reduce((adopted, col) => {
return snapshot[col.key] ? adopted + 1 : adopted;
}, 0);
return [...acc, adoptedCount];
}, []);
};
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DevopsAdoptionOverviewChart chart tooltip displays the tooltip information correctly 1`] = `
<div>
<h4>
Adoption over time
</h4>
<div
bars="[object Object],[object Object],[object Object]"
custom-palette="#b24800,#0094b6,#487900"
group-by="January"
option="[object Object]"
presentation="tiled"
x-axis-title=""
x-axis-type="category"
y-axis-title="Total number of features adopted"
>
Jan
<div
class="gl-mb-3"
>
<div
class="gl-font-weight-bold"
>
Dev
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
variant="warning"
/>
Approvals
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
variant="warning"
/>
Code owners
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
variant="warning"
/>
Issues
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
enabled="true"
variant="warning"
/>
MRs
</div>
</div>
<div
class="gl-mb-3"
>
<div
class="gl-font-weight-bold"
>
Sec
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
enabled="true"
variant="info"
/>
DAST
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
enabled="true"
variant="info"
/>
Dependency Scanning
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
enabled="true"
variant="info"
/>
Fuzz Testing
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
enabled="true"
variant="info"
/>
SAST
</div>
</div>
<div
class=""
>
<div
class="gl-font-weight-bold"
>
Ops
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
enabled="true"
variant="success"
/>
Deploys
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
variant="success"
/>
Pipelines
</div>
<div>
<devops-adoption-table-cell-flag-stub
class="gl-mr-3"
enabled="true"
variant="success"
/>
Runners
</div>
</div>
</div>
</div>
`;
exports[`DevopsAdoptionOverviewChart default state computes the correct series data 1`] = `
Array [
Object {
"data": Array [
1,
],
"name": "Dev",
},
Object {
"data": Array [
4,
],
"name": "Sec",
},
Object {
"data": Array [
2,
],
"name": "Ops",
},
]
`;
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import DevopsAdoptionOverviewChart from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_chart.vue';
import getSnapshotsQuery from 'ee/analytics/devops_report/devops_adoption/graphql/queries/devops_adoption_overview_chart.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { namespaceWithSnapotsData } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
const mockWithData = jest.fn().mockResolvedValue(namespaceWithSnapotsData);
describe('DevopsAdoptionOverviewChart', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
const createComponent = ({ stubs = {}, mockSnapshotsQuery = mockWithData, data = {} } = {}) => {
const handlers = [[getSnapshotsQuery, mockSnapshotsQuery]];
wrapper = shallowMount(DevopsAdoptionOverviewChart, {
localVue,
provide: {
groupGid: 'gid:123',
},
apolloProvider: createMockApollo(handlers),
data() {
return {
...data,
};
},
stubs,
});
};
describe('default state', () => {
beforeEach(() => {
createComponent();
});
it('does not display the chart loader', () => {
expect(wrapper.findComponent(ChartSkeletonLoader).exists()).toBe(false);
});
it('displays the chart', () => {
expect(wrapper.findComponent(GlStackedColumnChart).exists()).toBe(true);
});
it('computes the correct series data', () => {
expect(wrapper.findComponent(GlStackedColumnChart).props('bars')).toMatchSnapshot();
});
});
describe('loading', () => {
it('displays the chart loader', () => {
createComponent({});
expect(wrapper.findComponent(ChartSkeletonLoader).exists()).toBe(true);
});
it('does not display the chart', () => {
createComponent({});
expect(wrapper.findComponent(GlStackedColumnChart).exists()).toBe(false);
});
});
describe('chart tooltip', () => {
beforeEach(() => {
const mockParams = {
value: 'Jan',
seriesData: [{ dataIndex: 0 }],
};
createComponent({
stubs: {
GlStackedColumnChart: {
props: ['formatTooltipText'],
mounted() {
this.formatTooltipText(mockParams);
},
template: `
<div>
<slot name="tooltip-title"></slot>
<slot name="tooltip-content"></slot>
</div>`,
},
},
data: {
devopsAdoptionEnabledNamespaces: {
nodes: namespaceWithSnapotsData.data.devopsAdoptionEnabledNamespaces.nodes,
},
},
});
});
it('displays the tooltip information correctly', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
import { GlLoadingIcon } from '@gitlab/ui';
import DevopsAdoptionOverviewChart from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_chart.vue';
import DevopsAdoptionOverview from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview.vue';
import DevopsAdoptionOverviewCard from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_card.vue';
import DevopsAdoptionOverviewTable from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_table.vue';
......@@ -8,13 +9,14 @@ import { devopsAdoptionNamespaceData, overallAdoptionData } from '../mock_data';
describe('DevopsAdoptionOverview', () => {
let wrapper;
const createComponent = (props) => {
const createComponent = ({ props = {}, provide = {} } = {}) => {
wrapper = shallowMountExtended(DevopsAdoptionOverview, {
propsData: {
timestamp: '2020-10-31 23:59',
data: devopsAdoptionNamespaceData,
...props,
},
provide,
});
};
......@@ -50,12 +52,16 @@ describe('DevopsAdoptionOverview', () => {
it('displays the overview table', () => {
expect(wrapper.findComponent(DevopsAdoptionOverviewTable).exists()).toBe(true);
});
it('does not display the overview chart', () => {
expect(wrapper.findComponent(DevopsAdoptionOverviewChart).exists()).toBe(false);
});
});
});
describe('loading', () => {
beforeEach(() => {
createComponent({ loading: true });
createComponent({ props: { loading: true } });
});
it('displays a loading icon', () => {
......@@ -66,4 +72,14 @@ describe('DevopsAdoptionOverview', () => {
expect(wrapper.findByTestId('overview-container').exists()).toBe(false);
});
});
describe('group level', () => {
beforeEach(() => {
createComponent({ provide: { groupGid: 'gid:123' } });
});
it('displays the overview chart', () => {
expect(wrapper.findComponent(DevopsAdoptionOverviewChart).exists()).toBe(true);
});
});
});
......@@ -2,6 +2,10 @@ import json from 'test_fixtures/graphql/analytics/devops_report/devops_adoption/
import { DEVOPS_ADOPTION_TABLE_CONFIGURATION } from 'ee/analytics/devops_report/devops_adoption/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export const namespaceWithSnapotsData = getJSONFixture(
'graphql/analytics/devops_report/devops_adoption/graphql/queries/devops_adoption_overview_chart.query.graphql.json',
);
export const devopsAdoptionNamespaceData = json.data.devopsAdoptionEnabledNamespaces;
export const groupData = devopsAdoptionNamespaceData.nodes.map((node) => {
......
import { shouldPollTableData } from 'ee/analytics/devops_report/devops_adoption/utils/helpers';
import { devopsAdoptionNamespaceData } from '../mock_data';
import {
shouldPollTableData,
getAdoptedCountsByCols,
} from 'ee/analytics/devops_report/devops_adoption/utils/helpers';
import { DEVOPS_ADOPTION_TABLE_CONFIGURATION } from 'ee/analytics/devops_report/devops_adoption/constants';
import { devopsAdoptionNamespaceData, namespaceWithSnapotsData } from '../mock_data';
describe('shouldPollTableData', () => {
const { nodes: pendingData } = devopsAdoptionNamespaceData;
......@@ -15,3 +19,27 @@ describe('shouldPollTableData', () => {
expect(shouldPollTableData({ enabledNamespaces, timestamp, openModal })).toBe(expected);
});
});
describe('getAdoptedCountsByCols', () => {
const {
snapshots: { nodes },
} = namespaceWithSnapotsData.data.devopsAdoptionEnabledNamespaces.nodes[0];
it.each`
snapshots | cols | expected
${nodes} | ${DEVOPS_ADOPTION_TABLE_CONFIGURATION[0].cols} | ${[1]}
${[...nodes, ...nodes]} | ${DEVOPS_ADOPTION_TABLE_CONFIGURATION[0].cols} | ${[1, 1]}
${nodes} | ${DEVOPS_ADOPTION_TABLE_CONFIGURATION[1].cols} | ${[4]}
${[...nodes, ...nodes]} | ${DEVOPS_ADOPTION_TABLE_CONFIGURATION[1].cols} | ${[4, 4]}
${nodes} | ${DEVOPS_ADOPTION_TABLE_CONFIGURATION[2].cols} | ${[2]}
${[...nodes, ...nodes]} | ${DEVOPS_ADOPTION_TABLE_CONFIGURATION[2].cols} | ${[2, 2]}
${[]} | ${DEVOPS_ADOPTION_TABLE_CONFIGURATION[1].cols} | ${[]}
${nodes} | ${[]} | ${[0]}
${[]} | ${[]} | ${[]}
`(
'returns the correct data set based on the snapshots and cols',
({ snapshots, cols, expected }) => {
expect(getAdoptedCountsByCols(snapshots, cols)).toStrictEqual(expected);
},
);
});
......@@ -25,8 +25,8 @@ RSpec.describe 'DevOps Adoption (GraphQL fixtures)' do
Analytics::DevopsAdoption::Snapshot::BOOLEAN_METRICS.each.with_index do |m, i|
result[m] = i.odd?
end
Analytics::DevopsAdoption::Snapshot::NUMERIC_METRICS.each do |m|
result[m] = rand(10)
Analytics::DevopsAdoption::Snapshot::NUMERIC_METRICS.each.with_index do |m, i|
result[m] = i
end
result[:total_projects_count] += 10
result
......@@ -52,5 +52,17 @@ RSpec.describe 'DevOps Adoption (GraphQL fixtures)' do
expect_graphql_errors_to_be_empty
end
query_path = 'analytics/devops_report/devops_adoption/graphql/queries/devops_adoption_overview_chart.query.graphql'
it "graphql/#{query_path}.json" do
query = get_graphql_query_as_string(query_path, ee: true)
travel_to(DateTime.parse('2021-02-02')) do
post_graphql(query, current_user: current_user, variables: { displayNamespaceId: group.to_gid.to_s, startDate: '2020-06-31', endDate: '2021-03-31' } )
end
expect_graphql_errors_to_be_empty
end
end
end
......@@ -11704,6 +11704,9 @@ msgstr ""
msgid "DevopsAdoption|Adoption by subgroup"
msgstr ""
msgid "DevopsAdoption|Adoption over time"
msgstr ""
msgid "DevopsAdoption|An error occurred while removing the group. Please try again."
msgstr ""
......@@ -11782,6 +11785,9 @@ msgstr ""
msgid "DevopsAdoption|No results…"
msgstr ""
msgid "DevopsAdoption|No tracked features"
msgstr ""
msgid "DevopsAdoption|Not adopted"
msgstr ""
......@@ -11827,6 +11833,9 @@ msgstr ""
msgid "DevopsAdoption|This group has no subgroups"
msgstr ""
msgid "DevopsAdoption|Total number of features adopted"
msgstr ""
msgid "DevopsAdoption|You cannot remove the group you are currently in."
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