Commit f03a6040 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '271247-mlunoe-add-fetch-groups-vue-apollo-integration' into 'master'

DevOps Adoption: load groups in app

See merge request gitlab-org/gitlab!46601
parents 9de44187 79fade94
...@@ -6,6 +6,7 @@ import { __ } from '~/locale'; ...@@ -6,6 +6,7 @@ import { __ } from '~/locale';
const DEFAULT_PER_PAGE = 20; const DEFAULT_PER_PAGE = 20;
const Api = { const Api = {
DEFAULT_PER_PAGE,
groupsPath: '/api/:version/groups.json', groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id', groupPath: '/api/:version/groups/:id',
groupMembersPath: '/api/:version/groups/:id/members', groupMembersPath: '/api/:version/groups/:id/members',
......
<script> <script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper';
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import { DEVOPS_ADOPTION_STRINGS } from '../constants';
export default { export default {
name: 'DevopsAdoptionApp', name: 'DevopsAdoptionApp',
components: { components: {
GlAlert,
GlLoadingIcon,
DevopsAdoptionEmptyState, DevopsAdoptionEmptyState,
}, },
i18n: {
...DEVOPS_ADOPTION_STRINGS.app,
},
data() {
return {
loadingError: false,
};
},
apollo: {
groups: {
query: getGroupsQuery,
loadingKey: 'loading',
result() {
if (this.groups?.pageInfo?.nextPage) {
this.fetchNextPage();
}
},
error(error) {
this.handleError(error);
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.groups.loading;
},
isEmpty() {
return this.groups?.nodes?.length === 0;
},
},
methods: {
handleError(error) {
this.loadingError = true;
Sentry.captureException(error);
},
fetchNextPage() {
this.$apollo.queries.groups
.fetchMore({
variables: {
nextPage: this.groups.pageInfo.nextPage,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
const { nodes, ...rest } = fetchMoreResult.groups;
const previousNodes = previousResult.groups.nodes;
return { groups: { ...rest, nodes: [...previousNodes, ...nodes] } };
},
})
.catch(this.handleError);
},
},
}; };
</script> </script>
<template> <template>
<devops-adoption-empty-state /> <gl-loading-icon v-if="isLoading" size="md" class="gl-my-5" />
<gl-alert v-else-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.groupsError }}
</gl-alert>
<devops-adoption-empty-state v-else-if="isEmpty" />
</template> </template>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const DEVOPS_ADOPTION_STRINGS = { export const DEVOPS_ADOPTION_STRINGS = {
app: {
groupsError: s__('DevopsAdoption|There was an error fetching Groups'),
},
emptyState: { emptyState: {
title: s__('DevopsAdoption|Add a segment to get started'), title: s__('DevopsAdoption|Add a segment to get started'),
description: s__( description: s__(
......
import Vue from 'vue'; import Vue from 'vue';
import apolloProvider from './graphql';
import DevopsAdoptionApp from './components/devops_adoption_app.vue'; import DevopsAdoptionApp from './components/devops_adoption_app.vue';
export default () => { export default () => {
...@@ -10,6 +11,7 @@ export default () => { ...@@ -10,6 +11,7 @@ export default () => {
return new Vue({ return new Vue({
el, el,
apolloProvider,
provide: { provide: {
emptyStateSvgPath, emptyStateSvgPath,
}, },
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Api from 'ee/api';
import axios from '~/lib/utils/axios_utils';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const resolvers = {
Query: {
groups(_, { search, nextPage }) {
const url = Api.buildUrl(Api.groupsPath);
const params = {
per_page: Api.DEFAULT_PER_PAGE,
search,
};
if (nextPage) {
params.page = nextPage;
}
return axios.get(url, { params }).then(({ data, headers }) => {
const pageInfo = {
nextPage: headers['x-next-page'],
};
const groups = {
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Groups',
// eslint-disable-next-line @gitlab/require-i18n-strings
nodes: data.map(group => ({ ...group, __typename: 'Group' })),
pageInfo,
};
return groups;
});
},
},
};
const defaultClient = createDefaultClient(resolvers);
export default new VueApollo({
defaultClient,
});
query getGroups($search: String, $nextPage: String) {
groups(search: $search, nextPage: $nextPage) @client
}
...@@ -55,7 +55,7 @@ export default { ...@@ -55,7 +55,7 @@ export default {
.get(url, { .get(url, {
params: { params: {
search: query, search: query,
per_page: 20, per_page: Api.DEFAULT_PER_PAGE,
active: true, active: true,
}, },
}) })
......
import { shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import VueApollo from 'vue-apollo';
import { createMockClient } from 'mock-apollo-client';
import { resolvers as devOpsResolvers } from 'ee/admin/dev_ops_report/graphql';
import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.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 { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants';
import axios from '~/lib/utils/axios_utils';
import * as Sentry from '~/sentry/wrapper';
import { groupNodes, groupPageInfo } from '../mock_data';
const localVue = createLocalVue();
describe('DevopsAdoptionApp', () => { describe('DevopsAdoptionApp', () => {
let wrapper; let wrapper;
let mockAdapter;
const createComponent = (options = {}) => {
const { data = {} } = options;
const mockClient = createMockClient({
resolvers: devOpsResolvers,
});
mockClient.cache.writeQuery({
query: getGroupsQuery,
data,
});
const createComponent = () => { const apolloProvider = new VueApollo({
return shallowMount(DevopsAdoptionApp); defaultClient: mockClient,
});
return shallowMount(DevopsAdoptionApp, {
localVue,
apolloProvider,
});
}; };
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); mockAdapter = new MockAdapter(axios);
});
afterEach(() => {
mockAdapter.restore();
wrapper.destroy();
wrapper = null;
}); });
describe('default behaviour', () => { describe('when loading', () => {
beforeEach(() => {
wrapper = createComponent();
});
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 no data is present', () => {
beforeEach(() => {
const data = {
groups: {
__typename: 'Groups',
nodes: [],
pageInfo: {},
},
};
wrapper = createComponent({ data });
});
it('displays the empty state', () => { it('displays the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true); expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true);
}); });
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
});
describe('when data is present', () => {
beforeEach(() => {
const data = {
groups: {
__typename: 'Groups',
nodes: groupNodes,
pageInfo: groupPageInfo,
},
};
wrapper = createComponent({ data });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore').mockReturnValue(
new Promise(resolve => {
resolve({
groups: {
__typename: 'Groups',
nodes: [],
pageInfo: {},
},
});
}),
);
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('should fetch more data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: { nextPage: 2 },
}),
);
});
});
describe('when error is thrown', () => {
const error = 'Error: foo!';
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
const data = {
groups: {
__typename: 'Groups',
nodes: groupNodes,
pageInfo: groupPageInfo,
},
};
wrapper = createComponent({ data });
jest
.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore')
.mockImplementation(jest.fn().mockRejectedValue(error));
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('should fetch more data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: { nextPage: 2 },
}),
);
});
it('displays the error message and calls Sentry', () => {
const alert = wrapper.find(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError);
expect(Sentry.captureException).toHaveBeenCalledWith(error);
});
}); });
}); });
import Api from 'ee/api';
import MockAdapter from 'axios-mock-adapter';
import { createMockClient } from 'mock-apollo-client';
import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql';
import { resolvers } from 'ee/admin/dev_ops_report/graphql';
import httpStatus from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { groupData, pageData, groupNodes, groupPageInfo } from '../mock_data';
const fetchGroupsUrl = Api.buildUrl(Api.groupsPath);
describe('DevOps GraphQL resolvers', () => {
let mockAdapter;
let mockClient;
beforeEach(() => {
mockAdapter = new MockAdapter(axios);
mockClient = createMockClient({ resolvers });
});
afterEach(() => {
mockAdapter.restore();
});
describe('groups query', () => {
it('when receiving groups data', async () => {
mockAdapter.onGet(fetchGroupsUrl).reply(httpStatus.OK, groupData, pageData);
const result = await mockClient.query({ query: getGroupsQuery });
expect(result.data).toEqual({
groups: {
__typename: 'Groups',
nodes: groupNodes,
pageInfo: groupPageInfo,
},
});
});
it('when receiving empty groups data', async () => {
mockAdapter.onGet(fetchGroupsUrl).reply(httpStatus.OK, [], pageData);
const result = await mockClient.query({ query: getGroupsQuery });
expect(result.data).toEqual({
groups: {
__typename: 'Groups',
nodes: [],
pageInfo: groupPageInfo,
},
});
});
it('with no page information', async () => {
mockAdapter.onGet(fetchGroupsUrl).reply(httpStatus.OK, [], {});
const result = await mockClient.query({ query: getGroupsQuery });
expect(result.data).toEqual({
groups: {
__typename: 'Groups',
nodes: [],
pageInfo: {},
},
});
});
});
});
export const groupData = [{ id: 'foo', full_name: 'Foo' }, { id: 'bar', full_name: 'Bar' }];
export const pageData = {
'x-next-page': 2,
};
export const groupNodes = [
{
__typename: 'Group',
full_name: 'Foo',
id: 'foo',
},
{
__typename: 'Group',
full_name: 'Bar',
id: 'bar',
},
];
export const groupPageInfo = {
nextPage: 2,
};
export const devopsAdoptionSegmentsData = { export const devopsAdoptionSegmentsData = {
nodes: [ nodes: [
{ {
......
...@@ -9490,6 +9490,9 @@ msgstr "" ...@@ -9490,6 +9490,9 @@ msgstr ""
msgid "DevopsAdoption|Segment" msgid "DevopsAdoption|Segment"
msgstr "" msgstr ""
msgid "DevopsAdoption|There was an error fetching Groups"
msgstr ""
msgid "DevopsReport|Adoption" msgid "DevopsReport|Adoption"
msgstr "" 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