Commit 7517025d authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Miguel Rincon

Group-level "DevOps Adoption" - Show data table [RUN AS-IF-FOSS]

parent 4223146f
......@@ -46,6 +46,7 @@ in one place.
The following analytics features are available at the group level:
- [Contribution](../group/contribution_analytics/index.md). **(PREMIUM)**
- [DevOps Adoption](../group/devops_adoption/index.md). **(ULTIMATE)**
- [Insights](../group/insights/index.md). **(ULTIMATE)**
- [Issue](../group/issues_analytics/index.md). **(PREMIUM)**
- [Productivity](productivity_analytics.md). **(PREMIUM)**
......
---
stage: Manage
group: Optimize
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Group DevOps Adoption **(ULTIMATE)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/321083) in GitLab 13.11.
> - [Deployed behind a feature flag](../../../user/feature_flags.md), disabled by default.
> - Disabled on GitLab.com.
> - Not recommended for production use.
> - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-group-devops-adoption).
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/321083) in GitLab 13.11 as a [Beta feature](https://about.gitlab.com/handbook/product/gitlab-the-product/#beta).
Group DevOps Adoption shows you how individual groups and sub-groups within your organization use the following features:
- Issues
- Merge Requests
- Approvals
- Runners
- Pipelines
- Deployments
- Scans
When managing groups in the UI, you can manage your sub-groups with the **Add/Remove sub-groups**
button, in the top right hand section of your Groups pages.
DevOps Adoption allows you to:
- Verify whether you are getting the return on investment that you expected from GitLab.
- Identify specific sub-groups that are lagging in their adoption of GitLab so you can help them along in their DevOps journey.
- Find the sub-groups that have adopted certain features and can provide guidance to other sub-groups on how to use those features.
![DevOps Report](img/group_devops_adoption_v13_11.png)
## Enable or disable Group DevOps Adoption **(ULTIMATE)**
Group DevOps Adoption is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:group_devops_adoption)
```
To disable it:
```ruby
Feature.disable(:group_devops_adoption)
```
......@@ -291,6 +291,7 @@ group.
| View group Audit Events | | | ✓ (7) | ✓ (7) | ✓ |
| Disable notification emails | | | | | ✓ |
| View Contribution analytics | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Group DevOps Adoption **(ULTIMATE)** | | ✓ | ✓ | ✓ | ✓ |
| View Insights **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Issue analytics **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Productivity analytics **(PREMIUM)** | | ✓ | ✓ | ✓ | ✓ |
......
......@@ -17,9 +17,12 @@ import {
DATE_TIME_FORMAT,
DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
DEFAULT_POLLING_INTERVAL,
DEVOPS_ADOPTION_GROUP_LEVEL_LABEL,
} from '../constants';
import bulkFindOrCreateDevopsAdoptionSegmentsMutation from '../graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql';
import devopsAdoptionSegmentsQuery from '../graphql/queries/devops_adoption_segments.query.graphql';
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import { addSegmentsToCache } from '../utils/cache_updates';
import { shouldPollTableData } from '../utils/helpers';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import DevopsAdoptionSegmentModal from './devops_adoption_segment_modal.vue';
......@@ -40,7 +43,16 @@ export default {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
inject: {
isGroup: {
default: false,
},
groupGid: {
default: null,
},
},
i18n: {
groupLevelLabel: DEVOPS_ADOPTION_GROUP_LEVEL_LABEL,
...DEVOPS_ADOPTION_STRINGS.app,
},
maxSegments: MAX_SEGMENTS,
......@@ -48,23 +60,45 @@ export default {
data() {
return {
isLoadingGroups: false,
isLoadingEnableGroup: false,
requestCount: 0,
selectedSegment: null,
openModal: false,
errors: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: false,
[DEVOPS_ADOPTION_ERROR_KEYS.segments]: false,
[DEVOPS_ADOPTION_ERROR_KEYS.addSegment]: false,
},
groups: {
nodes: [],
pageInfo: null,
},
pollingTableData: null,
variables: this.isGroup
? {
parentNamespaceId: this.groupGid,
directDescendantsOnly: false,
}
: {},
};
},
apollo: {
devopsAdoptionSegments: {
query: devopsAdoptionSegmentsQuery,
variables() {
return this.variables;
},
result({ data }) {
if (this.isGroup) {
const groupEnabled = data.devopsAdoptionSegments.nodes.some(
({ namespace: { id } }) => id === this.groupGid,
);
if (!groupEnabled) {
this.enableGroup();
}
}
},
error(error) {
this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.segments, error);
},
......@@ -87,7 +121,11 @@ export default {
);
},
isLoading() {
return this.isLoadingGroups || this.$apollo.queries.devopsAdoptionSegments.loading;
return (
this.isLoadingGroups ||
this.isLoadingEnableGroup ||
this.$apollo.queries.devopsAdoptionSegments.loading
);
},
modalKey() {
return this.selectedSegment?.id;
......@@ -98,6 +136,11 @@ export default {
addSegmentButtonTooltipText() {
return this.segmentLimitReached ? this.$options.i18n.tableHeader.buttonTooltip : false;
},
editGroupsButtonLabel() {
return this.isGroup
? this.$options.i18n.groupLevelLabel
: this.$options.i18n.tableHeader.button;
},
},
created() {
this.fetchGroups();
......@@ -106,6 +149,34 @@ export default {
clearInterval(this.pollingTableData);
},
methods: {
enableGroup() {
this.isLoadingEnableGroup = true;
this.$apollo
.mutate({
mutation: bulkFindOrCreateDevopsAdoptionSegmentsMutation,
variables: {
namespaceIds: [this.groupGid],
},
update: (store, { data }) => {
const {
bulkFindOrCreateDevopsAdoptionSegments: { segments, errors },
} = data;
if (errors.length) {
this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.addSegment, errors);
} else {
addSegmentsToCache(store, segments, this.variables);
}
},
})
.catch((error) => {
this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.addSegment, error);
})
.finally(() => {
this.isLoadingEnableGroup = false;
});
},
pollTableData() {
const shouldPoll = shouldPollTableData({
segments: this.devopsAdoptionSegments.nodes,
......@@ -191,12 +262,16 @@ export default {
<template #timestamp>{{ timestamp }}</template>
</gl-sprintf>
</span>
<span v-gl-tooltip.hover="addSegmentButtonTooltipText" data-testid="segmentButtonWrapper">
<span
v-if="hasGroupData"
v-gl-tooltip.hover="addSegmentButtonTooltipText"
data-testid="segmentButtonWrapper"
>
<gl-button
v-gl-modal="$options.devopsSegmentModalId"
:disabled="segmentLimitReached"
@click="clearSelectedSegment"
>{{ $options.i18n.tableHeader.button }}</gl-button
>{{ editGroupsButtonLabel }}</gl-button
></span
>
</div>
......
......@@ -3,7 +3,11 @@ import { GlFormGroup, GlFormInput, GlFormCheckboxTree, GlModal, GlAlert, GlIcon
import * as Sentry from '@sentry/browser';
import _ from 'lodash';
import { convertToGraphQLId, getIdFromGraphQLId, TYPE_GROUP } from '~/graphql_shared/utils';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
import {
DEVOPS_ADOPTION_STRINGS,
DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
DEVOPS_ADOPTION_GROUP_LEVEL_LABEL,
} from '../constants';
import bulkFindOrCreateDevopsAdoptionSegmentsMutation from '../graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql';
import deleteDevopsAdoptionSegmentMutation from '../graphql/mutations/delete_devops_adoption_segment.mutation.graphql';
import { addSegmentsToCache, deleteSegmentsFromCache } from '../utils/cache_updates';
......@@ -18,6 +22,11 @@ export default {
GlAlert,
GlIcon,
},
inject: {
isGroup: {
default: false,
},
},
props: {
groups: {
type: Array,
......@@ -81,7 +90,7 @@ export default {
return this.errors[0];
},
modalTitle() {
return this.$options.i18n.addingTitle;
return this.isGroup ? DEVOPS_ADOPTION_GROUP_LEVEL_LABEL : this.$options.i18n.addingTitle;
},
filteredOptions() {
return this.filter
......
......@@ -15,6 +15,7 @@ import {
DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID,
DEVOPS_ADOPTION_SEGMENTS_TABLE_SORT_BY_STORAGE_KEY,
DEVOPS_ADOPTION_SEGMENTS_TABLE_SORT_DESC_STORAGE_KEY,
DEVOPS_ADOPTION_TABLE_REMOVE_BUTTON_DISABLED,
} from '../constants';
import DevopsAdoptionDeleteModal from './devops_adoption_delete_modal.vue';
import DevopsAdoptionTableCellFlag from './devops_adoption_table_cell_flag.vue';
......@@ -67,9 +68,6 @@ export default {
GlIcon,
GlBadge,
},
i18n,
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
devopsSegmentDeleteModalId: DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID,
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
......@@ -79,6 +77,12 @@ export default {
default: null,
},
},
i18n: {
...i18n,
removeButtonDisabled: DEVOPS_ADOPTION_TABLE_REMOVE_BUTTON_DISABLED,
},
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
devopsSegmentDeleteModalId: DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID,
tableHeaderFields: [
...headers,
{
......@@ -118,6 +122,11 @@ export default {
isCurrentGroup(item) {
return item.namespace?.id === this.groupGid;
},
getDeleteButtonTooltipText(item) {
return this.isCurrentGroup(item)
? this.$options.i18n.removeButtonDisabled
: this.$options.i18n.removeButton;
},
},
};
</script>
......@@ -226,15 +235,18 @@ export default {
</template>
<template #cell(actions)="{ item }">
<div :data-testid="$options.testids.ACTIONS">
<span
v-gl-tooltip.hover="getDeleteButtonTooltipText(item)"
:data-testid="$options.testids.ACTIONS"
>
<gl-button
v-gl-modal="$options.devopsSegmentDeleteModalId"
v-gl-tooltip.hover="$options.i18n.removeButton"
:disabled="isCurrentGroup(item)"
category="tertiary"
icon="remove"
@click="setSelectedSegment(item)"
/>
</div>
</span>
</template>
</gl-table>
<devops-adoption-delete-modal
......
......@@ -15,21 +15,31 @@ export const DATE_TIME_FORMAT = 'yyyy-mm-dd HH:MM';
export const DEVOPS_ADOPTION_ERROR_KEYS = {
groups: 'groupsError',
segments: 'segmentsError',
addSegment: 'addSegmentsError',
};
export const DEVOPS_ADOPTION_GROUP_LEVEL_LABEL = s__('DevopsAdoption|Add/remove sub-groups');
export const DEVOPS_ADOPTION_TABLE_REMOVE_BUTTON_DISABLED = s__(
'DevopsAdoption|You cannot remove the group you are currently in.',
);
export const DEVOPS_ADOPTION_STRINGS = {
app: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__(
'DevopsAdoption|There was an error fetching Groups. Please refresh the page to try again.',
'DevopsAdoption|There was an error fetching Groups. Please refresh the page.',
),
[DEVOPS_ADOPTION_ERROR_KEYS.segments]: s__(
'DevopsAdoption|There was an error fetching Group adoption data. Please refresh the page to try again.',
'DevopsAdoption|There was an error fetching Group adoption data. Please refresh the page.',
),
[DEVOPS_ADOPTION_ERROR_KEYS.addSegment]: s__(
'DevopsAdoption|There was an error enabling the current group. Please refresh the page.',
),
tableHeader: {
text: s__(
'DevopsAdoption|Feature adoption is based on usage in the last calendar month. Last updated: %{timestamp}.',
),
button: s__('DevopsAdoption|Add / remove groups'),
button: s__('DevopsAdoption|Add/remove groups'),
buttonTooltip: sprintf(s__('DevopsAdoption|Maximum %{maxSegments} groups allowed'), {
maxSegments: MAX_SEGMENTS,
}),
......@@ -43,7 +53,7 @@ export const DEVOPS_ADOPTION_STRINGS = {
button: s__('DevopsAdoption|Add Group'),
},
modal: {
addingTitle: s__('DevopsAdoption|Add / remove groups'),
addingTitle: s__('DevopsAdoption|Add/remove groups'),
addingButton: s__('DevopsAdoption|Save changes'),
cancel: __('Cancel'),
namePlaceholder: s__('DevopsAdoption|My group'),
......@@ -52,7 +62,7 @@ export const DEVOPS_ADOPTION_STRINGS = {
noResults: s__('DevopsAdoption|No filter results.'),
},
table: {
removeButton: s__('DevopsAdoption|Remove Group from the table'),
removeButton: s__('DevopsAdoption|Remove Group from the table.'),
headers: {
name: {
label: __('Group'),
......
......@@ -6,10 +6,12 @@ import axios from '~/lib/utils/axios_utils';
Vue.use(VueApollo);
export const resolvers = {
export const createResolvers = (groupId) => ({
Query: {
groups(_, { search, nextPage }) {
const url = Api.buildUrl(Api.groupsPath);
const url = groupId
? Api.buildUrl(Api.subgroupsPath).replace(':id', groupId)
: Api.buildUrl(Api.groupsPath);
const params = {
per_page: Api.DEFAULT_PER_PAGE,
search,
......@@ -34,10 +36,9 @@ export const resolvers = {
});
},
},
};
const defaultClient = createDefaultClient(resolvers);
export default new VueApollo({
defaultClient,
});
export const createApolloProvider = (groupId) =>
new VueApollo({
defaultClient: createDefaultClient(createResolvers(groupId)),
});
query devopsAdoptionSegments {
devopsAdoptionSegments {
query($parentNamespaceId: NamespaceID, $directDescendantsOnly: Boolean) {
devopsAdoptionSegments(
parentNamespaceId: $parentNamespaceId
directDescendantsOnly: $directDescendantsOnly
) {
nodes {
id
latestSnapshot {
......
import Vue from 'vue';
import { convertToGraphQLId, TYPE_GROUP } from '~/graphql_shared/utils';
import DevopsAdoptionApp from './components/devops_adoption_app.vue';
import apolloProvider from './graphql';
import { createApolloProvider } from './graphql';
export default () => {
const el = document.querySelector('.js-devops-adoption');
if (!el) return false;
const { emptyStateSvgPath } = el.dataset;
const { emptyStateSvgPath, groupId } = el.dataset;
const isGroup = Boolean(groupId);
return new Vue({
el,
apolloProvider,
apolloProvider: createApolloProvider(groupId),
provide: {
emptyStateSvgPath,
isGroup,
groupGid: isGroup ? convertToGraphQLId(TYPE_GROUP, groupId) : null,
},
render(h) {
return h(DevopsAdoptionApp);
......
import produce from 'immer';
import devopsAdoptionSegmentsQuery from '../graphql/queries/devops_adoption_segments.query.graphql';
export const addSegmentsToCache = (store, segments) => {
export const addSegmentsToCache = (store, segments, variables) => {
const sourceData = store.readQuery({
query: devopsAdoptionSegmentsQuery,
variables,
});
const data = produce(sourceData, (draftData) => {
......@@ -15,6 +16,7 @@ export const addSegmentsToCache = (store, segments) => {
store.writeQuery({
query: devopsAdoptionSegmentsQuery,
variables,
data,
});
};
......
import initDevopsAdoptionApp from 'ee/analytics/devops_report/devops_adoption';
initDevopsAdoptionApp();
- page_title _("DevOps Adoption")
- add_page_specific_style 'page_bundles/dev_ops_report'
%h2
= _('DevOps Adoption')
.js-devops-adoption{ data: { empty_state_svg_path: image_path('illustrations/monitoring/getting_started.svg'), group_id: @group.id } }
......@@ -14,8 +14,10 @@ import {
MAX_SEGMENTS,
DEFAULT_POLLING_INTERVAL,
} from 'ee/analytics/devops_report/devops_adoption/constants';
import bulkFindOrCreateDevopsAdoptionSegmentsMutation from 'ee/analytics/devops_report/devops_adoption/graphql/mutations/bulk_find_or_create_devops_adoption_segments.mutation.graphql';
import devopsAdoptionSegments from 'ee/analytics/devops_report/devops_adoption/graphql/queries/devops_adoption_segments.query.graphql';
import getGroupsQuery from 'ee/analytics/devops_report/devops_adoption/graphql/queries/get_groups.query.graphql';
import { addSegmentsToCache } from 'ee/analytics/devops_report/devops_adoption/utils/cache_updates';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -27,6 +29,10 @@ import {
devopsAdoptionSegmentsDataEmpty,
} from '../mock_data';
jest.mock('ee/analytics/devops_report/devops_adoption/utils/cache_updates', () => ({
addSegmentsToCache: jest.fn(),
}));
const localVue = createLocalVue();
Vue.use(VueApollo);
......@@ -43,15 +49,33 @@ describe('DevopsAdoptionApp', () => {
const segmentsEmpty = jest
.fn()
.mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsDataEmpty } });
const addSegmentMutationSpy = jest.fn().mockResolvedValue({
data: {
bulkFindOrCreateDevopsAdoptionSegments: {
segments: [devopsAdoptionSegmentsData.nodes[0]],
errors: [],
},
},
});
function createMockApolloProvider(options = {}) {
const { groupsSpy = groupsEmpty, segmentsSpy = segmentsEmpty } = options;
const mockApollo = createMockApollo([[devopsAdoptionSegments, segmentsSpy]], {
Query: {
groups: groupsSpy,
const {
groupsSpy = groupsEmpty,
segmentsSpy = segmentsEmpty,
addSegmentsSpy = addSegmentMutationSpy,
} = options;
const mockApollo = createMockApollo(
[
[bulkFindOrCreateDevopsAdoptionSegmentsMutation, addSegmentsSpy],
[devopsAdoptionSegments, segmentsSpy],
],
{
Query: {
groups: groupsSpy,
},
},
});
);
// Necessary for local resolvers to be activated
mockApollo.defaultClient.cache.writeQuery({
......@@ -63,11 +87,12 @@ describe('DevopsAdoptionApp', () => {
}
function createComponent(options = {}) {
const { mockApollo, data = {} } = options;
const { mockApollo, data = {}, provide = {} } = options;
return shallowMount(DevopsAdoptionApp, {
localVue,
apolloProvider: mockApollo,
provide,
stubs: {
GlSprintf,
},
......@@ -319,12 +344,13 @@ describe('DevopsAdoptionApp', () => {
});
});
describe('when there is segment data', () => {
describe('when there is segment data and group data', () => {
beforeEach(async () => {
const segmentsWithData = jest
.fn()
.mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsData } });
const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsWithData });
const groupsSpy = jest.fn().mockResolvedValueOnce({ ...initialResponse, pageInfo: null });
const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsWithData, groupsSpy });
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
......@@ -373,6 +399,7 @@ describe('DevopsAdoptionApp', () => {
it('displays the add segment button', () => {
expect(segmentButton.exists()).toBe(true);
expect(segmentButton.text()).toBe('Add/remove groups');
});
it('calls the gl-modal show', async () => {
......@@ -414,6 +441,153 @@ describe('DevopsAdoptionApp', () => {
});
});
describe('when there is segment data but no group 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 modal button', () => {
const segmentButtonWrapper = wrapper.find("[data-testid='segmentButtonWrapper']");
expect(segmentButtonWrapper.exists()).toBe(false);
});
});
describe('when there is no active group', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('does not attempt to enable a group', () => {
expect(addSegmentMutationSpy).toHaveBeenCalledTimes(0);
});
});
describe('when there is an active group', () => {
const groupGid = devopsAdoptionSegmentsData.nodes[0].namespace.id;
describe('which is enabled', () => {
beforeEach(async () => {
const segmentsWithData = jest.fn().mockResolvedValue({
data: { devopsAdoptionSegments: devopsAdoptionSegmentsData },
});
const mockApollo = createMockApolloProvider({
segmentsSpy: segmentsWithData,
});
const provide = {
isGroup: true,
groupGid,
};
wrapper = createComponent({ mockApollo, provide });
await waitForPromises();
await wrapper.vm.$nextTick();
});
it('does not attempt to enable a group', () => {
expect(addSegmentMutationSpy).toHaveBeenCalledTimes(0);
});
});
describe('which is not enabled', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider();
const provide = {
isGroup: true,
groupGid,
};
wrapper = createComponent({ mockApollo, provide });
await waitForPromises();
await wrapper.vm.$nextTick();
});
describe('enables the group', () => {
it('makes a request with the correct variables', () => {
expect(addSegmentMutationSpy).toHaveBeenCalledTimes(1);
expect(addSegmentMutationSpy).toHaveBeenCalledWith(
expect.objectContaining({
namespaceIds: [groupGid],
}),
);
expect(addSegmentsToCache).toHaveBeenCalledTimes(1);
});
describe('error handling', () => {
const addSegmentsSpyErrorMessage = 'Error: bar!';
beforeEach(async () => {
jest.spyOn(Sentry, 'captureException');
const addSegmentsSpyError = jest.fn().mockRejectedValue(addSegmentsSpyErrorMessage);
const provide = {
isGroup: true,
groupGid,
};
const mockApollo = createMockApolloProvider({ addSegmentsSpy: addSegmentsSpyError });
wrapper = createComponent({ mockApollo, provide });
await waitForPromises();
await wrapper.vm.$nextTick();
});
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.addSegmentsError);
});
it('calls Sentry', () => {
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(
addSegmentsSpyErrorMessage,
);
});
});
});
});
describe('segment modal button', () => {
beforeEach(async () => {
const segmentsWithData = jest.fn().mockResolvedValue({
data: { devopsAdoptionSegments: devopsAdoptionSegmentsData },
});
const groupsSpy = jest.fn().mockResolvedValueOnce({ ...initialResponse, pageInfo: null });
const mockApollo = createMockApolloProvider({
segmentsSpy: segmentsWithData,
groupsSpy,
});
const provide = {
isGroup: true,
groupGid,
};
wrapper = createComponent({ mockApollo, provide });
await waitForPromises();
await wrapper.vm.$nextTick();
});
it('displays group level text', () => {
const segmentButton = wrapper.find("[data-testid='segmentButtonWrapper']").find(GlButton);
expect(segmentButton.text()).toBe('Add/remove sub-groups');
});
});
});
describe('when there is an error', () => {
const segmentsErrorMessage = 'Error: bar!';
......
......@@ -43,7 +43,7 @@ const mutateWithErrors = jest.fn().mockRejectedValue(genericErrorMessage);
describe('DevopsAdoptionSegmentModal', () => {
let wrapper;
const createComponent = ({ mutationMock = mutate, props = {} } = {}) => {
const createComponent = ({ mutationMock = mutate, props = {}, provide = {} } = {}) => {
const $apollo = {
mutate: mutationMock,
};
......@@ -53,6 +53,7 @@ describe('DevopsAdoptionSegmentModal', () => {
groups: groupNodes,
...props,
},
provide,
stubs: {
GlSprintf,
ApolloMutation,
......@@ -83,6 +84,24 @@ describe('DevopsAdoptionSegmentModal', () => {
expect(modal.props('modalId')).toBe(DEVOPS_ADOPTION_SEGMENT_MODAL_ID);
});
describe('modal title', () => {
it('contains the correct admin level title', () => {
createComponent();
const modal = findModal();
expect(modal.props('title')).toBe('Add/remove groups');
});
it('contains the corrrect group level title', () => {
createComponent({ provide: { isGroup: true } });
const modal = findModal();
expect(modal.props('title')).toBe('Add/remove sub-groups');
});
});
it.each`
enabledGroups | checkboxValues | disabled | condition | state
${[]} | ${[]} | ${true} | ${'no changes'} | ${'disables'}
......
......@@ -99,6 +99,10 @@ describe('DevopsAdoptionTable', () => {
describe('table fields', () => {
describe('segment name', () => {
beforeEach(() => {
createComponent();
});
it('displays the correct segment name', () => {
createComponent();
......@@ -165,14 +169,32 @@ describe('DevopsAdoptionTable', () => {
expect(booleanFlag.props('enabled')).toBe(flag);
});
it('displays the actions icon', () => {
createComponent();
describe.each`
scenario | tooltipText | provide | disabled
${'not active group'} | ${'Remove Group from the table.'} | ${{}} | ${false}
${'active group'} | ${'You cannot remove the group you are currently in.'} | ${{ groupGid: devopsAdoptionSegmentsData.nodes[0].namespace.id }} | ${true}
`('actions column when $scenario', ({ tooltipText, provide, disabled }) => {
beforeEach(() => {
createComponent({ provide });
});
const button = findColSubComponent(TEST_IDS.ACTIONS, GlButton);
it('displays the actions icon', () => {
const button = findColSubComponent(TEST_IDS.ACTIONS, GlButton);
expect(button.exists()).toBe(true);
expect(button.props('icon')).toBe('remove');
expect(button.props('category')).toBe('tertiary');
expect(button.exists()).toBe(true);
expect(button.props('disabled')).toBe(disabled);
expect(button.props('icon')).toBe('remove');
expect(button.props('category')).toBe('tertiary');
});
it('wraps the icon in an element with a tooltip', () => {
const iconWrapper = findCol(TEST_IDS.ACTIONS);
const tooltip = getBinding(iconWrapper.element, 'gl-tooltip');
expect(iconWrapper.exists()).toBe(true);
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(tooltipText);
});
});
});
......
import MockAdapter from 'axios-mock-adapter';
import { createMockClient } from 'mock-apollo-client';
import { resolvers } from 'ee/analytics/devops_report/devops_adoption/graphql';
import { createResolvers } from 'ee/analytics/devops_report/devops_adoption/graphql';
import getGroupsQuery from 'ee/analytics/devops_report/devops_adoption/graphql/queries/get_groups.query.graphql';
import Api from 'ee/api';
import axios from '~/lib/utils/axios_utils';
......@@ -8,23 +8,28 @@ import httpStatus from '~/lib/utils/http_status';
import { groupData, pageData, groupNodes, groupPageInfo } from '../mock_data';
const fetchGroupsUrl = Api.buildUrl(Api.groupsPath);
const fetchSubGroupsUrl = Api.buildUrl(Api.subgroupsPath).replace(':id', 1);
describe('DevOps GraphQL resolvers', () => {
let mockAdapter;
let mockClient;
beforeEach(() => {
mockAdapter = new MockAdapter(axios);
mockClient = createMockClient({ resolvers });
});
describe.each`
type | groupId | url
${'group'} | ${1} | ${fetchSubGroupsUrl}
${'admin'} | ${null} | ${fetchGroupsUrl}
`('$type view query', ({ groupId, url }) => {
beforeEach(() => {
mockAdapter = new MockAdapter(axios);
mockClient = createMockClient({ resolvers: createResolvers(groupId) });
});
afterEach(() => {
mockAdapter.restore();
});
afterEach(() => {
mockAdapter.restore();
});
describe('groups query', () => {
it('fetches all groups', async () => {
mockAdapter.onGet(fetchGroupsUrl).reply(httpStatus.OK, groupData, pageData);
it('fetches all relevent groups / subgroups', async () => {
mockAdapter.onGet(url).reply(httpStatus.OK, groupData, pageData);
await mockClient.query({ query: getGroupsQuery });
expect(mockAdapter.history.get[0].params).not.toEqual(
......@@ -33,7 +38,7 @@ describe('DevOps GraphQL resolvers', () => {
});
it('when receiving groups data', async () => {
mockAdapter.onGet(fetchGroupsUrl).reply(httpStatus.OK, groupData, pageData);
mockAdapter.onGet(url).reply(httpStatus.OK, groupData, pageData);
const result = await mockClient.query({ query: getGroupsQuery });
expect(result.data).toEqual({
......@@ -46,7 +51,7 @@ describe('DevOps GraphQL resolvers', () => {
});
it('when receiving empty groups data', async () => {
mockAdapter.onGet(fetchGroupsUrl).reply(httpStatus.OK, [], pageData);
mockAdapter.onGet(url).reply(httpStatus.OK, [], pageData);
const result = await mockClient.query({ query: getGroupsQuery });
expect(result.data).toEqual({
......@@ -59,7 +64,7 @@ describe('DevOps GraphQL resolvers', () => {
});
it('with no page information', async () => {
mockAdapter.onGet(fetchGroupsUrl).reply(httpStatus.OK, [], {});
mockAdapter.onGet(url).reply(httpStatus.OK, [], {});
const result = await mockClient.query({ query: getGroupsQuery });
expect(result.data).toEqual({
......
......@@ -10759,15 +10759,18 @@ msgstr ""
msgid "DevOps Report"
msgstr ""
msgid "DevopsAdoption|Add / remove groups"
msgstr ""
msgid "DevopsAdoption|Add Group"
msgstr ""
msgid "DevopsAdoption|Add a group to get started"
msgstr ""
msgid "DevopsAdoption|Add/remove groups"
msgstr ""
msgid "DevopsAdoption|Add/remove sub-groups"
msgstr ""
msgid "DevopsAdoption|Adopted"
msgstr ""
......@@ -10840,7 +10843,7 @@ msgstr ""
msgid "DevopsAdoption|Remove Group"
msgstr ""
msgid "DevopsAdoption|Remove Group from the table"
msgid "DevopsAdoption|Remove Group from the table."
msgstr ""
msgid "DevopsAdoption|Runner configured for project/group"
......@@ -10855,10 +10858,16 @@ msgstr ""
msgid "DevopsAdoption|Scanning"
msgstr ""
msgid "DevopsAdoption|There was an error fetching Group adoption data. Please refresh the page to try again."
msgid "DevopsAdoption|There was an error enabling the current group. Please refresh the page."
msgstr ""
msgid "DevopsAdoption|There was an error fetching Group adoption data. Please refresh the page."
msgstr ""
msgid "DevopsAdoption|There was an error fetching Groups. Please refresh the page."
msgstr ""
msgid "DevopsAdoption|There was an error fetching Groups. Please refresh the page to try again."
msgid "DevopsAdoption|You cannot remove the group you are currently in."
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