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>
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 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 { DEVOPS_ADOPTION_STRINGS, MAX_REQUEST_COUNT } from '../constants';
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 {
name: 'DevopsAdoptionApp',
......@@ -13,37 +22,70 @@ export default {
GlLoadingIcon,
DevopsAdoptionEmptyState,
DevopsAdoptionSegmentModal,
DevopsAdoptionTable,
GlButton,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
i18n: {
...DEVOPS_ADOPTION_STRINGS.app,
},
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
data() {
return {
isLoadingGroups: false,
requestCount: 0,
loadingError: false,
isLoading: false,
selectedSegmentId: null,
errors: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: false,
[DEVOPS_ADOPTION_ERROR_KEYS.segments]: false,
},
groups: {
nodes: [],
pageInfo: null,
},
};
},
apollo: {
devopsAdoptionSegments: {
query: devopsAdoptionSegmentsQuery,
error(error) {
this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.segments, error);
},
},
},
computed: {
hasGroupData() {
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() {
this.fetchGroups();
},
methods: {
handleError(error) {
this.loadingError = true;
handleError(key, error) {
this.errors[key] = true;
Sentry.captureException(error);
},
fetchGroups(nextPage) {
this.isLoading = true;
this.isLoadingGroups = true;
this.$apollo
.query({
query: getGroupsQuery,
......@@ -64,25 +106,45 @@ export default {
if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.nextPage) {
this.fetchGroups(pageInfo.nextPage);
} else {
this.isLoading = false;
this.isLoadingGroups = false;
}
})
.catch(this.handleError);
.catch(error => this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.groups, error));
},
},
};
</script>
<template>
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.groupsError }}
</gl-alert>
<div v-if="hasLoadingError">
<template v-for="(error, key) in errors">
<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" />
<div v-else>
<devops-adoption-empty-state :has-groups-data="hasGroupData" />
<devops-adoption-segment-modal
v-if="hasGroupData"
:groups="groups.nodes"
: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>
</template>
......@@ -84,6 +84,7 @@ export default {
<template #cell(issueOpened)="{ item }">
<devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.ISSUES"
:enabled="item.latestSnapshot.issueOpened"
/>
......@@ -91,6 +92,7 @@ export default {
<template #cell(mergeRequestOpened)="{ item }">
<devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.MRS"
:enabled="item.latestSnapshot.mergeRequestOpened"
/>
......@@ -98,6 +100,7 @@ export default {
<template #cell(mergeRequestApproved)="{ item }">
<devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.APPROVALS"
:enabled="item.latestSnapshot.mergeRequestApproved"
/>
......@@ -105,6 +108,7 @@ export default {
<template #cell(runnerConfigured)="{ item }">
<devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.RUNNERS"
:enabled="item.latestSnapshot.runnerConfigured"
/>
......@@ -112,6 +116,7 @@ export default {
<template #cell(pipelineSucceeded)="{ item }">
<devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.PIPELINES"
:enabled="item.latestSnapshot.pipelineSucceeded"
/>
......@@ -119,6 +124,7 @@ export default {
<template #cell(deploySucceeded)="{ item }">
<devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.DEPLOYS"
:enabled="item.latestSnapshot.deploySucceeded"
/>
......@@ -126,6 +132,7 @@ export default {
<template #cell(securityScanSucceeded)="{ item }">
<devops-adoption-table-cell-flag
v-if="item.latestSnapshot"
:data-testid="$options.testids.SCANNING"
:enabled="item.latestSnapshot.securityScanSucceeded"
/>
......
......@@ -4,9 +4,27 @@ export const MAX_REQUEST_COUNT = 10;
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 = {
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: {
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 VueApollo from 'vue-apollo';
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 waitForPromises from 'helpers/wait_for_promises';
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 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 { 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 { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data';
import {
groupNodes,
nextGroupNode,
groupPageInfo,
devopsAdoptionSegmentsData,
devopsAdoptionSegmentsDataEmpty,
} from '../mock_data';
const localVue = createLocalVue();
Vue.use(VueApollo);
......@@ -24,9 +36,15 @@ const initialResponse = {
describe('DevopsAdoptionApp', () => {
let wrapper;
const groupsEmpty = jest.fn().mockResolvedValue({ __typename: 'Groups', nodes: [] });
const segmentsEmpty = jest
.fn()
.mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsDataEmpty } });
function createMockApolloProvider(options = {}) {
const { groupsSpy } = options;
const mockApollo = createMockApollo([], {
const { groupsSpy = groupsEmpty, segmentsSpy = segmentsEmpty } = options;
const mockApollo = createMockApollo([[devopsAdoptionSegments, segmentsSpy]], {
Query: {
groups: groupsSpy,
},
......@@ -47,6 +65,9 @@ describe('DevopsAdoptionApp', () => {
return shallowMount(DevopsAdoptionApp, {
localVue,
apolloProvider: mockApollo,
stubs: {
GlSprintf,
},
data() {
return data;
},
......@@ -163,7 +184,11 @@ describe('DevopsAdoptionApp', () => {
.fn()
.mockResolvedValueOnce(initialResponse)
// `fetchMore` response
.mockResolvedValueOnce({ __typename: 'Groups', nodes: [nextGroupNode], nextPage: null });
.mockResolvedValueOnce({
__typename: 'Groups',
nodes: [nextGroupNode],
nextPage: null,
});
const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
await waitForPromises();
......@@ -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 = {
__typename: 'devopsAdoptionSegments',
};
export const devopsAdoptionSegmentsDataEmpty = {
nodes: [],
__typename: 'devopsAdoptionSegments',
};
export const devopsAdoptionTableHeaders = [
'Segment',
'Issues',
......
......@@ -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."
msgstr ""
msgid "DevopsAdoption|Feature adoption is based on usage over the last 30 days. Last updated: %{timestamp}."
msgstr ""
msgid "DevopsAdoption|Issues"
msgstr ""
......@@ -9579,7 +9582,10 @@ msgstr ""
msgid "DevopsAdoption|Segment"
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 ""
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