diff options
author | philippe-perrin-sonarsource <philippe.perrin@sonarsource.com> | 2019-06-18 12:05:14 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-06-28 08:45:50 +0200 |
commit | 72d3203e113f99ac3aca1be5d2998343d61dbb53 (patch) | |
tree | d9999d167c0b6ee8deeec7197d0b4960b8926fda /server/sonar-web | |
parent | 606e05764f2c52a8afd68e9f9a9fc976e7b2de93 (diff) | |
download | sonarqube-72d3203e113f99ac3aca1be5d2998343d61dbb53.tar.gz sonarqube-72d3203e113f99ac3aca1be5d2998343d61dbb53.zip |
SONAR-12147 Add pagination to QG & QP projects list
Diffstat (limited to 'server/sonar-web')
16 files changed, 885 insertions, 177 deletions
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<Props, State> { 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<Props, State> { return ( <SelectList elements={this.state.projects.map(project => 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<Projects['props']> = {}) { + return shallow<Projects>(<Projects qualityGate={mockQualityGate()} {...props} />); +} 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`] = ` +<SelectList + elements={ + Array [ + "test1", + "test2", + "test3", + ] + } + elementsTotalCount={55} + labelAll="quality_gates.projects.all" + labelSelected="quality_gates.projects.with" + labelUnselected="quality_gates.projects.without" + needReload={false} + onLoadMore={[Function]} + onReload={[Function]} + onSearch={[Function]} + onSelect={[Function]} + onUnselect={[Function]} + readOnly={true} + renderElement={[Function]} + selectedElements={ + Array [ + "test3", + ] + } +/> +`; 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<Props> { - container?: HTMLElement | null; - state: State = { projects: [], selectedProjects: [] }; +const PAGE_SIZE = 100; + +export default class ChangeProjectsForm extends React.PureComponent<Props, State> { + 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<HTMLElement>) => { event.preventDefault(); @@ -106,9 +179,15 @@ export default class ChangeProjectsForm extends React.PureComponent<Props> { <SelectList allowBulkSelection={true} elements={this.state.projects.map(project => 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<ChangeProjectsForm['props']> = {}) { + return shallow<ChangeProjectsForm>( + <ChangeProjectsForm onClose={jest.fn()} organization={'TEST'} profile={profile} {...props} /> + ); +} 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`] = ` +<Modal + contentLabel="projects" + onRequestClose={[MockFunction]} +> + <div + className="modal-head" + > + <h2> + projects + </h2> + </div> + <div + className="modal-body" + id="profile-projects" + > + <SelectList + allowBulkSelection={true} + elements={ + Array [ + "test1", + "test2", + "test3", + ] + } + elementsTotalCount={55} + labelAll="quality_gates.projects.all" + labelSelected="quality_gates.projects.with" + labelUnselected="quality_gates.projects.without" + needReload={false} + onLoadMore={[Function]} + onReload={[Function]} + onSearch={[Function]} + onSelect={[Function]} + onUnselect={[Function]} + renderElement={[Function]} + selectedElements={ + Array [ + "test3", + ] + } + /> + </div> + <div + className="modal-foot" + > + <a + href="#" + onClick={[Function]} + > + close + </a> + </div> +</Modal> +`; 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<void>; + onReload?: () => Promise<void>; onSearch: (query: string, tab: Filter) => Promise<void>; onSelect: (element: string) => Promise<void>; onUnselect: (element: string) => Promise<void>; @@ -127,6 +132,15 @@ export default class SelectList extends React.PureComponent<Props, State> { renderElement={this.props.renderElement} selectedElements={this.props.selectedElements} /> + {!!this.props.elementsTotalCount && this.props.onLoadMore && ( + <ListFooter + count={this.props.elements.length} + loadMore={this.props.onLoadMore} + needReload={this.props.needReload} + reload={this.props.onReload} + total={this.props.elementsTotalCount} + /> + )} </div> ); } 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<Props, render() { const { allowBulkSelection, elements, filter } = this.props; - const filteredElements = elements.filter(element => { - if (filter === Filter.All) { - return true; - } - const isSelected = this.isSelected(element); - return filter === Filter.Selected ? isSelected : !isSelected; - }); return ( <div className={classNames('select-list-list-container spacer-top')}> @@ -118,7 +111,7 @@ export default class SelectListListContainer extends React.PureComponent<Props, elements.length > 0 && filter === Filter.All && this.renderBulkSelector()} - {filteredElements.map(element => ( + {elements.map(element => ( <SelectListListElement disabled={this.isDisabled(element)} element={element} diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx index 5e8ebe52531..f7843897089 100644 --- a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx @@ -22,24 +22,13 @@ import { shallow } from 'enzyme'; import SelectList, { Filter } from '../SelectList'; import { waitAndUpdate } from '../../../helpers/testUtils'; -const selectList = ( - <SelectList - elements={['foo', 'bar', 'baz']} - onSearch={jest.fn(() => 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>(selectList); + const wrapper = shallowRender(); expect(wrapper.state().filter).toBe(Filter.Selected); }); it('should display a loader when searching', async () => { - const wrapper = shallow<SelectList>(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>(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>(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<SelectList['props']> = {}) { + return shallow<SelectList>( + <SelectList + elements={['foo', 'bar', 'baz']} + onSearch={jest.fn(() => 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 = ( - <SelectListListContainer - disabledElements={[]} - elements={['foo', 'bar', 'baz']} - filter={Filter.All} - onSelect={jest.fn(() => 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( + <SelectListListContainer + allowBulkSelection={true} + disabledElements={[]} + elements={['foo', 'bar', 'baz']} + filter={Filter.All} + onSelect={jest.fn(() => 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`] = ` /> </div> `; + +exports[`should display pagination element properly 1`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": false, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": false, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": false, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="selected" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> + <ListFooter + count={3} + loadMore={[MockFunction]} + total={100} + /> +</div> +`; + +exports[`should display pagination element properly 2`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": false, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": false, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": false, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="selected" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> + <ListFooter + count={3} + loadMore={[MockFunction]} + needReload={true} + reload={[MockFunction]} + total={100} + /> +</div> +`; 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`] = ` <div className="select-list-list-container spacer-top" > <ul className="menu" > + <li> + <Checkbox + checked={true} + onCheck={[Function]} + thirdState={true} + > + <span + className="big-spacer-left" + > + bulk_change + <DeferredSpinner + className="spacer-left" + loading={false} + timeout={10} + /> + </span> + </Checkbox> + </li> + <li + className="divider" + /> <SelectListListElement disabled={false} element="foo" @@ -37,52 +58,3 @@ exports[`should display elements based on filters 1`] = ` </ul> </div> `; - -exports[`should display elements based on filters 2`] = ` -<div - className="select-list-list-container spacer-top" -> - <ul - className="menu" - > - <SelectListListElement - disabled={false} - element="bar" - key="bar" - onSelect={[MockFunction]} - onUnselect={[MockFunction]} - renderElement={[Function]} - selected={false} - /> - <SelectListListElement - disabled={false} - element="baz" - key="baz" - onSelect={[MockFunction]} - onUnselect={[MockFunction]} - renderElement={[Function]} - selected={false} - /> - </ul> -</div> -`; - -exports[`should display elements based on filters 3`] = ` -<div - className="select-list-list-container spacer-top" -> - <ul - className="menu" - > - <SelectListListElement - disabled={false} - element="foo" - key="foo" - onSelect={[MockFunction]} - onUnselect={[MockFunction]} - renderElement={[Function]} - selected={true} - /> - </ul> -</div> -`; 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<HTMLElement>) => { + event.preventDefault(); + event.currentTarget.blur(); + if (props.reload) { + props.reload(); + } + }; + const hasMore = props.total && props.total > props.count; + const loadMoreLink = ( <a className="spacer-left" href="#" onClick={handleLoadMore}> {translate('show_more')} </a> ); + + const reloadLink = ( + <a className="spacer-left" href="#" onClick={handleReload}> + {translate('reload')} + </a> + ); + 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 ( <footer className={className}> {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 && <DeferredSpinner className="text-bottom spacer-left position-absolute" />} </footer> ); 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(<ListFooter count={3} total={5} />); + 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(<ListFooter count={3} total={5} />); + const listFooter = shallowRender({ loadMore: undefined }); expect(listFooter.find('a').length).toBe(0); }); it('should not render "show more"', () => { - const listFooter = shallow(<ListFooter count={5} loadMore={jest.fn()} total={5} />); + const listFooter = shallowRender({ count: 5 }); expect(listFooter.find('a').length).toBe(0); }); it('should "show more"', () => { const loadMore = jest.fn(); - const listFooter = shallow(<ListFooter count={3} loadMore={loadMore} total={5} />); + 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(<ListFooter count={3} loadMore={jest.fn()} loading={true} total={10} />) + shallowRender({ loading: true }) .find('DeferredSpinner') .exists() ).toBe(true); }); + +function shallowRender(props: Partial<Props> = {}) { + return shallow(<ListFooter count={3} loadMore={jest.fn()} total={5} {...props} />); +} 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`] = ` +<footer + className="spacer-top note text-center" +> + x_of_y_shown.3.5 + <a + className="spacer-left" + href="#" + onClick={[Function]} + > + show_more + </a> +</footer> +`; + +exports[`should render "reload" properly 1`] = ` +<footer + className="spacer-top note text-center" +> + x_of_y_shown.3.5 + <a + className="spacer-left" + href="#" + onClick={[Function]} + > + show_more + </a> +</footer> +`; + +exports[`should render "reload" properly 2`] = ` +<footer + className="spacer-top note text-center" +> + x_of_y_shown.3.5 + <a + className="spacer-left" + href="#" + onClick={[Function]} + > + reload + </a> +</footer> +`; |