Commit c9d7ad44 authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Natalia Tepluhina

Add devops adoption table

Add a table to the devops adoption feature which
displays the data for user defined segments.
parent 1848e5e4
<script> <script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import dateformat from 'dateformat';
import { GlLoadingIcon, GlButton, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql'; import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import devopsAdoptionSegmentsQuery from '../graphql/queries/devops_adoption_segments.query.graphql';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import { DEVOPS_ADOPTION_STRINGS, MAX_REQUEST_COUNT } from '../constants';
import DevopsAdoptionSegmentModal from './devops_adoption_segment_modal.vue'; import DevopsAdoptionSegmentModal from './devops_adoption_segment_modal.vue';
import DevopsAdoptionTable from './devops_adoption_table.vue';
import {
DEVOPS_ADOPTION_STRINGS,
DEVOPS_ADOPTION_ERROR_KEYS,
MAX_REQUEST_COUNT,
DATE_TIME_FORMAT,
DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
} from '../constants';
export default { export default {
name: 'DevopsAdoptionApp', name: 'DevopsAdoptionApp',
...@@ -13,37 +22,70 @@ export default { ...@@ -13,37 +22,70 @@ export default {
GlLoadingIcon, GlLoadingIcon,
DevopsAdoptionEmptyState, DevopsAdoptionEmptyState,
DevopsAdoptionSegmentModal, DevopsAdoptionSegmentModal,
DevopsAdoptionTable,
GlButton,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
}, },
i18n: { i18n: {
...DEVOPS_ADOPTION_STRINGS.app, ...DEVOPS_ADOPTION_STRINGS.app,
}, },
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
data() { data() {
return { return {
isLoadingGroups: false,
requestCount: 0, requestCount: 0,
loadingError: false,
isLoading: false,
selectedSegmentId: null, selectedSegmentId: null,
errors: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: false,
[DEVOPS_ADOPTION_ERROR_KEYS.segments]: false,
},
groups: { groups: {
nodes: [], nodes: [],
pageInfo: null, pageInfo: null,
}, },
}; };
}, },
apollo: {
devopsAdoptionSegments: {
query: devopsAdoptionSegmentsQuery,
error(error) {
this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.segments, error);
},
},
},
computed: { computed: {
hasGroupData() { hasGroupData() {
return Boolean(this.groups?.nodes?.length); return Boolean(this.groups?.nodes?.length);
}, },
hasSegmentsData() {
return Boolean(this.devopsAdoptionSegments?.nodes?.length);
},
hasLoadingError() {
return Object.values(this.errors).some(error => error === true);
},
timestamp() {
return dateformat(
this.devopsAdoptionSegments?.nodes[0]?.latestSnapshot?.recordedAt,
DATE_TIME_FORMAT,
);
},
isLoading() {
return this.isLoadingGroups || this.$apollo.queries.devopsAdoptionSegments.loading;
},
}, },
created() { created() {
this.fetchGroups(); this.fetchGroups();
}, },
methods: { methods: {
handleError(error) { handleError(key, error) {
this.loadingError = true; this.errors[key] = true;
Sentry.captureException(error); Sentry.captureException(error);
}, },
fetchGroups(nextPage) { fetchGroups(nextPage) {
this.isLoading = true; this.isLoadingGroups = true;
this.$apollo this.$apollo
.query({ .query({
query: getGroupsQuery, query: getGroupsQuery,
...@@ -64,25 +106,45 @@ export default { ...@@ -64,25 +106,45 @@ export default {
if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.nextPage) { if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.nextPage) {
this.fetchGroups(pageInfo.nextPage); this.fetchGroups(pageInfo.nextPage);
} else { } else {
this.isLoading = false; this.isLoadingGroups = false;
} }
}) })
.catch(this.handleError); .catch(error => this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.groups, error));
}, },
}, },
}; };
</script> </script>
<template> <template>
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> <div v-if="hasLoadingError">
{{ $options.i18n.groupsError }} <template v-for="(error, key) in errors">
</gl-alert> <gl-alert v-if="error" :key="key" variant="danger" :dismissible="false" class="gl-mt-3">
{{ $options.i18n[key] }}
</gl-alert>
</template>
</div>
<gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" /> <gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" />
<div v-else> <div v-else>
<devops-adoption-empty-state :has-groups-data="hasGroupData" />
<devops-adoption-segment-modal <devops-adoption-segment-modal
v-if="hasGroupData" v-if="hasGroupData"
:groups="groups.nodes" :groups="groups.nodes"
:segment-id="selectedSegmentId" :segment-id="selectedSegmentId"
/> />
<div v-if="hasSegmentsData" class="gl-mt-3">
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-my-3"
data-testid="tableHeader"
>
<span class="gl-text-gray-400">
<gl-sprintf :message="$options.i18n.tableHeader.text">
<template #timestamp>{{ timestamp }}</template>
</gl-sprintf>
</span>
<gl-button v-gl-modal="$options.devopsSegmentModalId">{{
$options.i18n.tableHeader.button
}}</gl-button>
</div>
<devops-adoption-table :segments="devopsAdoptionSegments.nodes" />
</div>
<devops-adoption-empty-state v-else :has-groups-data="hasGroupData" />
</div> </div>
</template> </template>
...@@ -84,6 +84,7 @@ export default { ...@@ -84,6 +84,7 @@ export default {
<template #cell(issueOpened)="{ item }"> <template #cell(issueOpened)="{ item }">
<devops-adoption-table-cell-flag <devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.ISSUES" :data-testid="$options.testids.ISSUES"
:enabled="item.latestSnapshot.issueOpened" :enabled="item.latestSnapshot.issueOpened"
/> />
...@@ -91,6 +92,7 @@ export default { ...@@ -91,6 +92,7 @@ export default {
<template #cell(mergeRequestOpened)="{ item }"> <template #cell(mergeRequestOpened)="{ item }">
<devops-adoption-table-cell-flag <devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.MRS" :data-testid="$options.testids.MRS"
:enabled="item.latestSnapshot.mergeRequestOpened" :enabled="item.latestSnapshot.mergeRequestOpened"
/> />
...@@ -98,6 +100,7 @@ export default { ...@@ -98,6 +100,7 @@ export default {
<template #cell(mergeRequestApproved)="{ item }"> <template #cell(mergeRequestApproved)="{ item }">
<devops-adoption-table-cell-flag <devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.APPROVALS" :data-testid="$options.testids.APPROVALS"
:enabled="item.latestSnapshot.mergeRequestApproved" :enabled="item.latestSnapshot.mergeRequestApproved"
/> />
...@@ -105,6 +108,7 @@ export default { ...@@ -105,6 +108,7 @@ export default {
<template #cell(runnerConfigured)="{ item }"> <template #cell(runnerConfigured)="{ item }">
<devops-adoption-table-cell-flag <devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.RUNNERS" :data-testid="$options.testids.RUNNERS"
:enabled="item.latestSnapshot.runnerConfigured" :enabled="item.latestSnapshot.runnerConfigured"
/> />
...@@ -112,6 +116,7 @@ export default { ...@@ -112,6 +116,7 @@ export default {
<template #cell(pipelineSucceeded)="{ item }"> <template #cell(pipelineSucceeded)="{ item }">
<devops-adoption-table-cell-flag <devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.PIPELINES" :data-testid="$options.testids.PIPELINES"
:enabled="item.latestSnapshot.pipelineSucceeded" :enabled="item.latestSnapshot.pipelineSucceeded"
/> />
...@@ -119,6 +124,7 @@ export default { ...@@ -119,6 +124,7 @@ export default {
<template #cell(deploySucceeded)="{ item }"> <template #cell(deploySucceeded)="{ item }">
<devops-adoption-table-cell-flag <devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.DEPLOYS" :data-testid="$options.testids.DEPLOYS"
:enabled="item.latestSnapshot.deploySucceeded" :enabled="item.latestSnapshot.deploySucceeded"
/> />
...@@ -126,6 +132,7 @@ export default { ...@@ -126,6 +132,7 @@ export default {
<template #cell(securityScanSucceeded)="{ item }"> <template #cell(securityScanSucceeded)="{ item }">
<devops-adoption-table-cell-flag <devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.SCANNING" :data-testid="$options.testids.SCANNING"
:enabled="item.latestSnapshot.securityScanSucceeded" :enabled="item.latestSnapshot.securityScanSucceeded"
/> />
......
...@@ -4,9 +4,27 @@ export const MAX_REQUEST_COUNT = 10; ...@@ -4,9 +4,27 @@ export const MAX_REQUEST_COUNT = 10;
export const DEVOPS_ADOPTION_SEGMENT_MODAL_ID = 'devopsSegmentModal'; export const DEVOPS_ADOPTION_SEGMENT_MODAL_ID = 'devopsSegmentModal';
export const DATE_TIME_FORMAT = 'yyyy-mm-dd HH:MM';
export const DEVOPS_ADOPTION_ERROR_KEYS = {
groups: 'groupsError',
segments: 'segmentsError',
};
export const DEVOPS_ADOPTION_STRINGS = { export const DEVOPS_ADOPTION_STRINGS = {
app: { app: {
groupsError: s__('DevopsAdoption|There was an error fetching Groups'), [DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__(
'DevopsAdoption|There was an error fetching Groups. Please refresh the page to try again.',
),
[DEVOPS_ADOPTION_ERROR_KEYS.segments]: s__(
'DevopsAdoption|There was an error fetching Segments. Please refresh the page to try again.',
),
tableHeader: {
text: s__(
'DevopsAdoption|Feature adoption is based on usage over the last 30 days. Last updated: %{timestamp}.',
),
button: s__('DevopsAdoption|Add new segment'),
},
}, },
emptyState: { emptyState: {
title: s__('DevopsAdoption|Add a segment to get started'), title: s__('DevopsAdoption|Add a segment to get started'),
......
query devopsAdoptionSegments {
devopsAdoptionSegments {
nodes {
name
latestSnapshot {
issueOpened
mergeRequestOpened
mergeRequestApproved
runnerConfigured
pipelineSucceeded
deploySucceeded
securityScanSucceeded
recordedAt
}
}
}
}
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlButton, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
import createMockApollo from 'jest/helpers/mock_apollo_helper'; import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql'; import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql';
import devopsAdoptionSegments from 'ee/admin/dev_ops_report/graphql/queries/devops_adoption_segments.query.graphql';
import DevopsAdoptionApp from 'ee/admin/dev_ops_report/components/devops_adoption_app.vue'; import DevopsAdoptionApp from 'ee/admin/dev_ops_report/components/devops_adoption_app.vue';
import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue';
import DevopsAdoptionTable from 'ee/admin/dev_ops_report/components/devops_adoption_table.vue';
import DevopsAdoptionSegmentModal from 'ee/admin/dev_ops_report/components/devops_adoption_segment_modal.vue'; import DevopsAdoptionSegmentModal from 'ee/admin/dev_ops_report/components/devops_adoption_segment_modal.vue';
import { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants'; import {
DEVOPS_ADOPTION_STRINGS,
DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
} from 'ee/admin/dev_ops_report/constants';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data'; import {
groupNodes,
nextGroupNode,
groupPageInfo,
devopsAdoptionSegmentsData,
devopsAdoptionSegmentsDataEmpty,
} from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -24,9 +36,15 @@ const initialResponse = { ...@@ -24,9 +36,15 @@ const initialResponse = {
describe('DevopsAdoptionApp', () => { describe('DevopsAdoptionApp', () => {
let wrapper; let wrapper;
const groupsEmpty = jest.fn().mockResolvedValue({ __typename: 'Groups', nodes: [] });
const segmentsEmpty = jest
.fn()
.mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsDataEmpty } });
function createMockApolloProvider(options = {}) { function createMockApolloProvider(options = {}) {
const { groupsSpy } = options; const { groupsSpy = groupsEmpty, segmentsSpy = segmentsEmpty } = options;
const mockApollo = createMockApollo([], {
const mockApollo = createMockApollo([[devopsAdoptionSegments, segmentsSpy]], {
Query: { Query: {
groups: groupsSpy, groups: groupsSpy,
}, },
...@@ -47,6 +65,9 @@ describe('DevopsAdoptionApp', () => { ...@@ -47,6 +65,9 @@ describe('DevopsAdoptionApp', () => {
return shallowMount(DevopsAdoptionApp, { return shallowMount(DevopsAdoptionApp, {
localVue, localVue,
apolloProvider: mockApollo, apolloProvider: mockApollo,
stubs: {
GlSprintf,
},
data() { data() {
return data; return data;
}, },
...@@ -163,7 +184,11 @@ describe('DevopsAdoptionApp', () => { ...@@ -163,7 +184,11 @@ describe('DevopsAdoptionApp', () => {
.fn() .fn()
.mockResolvedValueOnce(initialResponse) .mockResolvedValueOnce(initialResponse)
// `fetchMore` response // `fetchMore` response
.mockResolvedValueOnce({ __typename: 'Groups', nodes: [nextGroupNode], nextPage: null }); .mockResolvedValueOnce({
__typename: 'Groups',
nodes: [nextGroupNode],
nextPage: null,
});
const mockApollo = createMockApolloProvider({ groupsSpy }); const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo }); wrapper = createComponent({ mockApollo });
await waitForPromises(); await waitForPromises();
...@@ -253,4 +278,139 @@ describe('DevopsAdoptionApp', () => { ...@@ -253,4 +278,139 @@ describe('DevopsAdoptionApp', () => {
}); });
}); });
}); });
describe('segments data', () => {
describe('when loading', () => {
beforeEach(async () => {
const segmentsLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsLoading });
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('displays the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
describe('when there is no segment data', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('displays the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true);
});
it('does not display the table', () => {
expect(wrapper.find(DevopsAdoptionTable).exists()).toBe(false);
});
});
describe('when there is segment data', () => {
beforeEach(async () => {
const segmentsWithData = jest
.fn()
.mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsData } });
const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsWithData });
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('displays the table', () => {
expect(wrapper.find(DevopsAdoptionTable).exists()).toBe(true);
});
describe('table header', () => {
let tableHeader;
beforeEach(() => {
tableHeader = wrapper.find("[data-testid='tableHeader']");
});
afterEach(() => {
tableHeader = null;
});
it('displays the table header', () => {
expect(tableHeader.exists()).toBe(true);
});
it('displays the header text', () => {
const text =
'Feature adoption is based on usage over the last 30 days. Last updated: 2020-10-31 23:59.';
expect(getByText(wrapper.element, text)).not.toBeNull();
});
describe('segment modal button', () => {
let segmentButton;
beforeEach(() => {
segmentButton = tableHeader.find(GlButton);
});
afterEach(() => {
segmentButton = null;
});
it('displays the add segment button', () => {
expect(segmentButton.exists()).toBe(true);
});
it('calls the gl-modal show', async () => {
const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit');
segmentButton.trigger('click');
expect(rootEmit.mock.calls[0][0]).toContain('show');
expect(rootEmit.mock.calls[0][1]).toBe(DEVOPS_ADOPTION_SEGMENT_MODAL_ID);
});
});
});
});
describe('when there is an error', () => {
const segmentsErrorMessage = 'Error: bar!';
beforeEach(async () => {
jest.spyOn(Sentry, 'captureException');
const segmentsError = jest.fn().mockRejectedValue(segmentsErrorMessage);
const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsError });
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('does not render the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(false);
});
it('does not render the table', () => {
expect(wrapper.find(DevopsAdoptionTable).exists()).toBe(false);
});
it('displays the error message ', () => {
const alert = wrapper.find(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.segmentsError);
});
it('calls Sentry', () => {
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(segmentsErrorMessage);
});
});
});
}); });
...@@ -48,6 +48,11 @@ export const devopsAdoptionSegmentsData = { ...@@ -48,6 +48,11 @@ export const devopsAdoptionSegmentsData = {
__typename: 'devopsAdoptionSegments', __typename: 'devopsAdoptionSegments',
}; };
export const devopsAdoptionSegmentsDataEmpty = {
nodes: [],
__typename: 'devopsAdoptionSegments',
};
export const devopsAdoptionTableHeaders = [ export const devopsAdoptionTableHeaders = [
'Segment', 'Segment',
'Issues', 'Issues',
......
...@@ -9552,6 +9552,9 @@ msgstr "" ...@@ -9552,6 +9552,9 @@ msgstr ""
msgid "DevopsAdoption|DevOps adoption uses segments to track adoption across key features. Segments are a way to track multiple related projects and groups at once. For example, you could create a segment for the engineering department or a particular product team." msgid "DevopsAdoption|DevOps adoption uses segments to track adoption across key features. Segments are a way to track multiple related projects and groups at once. For example, you could create a segment for the engineering department or a particular product team."
msgstr "" msgstr ""
msgid "DevopsAdoption|Feature adoption is based on usage over the last 30 days. Last updated: %{timestamp}."
msgstr ""
msgid "DevopsAdoption|Issues" msgid "DevopsAdoption|Issues"
msgstr "" msgstr ""
...@@ -9579,7 +9582,10 @@ msgstr "" ...@@ -9579,7 +9582,10 @@ msgstr ""
msgid "DevopsAdoption|Segment" msgid "DevopsAdoption|Segment"
msgstr "" msgstr ""
msgid "DevopsAdoption|There was an error fetching Groups" msgid "DevopsAdoption|There was an error fetching Groups. Please refresh the page to try again."
msgstr ""
msgid "DevopsAdoption|There was an error fetching Segments. Please refresh the page to try again."
msgstr "" msgstr ""
msgid "DevopsReport|Adoption" msgid "DevopsReport|Adoption"
......
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