Commit dd9c3eed authored by Scott Hampton's avatar Scott Hampton

Fetch more group projects

The GraphQL API only returns a max of
100 projects at a time. This sets up an
observer to watch for when the user
scrolls to the bottom of the dropdown,
and fetches the next "page" of projects.
parent 11cb76ff
......@@ -4,12 +4,16 @@ import {
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
GlIntersectionObserver,
GlLoadingIcon,
GlModal,
GlModalDirective,
GlSearchBoxByType,
} from '@gitlab/ui';
import produce from 'immer';
import { __, s__ } from '~/locale';
import { pikadayToString } from '~/lib/utils/datetime_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getGroupProjects from '../graphql/queries/get_group_projects.query.graphql';
export default {
......@@ -19,6 +23,8 @@ export default {
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
GlIntersectionObserver,
GlLoadingIcon,
GlModal,
GlSearchBoxByType,
},
......@@ -46,15 +52,19 @@ export default {
update(data) {
return data.group.projects.nodes.map(project => ({
...project,
id: project.id.split('Project/')[1],
id: getIdFromGraphQLId(project.id),
isSelected: false,
}));
},
result({ data }) {
this.projectsPageInfo = data?.group?.projects?.pageInfo;
},
},
},
data() {
return {
groupProjects: [],
projectsPageInfo: {},
projectSearchTerm: '',
selectAllProjects: true,
selectedDateRange: this.$options.dateRangeOptions[2],
......@@ -125,6 +135,26 @@ export default {
clickDateRange(dateRange) {
this.selectedDateRange = dateRange;
},
loadMoreProjects() {
if (this.projectsPageInfo.hasNextPage) {
this.$apollo.queries.groupProjects.fetchMore({
variables: {
groupFullPath: this.groupFullPath,
after: this.projectsPageInfo?.endCursor,
},
updateQuery(previousResult, { fetchMoreResult }) {
const results = produce(fetchMoreResult, draftData => {
// eslint-disable-next-line no-param-reassign
draftData.group.projects.nodes = [
...previousResult.group.projects.nodes,
...draftData.group.projects.nodes,
];
});
return results;
},
});
}
},
},
text: {
codeCoverageHeader: s__('RepositoriesAnalytics|Test Code Coverage'),
......@@ -168,17 +198,17 @@ export default {
>
<div>{{ $options.text.downloadCSVModalDescription }}</div>
<div class="gl-my-4">
<label class="gl-display-block col-form-label-sm col-form-label">{{
$options.text.projectDropdownHeader
}}</label>
<label class="gl-display-block col-form-label-sm col-form-label">
{{ $options.text.projectDropdownHeader }}
</label>
<gl-dropdown
:text="$options.text.projectDropdown"
class="gl-w-half"
data-testid="group-code-coverage-project-dropdown"
>
<gl-dropdown-section-header>{{
$options.text.projectDropdownHeader
}}</gl-dropdown-section-header>
<gl-dropdown-section-header>
{{ $options.text.projectDropdownHeader }}
</gl-dropdown-section-header>
<gl-search-box-by-type v-model.trim="projectSearchTerm" class="gl-my-2 gl-mx-3" />
<gl-dropdown-item
:is-check-item="true"
......@@ -196,6 +226,9 @@ export default {
@click.native.capture.stop="clickDropdownProject(project.id)"
>{{ project.name }}</gl-dropdown-item
>
<gl-intersection-observer v-if="projectsPageInfo.hasNextPage" @appear="loadMoreProjects">
<gl-loading-icon v-if="$apollo.queries.groupProjects.loading" size="md" />
</gl-intersection-observer>
</gl-dropdown>
<gl-button
......@@ -208,13 +241,13 @@ export default {
</div>
<div class="gl-my-4">
<label class="gl-display-block col-form-label-sm col-form-label">{{
$options.text.dateRangeHeader
}}</label>
<label class="gl-display-block col-form-label-sm col-form-label">
{{ $options.text.dateRangeHeader }}
</label>
<gl-dropdown :text="selectedDateRange.text" class="gl-w-half">
<gl-dropdown-section-header>{{
$options.text.dateRangeHeader
}}</gl-dropdown-section-header>
<gl-dropdown-section-header>
{{ $options.text.dateRangeHeader }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="dateRange in $options.dateRangeOptions"
:key="dateRange.value"
......
query getGroupProjects($groupFullPath: ID!) {
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getGroupProjects($groupFullPath: ID!, $after: String) {
group(fullPath: $groupFullPath) {
projects {
projects(after: $after, first: 100) {
nodes {
name
id
}
pageInfo {
...PageInfo
}
}
}
}
---
title: Fetch more group projects for the test coverage dropdown
merge_request: 43044
author:
type: added
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlModal } from '@gitlab/ui';
import {
GlDropdown,
GlDropdownItem,
GlIntersectionObserver,
GlLoadingIcon,
GlModal,
} from '@gitlab/ui';
import { useFakeDate } from 'helpers/fake_date';
import GroupRepositoryAnalytics from 'ee/analytics/repository_analytics/components/group_repository_analytics.vue';
......@@ -24,6 +30,8 @@ describe('Group repository analytics app', () => {
wrapper
.find(`[data-testid="group-code-coverage-download-select-project-${id}"]`)
.trigger('click');
const findIntersectionObserver = () => wrapper.find(GlIntersectionObserver);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const injectedProperties = {
groupAnalyticsCoverageReportsPath: '/coverage.csv?ref_path=refs/heads/master',
......@@ -31,24 +39,39 @@ describe('Group repository analytics app', () => {
};
const groupProjectsData = [{ id: 1, name: '1' }, { id: 2, name: '2' }];
const createComponent = () => {
const createComponent = ({ data = {}, apolloGroupProjects = {} }) => {
wrapper = shallowMount(GroupRepositoryAnalytics, {
localVue,
data() {
return {
// Ensure that isSelected is set to false for each project so that every test is reset properly
groupProjects: groupProjectsData.map(project => ({ ...project, isSelected: false })),
projectsPageInfo: {
hasNextPage: false,
endCursor: null,
},
...data,
};
},
provide: {
...injectedProperties,
},
mocks: {
$apollo: {
queries: {
groupProjects: {
fetchMore: jest.fn().mockResolvedValue(),
...apolloGroupProjects,
},
},
},
},
stubs: { GlDropdown, GlDropdownItem, GlModal },
});
};
beforeEach(() => {
createComponent();
createComponent({});
});
afterEach(() => {
......@@ -121,6 +144,52 @@ describe('Group repository analytics app', () => {
expect(findCodeCoverageDownloadButton().attributes('disabled')).toBe('true');
});
});
describe('when there is only one page of projects', () => {
it('should not render the intersection observer component', () => {
expect(findIntersectionObserver().exists()).toBe(false);
});
});
describe('when there is more than a page of projects', () => {
beforeEach(() => {
createComponent({ data: { projectsPageInfo: { hasNextPage: true } } });
});
it('should render the intersection observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
describe('when the intersection observer component appears in view', () => {
beforeEach(() => {
jest
.spyOn(wrapper.vm.$apollo.queries.groupProjects, 'fetchMore')
.mockImplementation(jest.fn());
findIntersectionObserver().vm.$emit('appear');
return wrapper.vm.$nextTick();
});
it('makes a query to fetch more projects', () => {
expect(wrapper.vm.$apollo.queries.groupProjects.fetchMore).toHaveBeenCalled();
});
});
describe('when a query is loading a new page of projects', () => {
beforeEach(() => {
createComponent({
data: { projectsPageInfo: { hasNextPage: true } },
apolloGroupProjects: {
loading: true,
},
});
});
it('should render the loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
});
});
describe('when selecting a date range', () => {
......
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