Commit 83709c7a authored by Mike Greiling's avatar Mike Greiling

Merge branch 'infinite-scroll' into 'master'

feat: add Infinite scroll to "Add Projects" modal in the operations dashboard

See merge request gitlab-org/gitlab!17842
parents 130792cf c6bb7584
......@@ -113,10 +113,9 @@ const Api = {
.get(url, {
params: Object.assign(defaults, options),
})
.then(({ data }) => {
.then(({ data, headers }) => {
callback(data);
return data;
return { data, headers };
});
},
......
......@@ -47,7 +47,8 @@ export default {
hasSearchQuery: true,
});
},
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) {
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) {
const rawItems = results.data;
Object.assign(state, {
items: rawItems.map(rawItem => ({
id: rawItem.id,
......
<script>
import _ from 'underscore';
import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
import ProjectListItem from './project_list_item.vue';
const SEARCH_INPUT_TIMEOUT_MS = 500;
......@@ -10,6 +10,7 @@ export default {
components: {
GlLoadingIcon,
GlSearchBoxByType,
GlInfiniteScroll,
ProjectListItem,
},
props: {
......@@ -41,6 +42,11 @@ export default {
required: false,
default: false,
},
totalResults: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
......@@ -51,6 +57,9 @@ export default {
projectClicked(project) {
this.$emit('projectClicked', project);
},
bottomReached() {
this.$emit('bottomReached');
},
isSelected(project) {
return Boolean(_.find(this.selectedProjects, { id: project.id }));
},
......@@ -71,18 +80,25 @@ export default {
@input="onInput"
/>
<div class="d-flex flex-column">
<gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" />
<div v-if="!showLoadingIndicator" class="d-flex flex-column">
<project-list-item
v-for="project in projectSearchResults"
:key="project.id"
:selected="isSelected(project)"
:project="project"
:matcher="searchQuery"
class="js-project-list-item"
@click="projectClicked(project)"
/>
</div>
<gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" />
<gl-infinite-scroll
:max-list-height="402"
:fetched-items="projectSearchResults.length"
:total-items="totalResults"
@bottomReached="bottomReached"
>
<div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column">
<project-list-item
v-for="project in projectSearchResults"
:key="project.id"
:selected="isSelected(project)"
:project="project"
:matcher="searchQuery"
class="js-project-list-item"
@click="projectClicked(project)"
/>
</div>
</gl-infinite-scroll>
<div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
{{ __('Sorry, no projects matched your search') }}
</div>
......
---
title: Add Infinite scroll to Add Projects modal in the operations dashboard
merge_request: 17842
author:
type: fixed
......@@ -51,6 +51,7 @@ export default {
'projectSearchResults',
'searchCount',
'messages',
'pageInfo',
]),
projects: {
get() {
......@@ -76,6 +77,7 @@ export default {
},
methods: {
...mapActions([
'fetchNextPage',
'fetchSearchResults',
'addProjectsToDashboard',
'fetchProjects',
......@@ -126,8 +128,10 @@ export default {
:show-loading-indicator="isSearchingProjects"
:show-minimum-search-query-message="messages.minimumQuery"
:show-search-error-message="messages.searchError"
:total-results="pageInfo.totalResults"
@searched="searched"
@projectClicked="projectClicked"
@bottomReached="fetchNextPage"
/>
</gl-modal>
......
......@@ -38,7 +38,7 @@ const AdminEmailSelect = () => {
const all = {
id: 'all',
};
const data = [all].concat(groups, projects);
const data = [all].concat(groups, projects.data);
return query.callback({
results: data,
});
......
......@@ -163,8 +163,21 @@ export const fetchSearchResults = ({ state, dispatch }) => {
}
};
export const fetchNextPage = ({ state, dispatch }) => {
if (state.pageInfo.totalPages <= state.pageInfo.currentPage) {
return;
}
Api.projects(state.searchQuery, { page: state.pageInfo.nextPage })
.then(results => dispatch('receiveNextPageSuccess', results))
.catch(() => dispatch('receiveSearchResultsError'));
};
export const requestSearchResults = ({ commit }) => commit(types.REQUEST_SEARCH_RESULTS);
export const receiveNextPageSuccess = ({ commit }, results) => {
commit(types.RECEIVE_NEXT_PAGE_SUCCESS, results);
};
export const receiveSearchResultsSuccess = ({ commit }, results) => {
commit(types.RECEIVE_SEARCH_RESULTS_SUCCESS, results);
};
......
......@@ -17,6 +17,7 @@ export const CLEAR_SEARCH_RESULTS = 'CLEAR_SEARCH_RESULTS';
export const REQUEST_SEARCH_RESULTS = 'REQUEST_SEARCH_RESULTS';
export const RECEIVE_SEARCH_RESULTS_SUCCESS = 'RECEIVE_SEARCH_RESULTS_SUCCESS';
export const RECEIVE_NEXT_PAGE_SUCCESS = 'RECEIVE_NEXT_PAGE_SUCCESS';
export const RECEIVE_SEARCH_RESULTS_ERROR = 'RECEIVE_SEARCH_RESULTS_ERROR';
export const MINIMUM_QUERY_MESSAGE = 'MINIMUM_QUERY_MESSAGE';
......@@ -3,6 +3,15 @@ import AccessorUtilities from '~/lib/utils/accessor';
import createFlash from '~/flash';
import { __ } from '~/locale';
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export const updatePageInfo = (state, headers) => {
const pageInfo = parseIntPagination(normalizeHeaders(headers));
Vue.set(state.pageInfo, 'currentPage', pageInfo.page);
Vue.set(state.pageInfo, 'nextPage', pageInfo.nextPage);
Vue.set(state.pageInfo, 'totalResults', pageInfo.total);
Vue.set(state.pageInfo, 'totalPages', pageInfo.totalPages);
};
export default {
[types.SET_PROJECT_ENDPOINT_LIST](state, url) {
......@@ -75,12 +84,18 @@ export default {
state.searchCount += 1;
},
[types.RECEIVE_NEXT_PAGE_SUCCESS](state, { data, headers }) {
state.projectSearchResults = state.projectSearchResults.concat(data);
updatePageInfo(state, headers);
},
[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, results) {
state.projectSearchResults = results;
state.projectSearchResults = results.data;
Vue.set(state.messages, 'noResults', state.projectSearchResults.length === 0);
Vue.set(state.messages, 'searchError', false);
Vue.set(state.messages, 'minimumQuery', false);
updatePageInfo(state, results.headers);
state.searchCount = Math.max(0, state.searchCount - 1);
},
[types.RECEIVE_SEARCH_RESULTS_ERROR](state, message) {
......@@ -93,6 +108,7 @@ export default {
},
[types.MINIMUM_QUERY_MESSAGE](state) {
state.projectSearchResults = [];
state.pageInfo.totalResults = 0;
Vue.set(state.messages, 'noResults', false);
Vue.set(state.messages, 'minimumQuery', true);
Vue.set(state.messages, 'searchError', false);
......
......@@ -6,6 +6,12 @@ export default () => ({
add: null,
},
searchQuery: '',
pageInfo: {
totalPages: 0,
totalResults: 0,
nextPage: 0,
currentPage: 0,
},
projects: [],
projectSearchResults: [],
selectedProjects: [],
......
......@@ -16,6 +16,7 @@ exports[`dashboard should match the snapshot 1`] = `
<projectselector-stub
projectsearchresults=""
selectedprojects=""
totalresults="0"
/>
</glmodal-stub>
......
......@@ -28,6 +28,7 @@ describe('dashboard', () => {
fetchSearchResults: jest.fn(),
removeProject: jest.fn(),
toggleSelectedProject: jest.fn(),
fetchNextPage: jest.fn(),
};
propsData = {
addPath: 'mock-addPath',
......
......@@ -9,6 +9,15 @@ export const mockText = {
RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get projects',
};
export const mockHeaders = {
pageInfo: {
currentPage: 1,
nextPage: 2,
totalResults: 33,
totalPages: 2,
},
};
export function mockPipelineData(
status = 'success',
id = 1,
......
......@@ -5,7 +5,7 @@ import * as types from 'ee/vue_shared/dashboards/store/mutation_types';
import defaultActions, * as actions from 'ee/vue_shared/dashboards/store/actions';
import testAction from 'spec/helpers/vuex_action_helper';
import clearState from '../helpers';
import { mockText, mockProjectData } from '../mock_data';
import { mockHeaders, mockText, mockProjectData } from '../mock_data';
describe('actions', () => {
const mockAddEndpoint = 'mock-add_endpoint';
......@@ -396,7 +396,7 @@ describe('actions', () => {
});
it(`dispatches the correct actions when the query is valid`, done => {
mockAxios.onAny().replyOnce(200, mockProjects);
mockAxios.onAny().reply(200, mockProjects, mockHeaders);
store.state.searchQuery = 'mock-query';
testAction(
......@@ -410,12 +410,39 @@ describe('actions', () => {
},
{
type: 'receiveSearchResultsSuccess',
payload: mockProjects,
payload: { data: mockProjects, headers: mockHeaders },
},
],
done,
);
});
});
describe('fetchNextPage', () => {
it(`fetches the next page`, done => {
mockAxios.onAny().reply(200, mockProjects, mockHeaders);
store.state.pageInfo = mockHeaders.pageInfo;
testAction(
actions.fetchNextPage,
null,
store.state,
[],
[
{
type: 'receiveNextPageSuccess',
payload: { data: mockProjects, headers: mockHeaders },
},
],
done,
);
});
it(`stops fetching if current page is the last page`, done => {
mockAxios.onAny().reply(200, mockProjects, mockHeaders);
store.state.pageInfo.totalPages = 3;
store.state.pageInfo.currentPage = 3;
testAction(actions.fetchNextPage, mockHeaders, store.state, [], [], done);
});
});
describe('requestSearchResults', () => {
......@@ -435,6 +462,24 @@ describe('actions', () => {
});
});
describe('receiveNextPageSuccess', () => {
it(`commits the RECEIVE_NEXT_PAGE_SUCCESS mutation`, done => {
testAction(
actions.receiveNextPageSuccess,
mockHeaders,
store.state,
[
{
type: types.RECEIVE_NEXT_PAGE_SUCCESS,
payload: mockHeaders,
},
],
[],
done,
);
});
});
describe('receiveSearchResultsSuccess', () => {
it('commits the RECEIVE_SEARCH_RESULTS_SUCCESS mutation', done => {
testAction(
......
......@@ -194,8 +194,25 @@ describe('mutations', () => {
});
});
describe('RECEIEVE_NEXT_PAGE_SUCESS', () => {
it('sets the nextPage and currentPage of results', () => {
localState.projectSearchResults = [{ id: 1 }];
const headers = {
'x-next-page': '3',
'x-page': '2',
};
const results = { data: projects[1], headers };
mutations[types.RECEIVE_NEXT_PAGE_SUCCESS](localState, results);
expect(localState.projectSearchResults.length).toEqual(2);
expect(localState.pageInfo.currentPage).toEqual(2);
expect(localState.pageInfo.nextPage).toEqual(3);
});
});
describe('RECEIVE_SEARCH_RESULTS_SUCCESS', () => {
it('resets all messages and sets state.projectSearchResults to the results from the API', () => {
it('resets all messages, sets page info, and sets state.projectSearchResults to the results from the API', () => {
localState.projectSearchResults = [];
localState.messages = {
noResults: true,
......@@ -203,20 +220,30 @@ describe('mutations', () => {
minimumQuery: true,
};
const searchResults = [{ id: 1 }];
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, searchResults);
const results = {
data: [{ id: 1 }],
headers: {
'x-next-page': '2',
'x-page': '1',
'X-Total': '37',
'X-Total-Pages': '2',
},
};
expect(localState.projectSearchResults).toEqual(searchResults);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, results);
expect(localState.projectSearchResults).toEqual(results.data);
expect(localState.messages.noResults).toBe(false);
expect(localState.messages.searchError).toBe(false);
expect(localState.messages.minimumQuery).toBe(false);
expect(localState.pageInfo).toEqual({
currentPage: 1,
nextPage: 2,
totalPages: 2,
totalResults: 37,
});
});
it('resets all messages and sets state.projectSearchResults to an empty array if no results', () => {
it('resets all messages and pageInfo and sets state.projectSearchResults to an empty array if no results', () => {
localState.projectSearchResults = [];
localState.messages = {
noResults: false,
......@@ -224,31 +251,35 @@ describe('mutations', () => {
minimumQuery: true,
};
const searchResults = [];
const results = { data: [], headers: { 'x-total': 0 } };
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, searchResults);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, results);
expect(localState.projectSearchResults).toEqual(searchResults);
expect(localState.projectSearchResults).toEqual(results.data);
expect(localState.messages.noResults).toBe(true);
expect(localState.messages.searchError).toBe(false);
expect(localState.messages.minimumQuery).toBe(false);
expect(localState.pageInfo.totalResults).toEqual(0);
});
it('decrements the search count by one', () => {
localState.searchCount = 1;
const results = { data: [], headers: {} };
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, []);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, results);
expect(localState.searchCount).toBe(0);
});
it('does not decrement the search count to be negative', () => {
localState.searchCount = 0;
const results = { data: [], headers: {} };
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, []);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, results);
expect(localState.searchCount).toBe(0);
});
......
......@@ -247,7 +247,7 @@ describe('Frequent Items App Component', () => {
.then(() => {
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
mockSearchedProjects.length,
mockSearchedProjects.data.length,
);
})
.then(done)
......
......@@ -68,7 +68,7 @@ export const mockFrequentGroups = [
},
];
export const mockSearchedGroups = [mockRawGroup];
export const mockSearchedGroups = { data: [mockRawGroup] };
export const mockProcessedSearchedGroups = [mockGroup];
export const mockProject = {
......@@ -135,7 +135,7 @@ export const mockFrequentProjects = [
},
];
export const mockSearchedProjects = [mockRawProject];
export const mockSearchedProjects = { data: [mockRawProject] };
export const mockProcessedSearchedProjects = [mockProject];
export const unsortedFrequentItems = [
......
......@@ -169,7 +169,7 @@ describe('Frequent Items Dropdown Store Actions', () => {
});
it('should dispatch `receiveSearchedItemsSuccess`', done => {
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects);
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {});
testAction(
actions.fetchSearchedItems,
......@@ -178,7 +178,10 @@ describe('Frequent Items Dropdown Store Actions', () => {
[],
[
{ type: 'requestSearchedItems' },
{ type: 'receiveSearchedItemsSuccess', payload: mockSearchedProjects },
{
type: 'receiveSearchedItemsSuccess',
payload: { data: mockSearchedProjects, headers: {} },
},
],
done,
);
......
......@@ -3,7 +3,7 @@ import _ from 'underscore';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
import { GlSearchBoxByType } from '@gitlab/ui';
import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import { trimText } from 'spec/helpers/text_helper';
......@@ -91,6 +91,13 @@ describe('ProjectSelector component', () => {
expect(searchInput.attributes('placeholder')).toBe('Search your projects');
});
it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => {
spyOn(vm, '$emit');
wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached');
expect(vm.$emit).toHaveBeenCalledWith('bottomReached');
});
it(`triggers a "projectClicked" event when a project is clicked`, () => {
spyOn(vm, '$emit');
wrapper.find(ProjectListItem).vm.$emit('click', _.first(searchResults));
......
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