From 72d3203e113f99ac3aca1be5d2998343d61dbb53 Mon Sep 17 00:00:00 2001 From: philippe-perrin-sonarsource Date: Tue, 18 Jun 2019 12:05:14 +0200 Subject: [PATCH] SONAR-12147 Add pagination to QG & QP projects list --- .../src/main/js/api/quality-gates.ts | 4 +- .../quality-gates/components/Projects.tsx | 134 ++++++++++++---- .../components/__tests__/Projects-test.tsx | 138 ++++++++++++++++ .../__snapshots__/Projects-test.tsx.snap | 30 ++++ .../details/ChangeProjectsForm.tsx | 149 ++++++++++++++---- .../__tests__/ChangeProjectsForm-test.tsx | 129 +++++++++++++++ .../ChangeProjectsForm-test.tsx.snap | 57 +++++++ .../js/components/SelectList/SelectList.tsx | 16 +- .../SelectList/SelectListListContainer.tsx | 9 +- .../SelectList/__tests__/SelectList-test.tsx | 41 +++-- .../SelectListListContainer-test.tsx | 36 ++--- .../__snapshots__/SelectList-test.tsx.snap | 138 ++++++++++++++++ .../SelectListListContainer-test.tsx.snap | 72 +++------ .../js/components/controls/ListFooter.tsx | 30 +++- .../controls/__tests__/ListFooter-test.tsx | 33 +++- .../__snapshots__/ListFooter-test.tsx.snap | 46 ++++++ 16 files changed, 885 insertions(+), 177 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/Projects-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/Projects-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ChangeProjectsForm-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ChangeProjectsForm-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap diff --git a/server/sonar-web/src/main/js/api/quality-gates.ts b/server/sonar-web/src/main/js/api/quality-gates.ts index 29809c00691..5cab317b5ba 100644 --- a/server/sonar-web/src/main/js/api/quality-gates.ts +++ b/server/sonar-web/src/main/js/api/quality-gates.ts @@ -106,14 +106,14 @@ export function getGateForProject(data: { ); } -export function searchGates(data: { +export function searchProjects(data: { gateId: number; organization?: string; page?: number; pageSize?: number; query?: string; selected?: string; -}): Promise<{ more: boolean; results: Array<{ id: string; name: string; selected: boolean }> }> { +}): Promise<{ paging: T.Paging; results: Array<{ id: string; name: string; selected: boolean }> }> { return getJSON('/api/qualitygates/search', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx index 84105505fba..1db75ac3447 100644 --- a/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx +++ b/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx @@ -17,15 +17,15 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import * as React from 'react'; import { find, without } from 'lodash'; -import SelectList, { Filter } from '../../../components/SelectList/SelectList'; -import { translate } from '../../../helpers/l10n'; +import * as React from 'react'; import { - searchGates, associateGateWithProject, - dissociateGateWithProject + dissociateGateWithProject, + searchProjects } from '../../../api/quality-gates'; +import SelectList, { Filter } from '../../../components/SelectList/SelectList'; +import { translate } from '../../../helpers/l10n'; interface Props { canEdit?: boolean; @@ -33,73 +33,131 @@ interface Props { qualityGate: T.QualityGate; } +export interface SearchParams { + gateId: number; + organization?: string; + page: number; + pageSize: number; + query?: string; + selected: string; +} + interface State { + lastSearchParams: SearchParams; + listHasBeenTouched: boolean; projects: Array<{ id: string; name: string; selected: boolean }>; + projectsTotalCount?: number; selectedProjects: string[]; } +const PAGE_SIZE = 100; + export default class Projects extends React.PureComponent { mounted = false; - state: State = { projects: [], selectedProjects: [] }; + + constructor(props: Props) { + super(props); + + this.state = { + lastSearchParams: { + gateId: props.qualityGate.id, + organization: props.organization, + page: 1, + pageSize: PAGE_SIZE, + query: '', + selected: Filter.Selected + }, + listHasBeenTouched: false, + projects: [], + selectedProjects: [] + }; + } componentDidMount() { this.mounted = true; - this.handleSearch('', Filter.Selected); + this.fetchProjects(this.state.lastSearchParams); } componentWillUnmount() { this.mounted = false; } - handleSearch = (query: string, selected: string) => { - return searchGates({ - gateId: this.props.qualityGate.id, - organization: this.props.organization, - pageSize: 100, - query: query !== '' ? query : undefined, - selected + fetchProjects = (searchParams: SearchParams, more?: boolean) => + searchProjects({ + ...searchParams, + query: searchParams.query !== '' ? searchParams.query : undefined }).then(data => { if (this.mounted) { - this.setState({ - projects: data.results, - selectedProjects: data.results + this.setState(prevState => { + const projects = more ? [...prevState.projects, ...data.results] : data.results; + const newSelectedProjects = data.results .filter(project => project.selected) - .map(project => project.id) + .map(project => project.id); + const selectedProjects = more + ? [...prevState.selectedProjects, ...newSelectedProjects] + : newSelectedProjects; + + return { + lastSearchParams: searchParams, + listHasBeenTouched: false, + projects, + projectsTotalCount: data.paging.total, + selectedProjects + }; }); } }); - }; - handleSelect = (id: string) => { - return associateGateWithProject({ + handleLoadMore = () => + this.fetchProjects( + { + ...this.state.lastSearchParams, + page: this.state.lastSearchParams.page + 1 + }, + true + ); + + handleReload = () => + this.fetchProjects({ + ...this.state.lastSearchParams, + page: 1 + }); + + handleSearch = (query: string, selected: string) => + this.fetchProjects({ + ...this.state.lastSearchParams, + page: 1, + query, + selected + }); + + handleSelect = (id: string) => + associateGateWithProject({ gateId: this.props.qualityGate.id, organization: this.props.organization, projectId: id }).then(() => { if (this.mounted) { this.setState(state => ({ + listHasBeenTouched: true, selectedProjects: [...state.selectedProjects, id] })); } }); - }; - handleUnselect = (id: string) => { - return dissociateGateWithProject({ + handleUnselect = (id: string) => + dissociateGateWithProject({ gateId: this.props.qualityGate.id, organization: this.props.organization, projectId: id - }).then( - () => { - if (this.mounted) { - this.setState(state => ({ - selectedProjects: without(state.selectedProjects, id) - })); - } - }, - () => {} - ); - }; + }).then(() => { + if (this.mounted) { + this.setState(state => ({ + listHasBeenTouched: true, + selectedProjects: without(state.selectedProjects, id) + })); + } + }); renderElement = (id: string): React.ReactNode => { const project = find(this.state.projects, { id }); @@ -110,9 +168,15 @@ export default class Projects extends React.PureComponent { return ( project.id)} + elementsTotalCount={this.state.projectsTotalCount} labelAll={translate('quality_gates.projects.all')} labelSelected={translate('quality_gates.projects.with')} labelUnselected={translate('quality_gates.projects.without')} + needReload={ + this.state.listHasBeenTouched && this.state.lastSearchParams.selected !== Filter.All + } + onLoadMore={this.handleLoadMore} + onReload={this.handleReload} onSearch={this.handleSearch} onSelect={this.handleSelect} onUnselect={this.handleUnselect} diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/Projects-test.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/Projects-test.tsx new file mode 100644 index 00000000000..0efe2ae9496 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/Projects-test.tsx @@ -0,0 +1,138 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import Projects, { SearchParams } from '../Projects'; +import SelectList, { Filter } from '../../../../components/SelectList/SelectList'; +import { mockQualityGate } from '../../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { + searchProjects, + associateGateWithProject, + dissociateGateWithProject +} from '../../../../api/quality-gates'; + +jest.mock('../../../../api/quality-gates', () => ({ + searchProjects: jest.fn().mockResolvedValue({ + paging: { pageIndex: 1, pageSize: 3, total: 55 }, + results: [ + { id: 'test1', name: 'test1', selected: false }, + { id: 'test2', name: 'test2', selected: false }, + { id: 'test3', name: 'test3', selected: true } + ] + }), + associateGateWithProject: jest.fn().mockResolvedValue({}), + dissociateGateWithProject: jest.fn().mockResolvedValue({}) +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should render correctly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + expect(wrapper).toMatchSnapshot(); + expect(searchProjects).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1 + }) + ); + + wrapper.setState({ listHasBeenTouched: true }); + expect(wrapper.find(SelectList).props().needReload).toBe(true); + + wrapper.setState({ lastSearchParams: { selected: Filter.All } as SearchParams }); + expect(wrapper.find(SelectList).props().needReload).toBe(false); +}); + +it('should handle reload properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleReload(); + expect(searchProjects).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1 + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(false); +}); + +it('should handle search reload properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleSearch('foo', Filter.Selected); + expect(searchProjects).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + query: 'foo', + selected: Filter.Selected + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(false); +}); + +it('should handle load more properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleLoadMore(); + expect(searchProjects).toHaveBeenCalledWith( + expect.objectContaining({ + page: 2 + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(false); +}); + +it('should handle selection properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleSelect('toto'); + await waitAndUpdate(wrapper); + expect(associateGateWithProject).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'toto' + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(true); +}); + +it('should handle deselection properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleUnselect('tata'); + await waitAndUpdate(wrapper); + expect(dissociateGateWithProject).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'tata' + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(true); +}); + +function shallowRender(props: Partial = {}) { + return shallow(); +} diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/Projects-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/Projects-test.tsx.snap new file mode 100644 index 00000000000..dadf536e817 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/Projects-test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + +`; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx index b1a718f1ead..ed11dd0454e 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx @@ -17,18 +17,18 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import * as React from 'react'; import { find, without } from 'lodash'; -import Modal from '../../../components/controls/Modal'; -import SelectList, { Filter } from '../../../components/SelectList/SelectList'; -import { translate } from '../../../helpers/l10n'; -import { Profile } from '../types'; +import * as React from 'react'; import { - getProfileProjects, associateProject, dissociateProject, + getProfileProjects, ProfileProject } from '../../../api/quality-profiles'; +import Modal from '../../../components/controls/Modal'; +import SelectList, { Filter } from '../../../components/SelectList/SelectList'; +import { translate } from '../../../helpers/l10n'; +import { Profile } from '../types'; interface Props { onClose: () => void; @@ -36,52 +36,125 @@ interface Props { profile: Profile; } +export interface SearchParams { + key: string; + organization: string | null; + page: number; + pageSize: number; + query?: string; + selected: string; +} + interface State { + lastSearchParams: SearchParams; + listHasBeenTouched: boolean; projects: ProfileProject[]; + projectsTotalCount?: number; selectedProjects: string[]; } -export default class ChangeProjectsForm extends React.PureComponent { - container?: HTMLElement | null; - state: State = { projects: [], selectedProjects: [] }; +const PAGE_SIZE = 100; + +export default class ChangeProjectsForm extends React.PureComponent { + mounted = false; + + constructor(props: Props) { + super(props); + + this.state = { + lastSearchParams: { + key: props.profile.key, + organization: props.organization, + page: 1, + pageSize: PAGE_SIZE, + query: '', + selected: Filter.Selected + }, + listHasBeenTouched: false, + projects: [], + selectedProjects: [] + }; + } componentDidMount() { - this.handleSearch('', Filter.Selected); + this.mounted = true; + this.fetchProjects(this.state.lastSearchParams); } - handleSearch = (query: string, selected: Filter) => { - return getProfileProjects({ - key: this.props.profile.key, - organization: this.props.organization, - pageSize: 100, - query: query !== '' ? query : undefined, - selected - }).then( - data => { - this.setState({ - projects: data.results, - selectedProjects: data.results + componentWillUnmount() { + this.mounted = false; + } + + fetchProjects = (searchParams: SearchParams, more?: boolean) => + getProfileProjects({ + ...searchParams, + p: searchParams.page, + ps: searchParams.pageSize, + q: searchParams.query !== '' ? searchParams.query : undefined + }).then(data => { + if (this.mounted) { + this.setState(prevState => { + const projects = more ? [...prevState.projects, ...data.results] : data.results; + const newSeletedProjects = data.results .filter(project => project.selected) - .map(project => project.key) + .map(project => project.key); + const selectedProjects = more + ? [...prevState.selectedProjects, ...newSeletedProjects] + : newSeletedProjects; + + return { + lastSearchParams: searchParams, + listHasBeenTouched: false, + projects, + projectsTotalCount: data.paging.total, + selectedProjects + }; }); + } + }); + + handleLoadMore = () => + this.fetchProjects( + { + ...this.state.lastSearchParams, + page: this.state.lastSearchParams.page + 1 }, - () => {} + true ); - }; - handleSelect = (key: string) => { - return associateProject(this.props.profile.key, key).then(() => { - this.setState((state: State) => ({ - selectedProjects: [...state.selectedProjects, key] - })); + handleReload = () => + this.fetchProjects({ + ...this.state.lastSearchParams, + page: 1 }); - }; - handleUnselect = (key: string) => { - return dissociateProject(this.props.profile.key, key).then(() => { - this.setState((state: State) => ({ selectedProjects: without(state.selectedProjects, key) })); + handleSearch = (query: string, selected: Filter) => + this.fetchProjects({ + ...this.state.lastSearchParams, + page: 1, + query, + selected + }); + + handleSelect = (key: string) => + associateProject(this.props.profile.key, key).then(() => { + if (this.mounted) { + this.setState((state: State) => ({ + listHasBeenTouched: true, + selectedProjects: [...state.selectedProjects, key] + })); + } + }); + + handleUnselect = (key: string) => + dissociateProject(this.props.profile.key, key).then(() => { + if (this.mounted) { + this.setState((state: State) => ({ + listHasBeenTouched: true, + selectedProjects: without(state.selectedProjects, key) + })); + } }); - }; handleCloseClick = (event: React.SyntheticEvent) => { event.preventDefault(); @@ -106,9 +179,15 @@ export default class ChangeProjectsForm extends React.PureComponent { project.key)} + elementsTotalCount={this.state.projectsTotalCount} labelAll={translate('quality_gates.projects.all')} labelSelected={translate('quality_gates.projects.with')} labelUnselected={translate('quality_gates.projects.without')} + needReload={ + this.state.listHasBeenTouched && this.state.lastSearchParams.selected !== Filter.All + } + onLoadMore={this.handleLoadMore} + onReload={this.handleReload} onSearch={this.handleSearch} onSelect={this.handleSelect} onUnselect={this.handleUnselect} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ChangeProjectsForm-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ChangeProjectsForm-test.tsx new file mode 100644 index 00000000000..97f036ce3ab --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ChangeProjectsForm-test.tsx @@ -0,0 +1,129 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import ChangeProjectsForm, { SearchParams } from '../ChangeProjectsForm'; +import SelectList, { Filter } from '../../../../components/SelectList/SelectList'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { + getProfileProjects, + associateProject, + dissociateProject +} from '../../../../api/quality-profiles'; + +jest.mock('../../../../api/quality-profiles', () => ({ + getProfileProjects: jest.fn().mockResolvedValue({ + paging: { pageIndex: 1, pageSize: 3, total: 55 }, + results: [ + { id: 'test1', key: 'test1', name: 'test1', selected: false }, + { id: 'test2', key: 'test2', name: 'test2', selected: false }, + { id: 'test3', key: 'test3', name: 'test3', selected: true } + ] + }), + associateProject: jest.fn().mockResolvedValue({}), + dissociateProject: jest.fn().mockResolvedValue({}) +})); + +const profile: any = { key: 'profFile_key' }; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should render correctly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + expect(wrapper).toMatchSnapshot(); + expect(getProfileProjects).toHaveBeenCalled(); + + wrapper.setState({ listHasBeenTouched: true }); + expect(wrapper.find(SelectList).props().needReload).toBe(true); + + wrapper.setState({ lastSearchParams: { selected: Filter.All } as SearchParams }); + expect(wrapper.find(SelectList).props().needReload).toBe(false); +}); + +it('should handle reload properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleReload(); + expect(getProfileProjects).toHaveBeenCalledWith( + expect.objectContaining({ + p: 1 + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(false); +}); + +it('should handle search reload properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleSearch('foo', Filter.Selected); + expect(getProfileProjects).toHaveBeenCalledWith( + expect.objectContaining({ + p: 1, + q: 'foo', + selected: Filter.Selected + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(false); +}); + +it('should handle load more properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleLoadMore(); + expect(getProfileProjects).toHaveBeenCalledWith( + expect.objectContaining({ + p: 2 + }) + ); + expect(wrapper.state().listHasBeenTouched).toBe(false); +}); + +it('should handle selection properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleSelect('toto'); + await waitAndUpdate(wrapper); + expect(associateProject).toHaveBeenCalledWith(profile.key, 'toto'); + expect(wrapper.state().listHasBeenTouched).toBe(true); +}); + +it('should handle deselection properly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + wrapper.instance().handleUnselect('tata'); + await waitAndUpdate(wrapper); + expect(dissociateProject).toHaveBeenCalledWith(profile.key, 'tata'); + expect(wrapper.state().listHasBeenTouched).toBe(true); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ChangeProjectsForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ChangeProjectsForm-test.tsx.snap new file mode 100644 index 00000000000..0eaee6611c5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ChangeProjectsForm-test.tsx.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + +
+

+ projects +

+
+
+ +
+ +
+`; diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx index b1c7e9aa665..bf5cffcf15a 100644 --- a/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx +++ b/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx @@ -20,8 +20,9 @@ import * as React from 'react'; import SelectListListContainer from './SelectListListContainer'; import { translate } from '../../helpers/l10n'; -import SearchBox from '../controls/SearchBox'; +import ListFooter from '../controls/ListFooter'; import RadioToggle from '../controls/RadioToggle'; +import SearchBox from '../controls/SearchBox'; import './styles.css'; export enum Filter { @@ -33,10 +34,14 @@ export enum Filter { interface Props { allowBulkSelection?: boolean; elements: string[]; + elementsTotalCount?: number; disabledElements?: string[]; labelSelected?: string; labelUnselected?: string; labelAll?: string; + needReload?: boolean; + onLoadMore?: () => Promise; + onReload?: () => Promise; onSearch: (query: string, tab: Filter) => Promise; onSelect: (element: string) => Promise; onUnselect: (element: string) => Promise; @@ -127,6 +132,15 @@ export default class SelectList extends React.PureComponent { renderElement={this.props.renderElement} selectedElements={this.props.selectedElements} /> + {!!this.props.elementsTotalCount && this.props.onLoadMore && ( + + )} ); } diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx index 829bee3f284..fa2ed489c4b 100644 --- a/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx +++ b/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx @@ -103,13 +103,6 @@ export default class SelectListListContainer extends React.PureComponent { - if (filter === Filter.All) { - return true; - } - const isSelected = this.isSelected(element); - return filter === Filter.Selected ? isSelected : !isSelected; - }); return (
@@ -118,7 +111,7 @@ export default class SelectListListContainer extends React.PureComponent 0 && filter === Filter.All && this.renderBulkSelector()} - {filteredElements.map(element => ( + {elements.map(element => ( Promise.resolve())} - onSelect={jest.fn(() => Promise.resolve())} - onUnselect={jest.fn(() => Promise.resolve())} - renderElement={(foo: string) => foo} - selectedElements={['foo']} - /> -); - it('should display selected elements only by default', () => { - const wrapper = shallow(selectList); + const wrapper = shallowRender(); expect(wrapper.state().filter).toBe(Filter.Selected); }); it('should display a loader when searching', async () => { - const wrapper = shallow(selectList); + const wrapper = shallowRender(); expect(wrapper).toMatchSnapshot(); expect(wrapper.state().loading).toBe(false); @@ -52,7 +41,7 @@ it('should display a loader when searching', async () => { }); it('should display a loader when updating filter', async () => { - const wrapper = shallow(selectList); + const wrapper = shallowRender(); expect(wrapper).toMatchSnapshot(); expect(wrapper.state().loading).toBe(false); @@ -66,7 +55,7 @@ it('should display a loader when updating filter', async () => { }); it('should cancel filter selection when search is active', async () => { - const wrapper = shallow(selectList); + const wrapper = shallowRender(); wrapper.setState({ filter: Filter.Selected }); await waitAndUpdate(wrapper); @@ -76,3 +65,25 @@ it('should cancel filter selection when search is active', async () => { await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); }); + +it('should display pagination element properly', () => { + const wrapper = shallowRender({ elementsTotalCount: 100, onLoadMore: jest.fn() }); + expect(wrapper).toMatchSnapshot(); + + wrapper.setProps({ needReload: true, onReload: jest.fn() }); + expect(wrapper).toMatchSnapshot(); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + Promise.resolve())} + onSelect={jest.fn(() => Promise.resolve())} + onUnselect={jest.fn(() => Promise.resolve())} + renderElement={(foo: string) => foo} + selectedElements={['foo']} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx index c5bc86aff22..496b9b49a9a 100644 --- a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx @@ -22,28 +22,18 @@ import { shallow } from 'enzyme'; import SelectListListContainer from '../SelectListListContainer'; import { Filter } from '../SelectList'; -const elementsContainer = ( - Promise.resolve())} - onUnselect={jest.fn(() => Promise.resolve())} - renderElement={(foo: string) => foo} - selectedElements={['foo']} - /> -); - -it('should display elements based on filters', () => { - const wrapper = shallow(elementsContainer); - expect(wrapper.find('SelectListListElement')).toHaveLength(3); - expect(wrapper).toMatchSnapshot(); - - wrapper.setProps({ filter: Filter.Unselected }); - expect(wrapper.find('SelectListListElement')).toHaveLength(2); - expect(wrapper).toMatchSnapshot(); - - wrapper.setProps({ filter: Filter.Selected }); - expect(wrapper.find('SelectListListElement')).toHaveLength(1); +it('should render correctly', () => { + const wrapper = shallow( + Promise.resolve())} + onUnselect={jest.fn(() => Promise.resolve())} + renderElement={(foo: string) => foo} + selectedElements={['foo']} + /> + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap index d7fadb494c1..eb38a0a6304 100644 --- a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap @@ -377,3 +377,141 @@ exports[`should display a loader when updating filter 2`] = ` />
`; + +exports[`should display pagination element properly 1`] = ` +
+
+ + +
+ + +
+`; + +exports[`should display pagination element properly 2`] = ` +
+
+ + +
+ + +
+`; diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap index 9f946d3e41b..04b0553b72a 100644 --- a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap @@ -1,12 +1,33 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should display elements based on filters 1`] = ` +exports[`should render correctly 1`] = `
    +
  • + + + bulk_change + + + +
  • +
`; - -exports[`should display elements based on filters 2`] = ` -
-
    - - -
-
-`; - -exports[`should display elements based on filters 3`] = ` -
-
    - -
-
-`; diff --git a/server/sonar-web/src/main/js/components/controls/ListFooter.tsx b/server/sonar-web/src/main/js/components/controls/ListFooter.tsx index 9d0efcf94f3..922899cd67a 100644 --- a/server/sonar-web/src/main/js/components/controls/ListFooter.tsx +++ b/server/sonar-web/src/main/js/components/controls/ListFooter.tsx @@ -23,11 +23,13 @@ import DeferredSpinner from '../common/DeferredSpinner'; import { translate, translateWithParameters } from '../../helpers/l10n'; import { formatMeasure } from '../../helpers/measures'; -interface Props { +export interface Props { count: number; className?: string; loading?: boolean; loadMore?: () => void; + needReload?: boolean; + reload?: () => void; ready?: boolean; total?: number; } @@ -41,18 +43,42 @@ export default function ListFooter({ ready = true, ...props }: Props) { } }; + const handleReload = (event: React.SyntheticEvent) => { + event.preventDefault(); + event.currentTarget.blur(); + if (props.reload) { + props.reload(); + } + }; + const hasMore = props.total && props.total > props.count; + const loadMoreLink = ( {translate('show_more')} ); + + const reloadLink = ( + + {translate('reload')} + + ); + const className = classNames( 'spacer-top note text-center', { 'new-loading': !ready }, props.className ); + let link; + + if (props.needReload && props.reload) { + link = reloadLink; + } else if (hasMore && props.loadMore) { + link = loadMoreLink; + } + return (
{translateWithParameters( @@ -60,7 +86,7 @@ export default function ListFooter({ ready = true, ...props }: Props) { formatMeasure(props.count, 'INT', null), formatMeasure(props.total, 'INT', null) )} - {props.loadMore != null && hasMore ? loadMoreLink : null} + {link} {props.loading && }
); diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx index b92b4ca9454..901cfb75637 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx @@ -19,37 +19,58 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import ListFooter from '../ListFooter'; +import ListFooter, { Props } from '../ListFooter'; import { click } from '../../../helpers/testUtils'; it('should render "3 of 5 shown"', () => { - const listFooter = shallow(); + const listFooter = shallowRender(); expect(listFooter.text()).toContain('x_of_y_shown.3.5'); + expect(listFooter).toMatchSnapshot(); }); it('should not render "show more"', () => { - const listFooter = shallow(); + const listFooter = shallowRender({ loadMore: undefined }); expect(listFooter.find('a').length).toBe(0); }); it('should not render "show more"', () => { - const listFooter = shallow(); + const listFooter = shallowRender({ count: 5 }); expect(listFooter.find('a').length).toBe(0); }); it('should "show more"', () => { const loadMore = jest.fn(); - const listFooter = shallow(); + const listFooter = shallowRender({ loadMore }); const link = listFooter.find('a'); expect(link.length).toBe(1); click(link); expect(loadMore).toBeCalled(); }); +it('should render "reload" properly', () => { + const listFooter = shallowRender({ needReload: true }); + expect(listFooter).toMatchSnapshot(); + + const reload = jest.fn(); + + listFooter.setProps({ reload }); + expect(listFooter).toMatchSnapshot(); + + const link = listFooter.find('a'); + expect(link.length).toBe(1); + + click(link); + expect(reload).toBeCalled(); +}); + it('should display spinner while loading', () => { expect( - shallow() + shallowRender({ loading: true }) .find('DeferredSpinner') .exists() ).toBe(true); }); + +function shallowRender(props: Partial = {}) { + return shallow(); +} diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap new file mode 100644 index 00000000000..c2aa7f064fe --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render "3 of 5 shown" 1`] = ` + +`; + +exports[`should render "reload" properly 1`] = ` + +`; + +exports[`should render "reload" properly 2`] = ` + +`; -- 2.39.5