diff options
author | philippe-perrin-sonarsource <philippe.perrin@sonarsource.com> | 2019-07-02 13:53:28 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-07-18 20:21:12 +0200 |
commit | e5129e41c9e33ae14fa8c753a2f1f58c5bfc1f84 (patch) | |
tree | 6b101e761e13953ee5f53aea0c346d546a3a27c7 /server/sonar-web | |
parent | 92db3d264f921127bb0635783c40c9571c19947d (diff) | |
download | sonarqube-e5129e41c9e33ae14fa8c753a2f1f58c5bfc1f84.tar.gz sonarqube-e5129e41c9e33ae14fa8c753a2f1f58c5bfc1f84.zip |
SONAR-12244 Handle pagination in portfolio projects list properly
Diffstat (limited to 'server/sonar-web')
15 files changed, 548 insertions, 614 deletions
diff --git a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx index 712206e016f..5bfeb5e20bf 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx @@ -22,8 +22,10 @@ import { find, without } from 'lodash'; import { translate } from 'sonar-ui-common/helpers/l10n'; import { ResetButtonLink } from 'sonar-ui-common/components/controls/buttons'; import Modal from 'sonar-ui-common/components/controls/Modal'; -import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; -import SelectList, { Filter } from '../../../components/SelectList/SelectList'; +import SelectList, { + Filter, + SelectListSearchParams +} from '../../../components/SelectList/SelectList'; import { addUserToGroup, getUsersInGroup, removeUserFromGroup } from '../../../api/user_groups'; interface Props { @@ -32,26 +34,14 @@ interface Props { organization: string | undefined; } -export interface SearchParams { - name: string; - organization?: string; - page: number; - pageSize: number; - query?: string; - selected: string; -} - interface State { - lastSearchParams: SearchParams; - listHasBeenTouched: boolean; - loading: boolean; + lastSearchParams?: SelectListSearchParams; + needToReload: boolean; users: T.UserSelected[]; usersTotalCount?: number; selectedUsers: string[]; } -const PAGE_SIZE = 100; - export default class EditMembersModal extends React.PureComponent<Props, State> { mounted = false; @@ -59,16 +49,7 @@ export default class EditMembersModal extends React.PureComponent<Props, State> super(props); this.state = { - lastSearchParams: { - name: props.group.name, - organization: props.organization, - page: 1, - pageSize: PAGE_SIZE, - query: '', - selected: Filter.Selected - }, - listHasBeenTouched: false, - loading: true, + needToReload: false, users: [], selectedUsers: [] }; @@ -76,70 +57,41 @@ export default class EditMembersModal extends React.PureComponent<Props, State> componentDidMount() { this.mounted = true; - this.fetchUsers(this.state.lastSearchParams); } componentWillUnmount() { this.mounted = false; } - fetchUsers = (searchParams: SearchParams, more?: boolean) => + fetchUsers = (searchParams: SelectListSearchParams) => getUsersInGroup({ - ...searchParams, + name: this.props.group.name, + organization: this.props.organization, p: searchParams.page, ps: searchParams.pageSize, - q: searchParams.query !== '' ? searchParams.query : undefined - }).then( - data => { - if (this.mounted) { - this.setState(prevState => { - const users = more ? [...prevState.users, ...data.users] : data.users; - const newSelectedUsers = data.users - .filter(user => user.selected) - .map(user => user.login); - const selectedUsers = more - ? [...prevState.selectedUsers, ...newSelectedUsers] - : newSelectedUsers; - - return { - lastSearchParams: searchParams, - listHasBeenTouched: false, - loading: false, - users, - usersTotalCount: data.total, - selectedUsers - }; - }); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } + q: searchParams.query !== '' ? searchParams.query : undefined, + selected: searchParams.filter + }).then(data => { + if (this.mounted) { + this.setState(prevState => { + const more = searchParams.page != null && searchParams.page > 1; + + const users = more ? [...prevState.users, ...data.users] : data.users; + const newSelectedUsers = data.users.filter(user => user.selected).map(user => user.login); + const selectedUsers = more + ? [...prevState.selectedUsers, ...newSelectedUsers] + : newSelectedUsers; + + return { + needToReload: false, + lastSearchParams: searchParams, + loading: false, + users, + usersTotalCount: data.total, + selectedUsers + }; + }); } - ); - - handleLoadMore = () => - this.fetchUsers( - { - ...this.state.lastSearchParams, - page: this.state.lastSearchParams.page + 1 - }, - true - ); - - handleReload = () => - this.fetchUsers({ - ...this.state.lastSearchParams, - page: 1 - }); - - handleSearch = (query: string, selected: Filter) => - this.fetchUsers({ - ...this.state.lastSearchParams, - page: 1, - query, - selected }); handleSelect = (login: string) => @@ -150,7 +102,7 @@ export default class EditMembersModal extends React.PureComponent<Props, State> }).then(() => { if (this.mounted) { this.setState((state: State) => ({ - listHasBeenTouched: true, + needToReload: true, selectedUsers: [...state.selectedUsers, login] })); } @@ -164,7 +116,7 @@ export default class EditMembersModal extends React.PureComponent<Props, State> }).then(() => { if (this.mounted) { this.setState((state: State) => ({ - listHasBeenTouched: true, + needToReload: true, selectedUsers: without(state.selectedUsers, login) })); } @@ -196,22 +148,21 @@ export default class EditMembersModal extends React.PureComponent<Props, State> </header> <div className="modal-body modal-container"> - <DeferredSpinner loading={this.state.loading}> - <SelectList - elements={this.state.users.map(user => user.login)} - elementsTotalCount={this.state.usersTotalCount} - 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} - renderElement={this.renderElement} - selectedElements={this.state.selectedUsers} - /> - </DeferredSpinner> + <SelectList + elements={this.state.users.map(user => user.login)} + elementsTotalCount={this.state.usersTotalCount} + needToReload={ + this.state.needToReload && + this.state.lastSearchParams && + this.state.lastSearchParams.filter !== Filter.All + } + onSearch={this.fetchUsers} + onSelect={this.handleSelect} + onUnselect={this.handleUnselect} + renderElement={this.renderElement} + selectedElements={this.state.selectedUsers} + withPaging={true} + /> </div> <footer className="modal-foot"> diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx index e0d134373c4..ed9e065877c 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx @@ -20,10 +20,13 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import EditMembersModal, { SearchParams } from '../EditMembersModal'; +import EditMembersModal from '../EditMembersModal'; import SelectList, { Filter } from '../../../../components/SelectList/SelectList'; import { getUsersInGroup, addUserToGroup, removeUserFromGroup } from '../../../../api/user_groups'; +const organization = 'orga'; +const group = { id: 1, name: 'foo', membersCount: 1 }; + jest.mock('../../../../api/user_groups', () => ({ getUsersInGroup: jest.fn().mockResolvedValue({ paging: { pageIndex: 1, pageSize: 10, total: 1 }, @@ -45,98 +48,70 @@ beforeEach(() => { it('should render modal properly', async () => { const wrapper = shallowRender(); + wrapper + .find(SelectList) + .props() + .onSearch({ + query: '', + filter: Filter.Selected, + page: 1, + pageSize: 100 + }); await waitAndUpdate(wrapper); + expect(wrapper.state().needToReload).toBe(false); + expect(wrapper.instance().mounted).toBe(true); expect(wrapper).toMatchSnapshot(); - expect(getUsersInGroup).toHaveBeenCalledWith( - expect.objectContaining({ - p: 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(getUsersInGroup).toHaveBeenCalledWith( - expect.objectContaining({ - p: 1 - }) - ); - expect(wrapper.state().listHasBeenTouched).toBe(false); -}); + expect(wrapper.instance().renderElement('test1')).toMatchSnapshot(); + expect(wrapper.instance().renderElement('test_foo')).toMatchSnapshot(); -it('should handle search reload properly', async () => { - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - - wrapper.instance().handleSearch('foo', Filter.Selected); expect(getUsersInGroup).toHaveBeenCalledWith( expect.objectContaining({ + name: group.name, + organization, p: 1, - q: 'foo', + ps: 100, + q: undefined, 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(getUsersInGroup).toHaveBeenCalledWith( - expect.objectContaining({ - p: 2 - }) - ); - expect(wrapper.state().listHasBeenTouched).toBe(false); + wrapper.instance().componentWillUnmount(); + expect(wrapper.instance().mounted).toBe(false); }); it('should handle selection properly', async () => { const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - wrapper.instance().handleSelect('toto'); await waitAndUpdate(wrapper); + expect(addUserToGroup).toHaveBeenCalledWith( expect.objectContaining({ + name: group.name, + organization, login: 'toto' }) ); - expect(wrapper.state().listHasBeenTouched).toBe(true); + expect(wrapper.state().needToReload).toBe(true); }); it('should handle deselection properly', async () => { const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - wrapper.instance().handleUnselect('tata'); + await waitAndUpdate(wrapper); expect(removeUserFromGroup).toHaveBeenCalledWith( expect.objectContaining({ + name: group.name, + organization, login: 'tata' }) ); - expect(wrapper.state().listHasBeenTouched).toBe(true); + expect(wrapper.state().needToReload).toBe(true); }); function shallowRender(props: Partial<EditMembersModal['props']> = {}) { return shallow<EditMembersModal>( - <EditMembersModal - group={{ id: 1, name: 'foo', membersCount: 1 }} - onClose={jest.fn()} - organization="bar" - {...props} - /> + <EditMembersModal group={group} onClose={jest.fn()} organization={organization} {...props} /> ); } diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap index faf0598e59b..874619ef9ea 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap @@ -15,30 +15,24 @@ exports[`should render modal properly 1`] = ` <div className="modal-body modal-container" > - <DeferredSpinner - loading={false} - timeout={100} - > - <SelectList - elements={ - Array [ - "foo", - ] - } - needReload={false} - onLoadMore={[Function]} - onReload={[Function]} - onSearch={[Function]} - onSelect={[Function]} - onUnselect={[Function]} - renderElement={[Function]} - selectedElements={ - Array [ - "foo", - ] - } - /> - </DeferredSpinner> + <SelectList + elements={ + Array [ + "foo", + ] + } + needToReload={false} + onSearch={[Function]} + onSelect={[Function]} + onUnselect={[Function]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + withPaging={true} + /> </div> <footer className="modal-foot" @@ -51,3 +45,19 @@ exports[`should render modal properly 1`] = ` </footer> </Modal> `; + +exports[`should render modal properly 2`] = ` +<div + className="select-list-list-item" +> + test1 +</div> +`; + +exports[`should render modal properly 3`] = ` +<div + className="select-list-list-item" +> + test_foo +</div> +`; 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 f1bd32dd9e9..a64bf9ece87 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 @@ -20,7 +20,10 @@ import * as React from 'react'; import { find, without } from 'lodash'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import SelectList, { Filter } from '../../../components/SelectList/SelectList'; +import SelectList, { + Filter, + SelectListSearchParams +} from '../../../components/SelectList/SelectList'; import { associateGateWithProject, dissociateGateWithProject, @@ -33,25 +36,14 @@ 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; + needToReload: boolean; + lastSearchParams?: SelectListSearchParams; projects: Array<{ id: string; key: string; name: string; selected: boolean }>; projectsTotalCount?: number; selectedProjects: string[]; } -const PAGE_SIZE = 100; - export default class Projects extends React.PureComponent<Props, State> { mounted = false; @@ -59,15 +51,7 @@ export default class Projects extends React.PureComponent<Props, State> { super(props); this.state = { - lastSearchParams: { - gateId: props.qualityGate.id, - organization: props.organization, - page: 1, - pageSize: PAGE_SIZE, - query: '', - selected: Filter.Selected - }, - listHasBeenTouched: false, + needToReload: false, projects: [], selectedProjects: [] }; @@ -75,20 +59,25 @@ export default class Projects extends React.PureComponent<Props, State> { componentDidMount() { this.mounted = true; - this.fetchProjects(this.state.lastSearchParams); } componentWillUnmount() { this.mounted = false; } - fetchProjects = (searchParams: SearchParams, more?: boolean) => + fetchProjects = (searchParams: SelectListSearchParams) => searchProjects({ - ...searchParams, - query: searchParams.query !== '' ? searchParams.query : undefined + gateId: this.props.qualityGate.id, + organization: this.props.organization, + page: searchParams.page, + pageSize: searchParams.pageSize, + query: searchParams.query !== '' ? searchParams.query : undefined, + selected: searchParams.filter }).then(data => { if (this.mounted) { this.setState(prevState => { + const more = searchParams.page != null && searchParams.page > 1; + const projects = more ? [...prevState.projects, ...data.results] : data.results; const newSelectedProjects = data.results .filter(project => project.selected) @@ -99,7 +88,7 @@ export default class Projects extends React.PureComponent<Props, State> { return { lastSearchParams: searchParams, - listHasBeenTouched: false, + needToReload: false, projects, projectsTotalCount: data.paging.total, selectedProjects @@ -108,29 +97,6 @@ export default class Projects extends React.PureComponent<Props, State> { } }); - 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, @@ -138,9 +104,9 @@ export default class Projects extends React.PureComponent<Props, State> { projectId: id }).then(() => { if (this.mounted) { - this.setState(state => ({ - listHasBeenTouched: true, - selectedProjects: [...state.selectedProjects, id] + this.setState(prevState => ({ + needToReload: true, + selectedProjects: [...prevState.selectedProjects, id] })); } }); @@ -152,9 +118,9 @@ export default class Projects extends React.PureComponent<Props, State> { projectId: id }).then(() => { if (this.mounted) { - this.setState(state => ({ - listHasBeenTouched: true, - selectedProjects: without(state.selectedProjects, id) + this.setState(prevState => ({ + needToReload: true, + selectedProjects: without(prevState.selectedProjects, id) })); } }); @@ -184,17 +150,18 @@ export default class Projects extends React.PureComponent<Props, State> { 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 + needToReload={ + this.state.needToReload && + this.state.lastSearchParams && + this.state.lastSearchParams.filter !== Filter.All } - onLoadMore={this.handleLoadMore} - onReload={this.handleReload} - onSearch={this.handleSearch} + onSearch={this.fetchProjects} onSelect={this.handleSelect} onUnselect={this.handleUnselect} readOnly={!this.props.canEdit} renderElement={this.renderElement} selectedElements={this.state.selectedProjects} + withPaging={true} /> ); } 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 index 555c8859ede..572808e4466 100644 --- 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 @@ -20,7 +20,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import Projects, { SearchParams } from '../Projects'; +import Projects from '../Projects'; import SelectList, { Filter } from '../../../../components/SelectList/SelectList'; import { mockQualityGate } from '../../../../helpers/testMocks'; import { @@ -29,6 +29,9 @@ import { dissociateGateWithProject } from '../../../../api/quality-gates'; +const qualityGate = mockQualityGate(); +const organization = 'TEST'; + jest.mock('../../../../api/quality-gates', () => ({ searchProjects: jest.fn().mockResolvedValue({ paging: { pageIndex: 1, pageSize: 3, total: 55 }, @@ -48,97 +51,66 @@ beforeEach(() => { it('should render correctly', async () => { const wrapper = shallowRender(); + wrapper + .find(SelectList) + .props() + .onSearch({ + query: '', + filter: Filter.Selected, + page: 1, + pageSize: 100 + }); await waitAndUpdate(wrapper); - expect(wrapper.instance().mounted).toBe(true); + expect(wrapper.instance().mounted).toBe(true); expect(wrapper).toMatchSnapshot(); expect(wrapper.instance().renderElement('test1')).toMatchSnapshot(); expect(wrapper.instance().renderElement('test_foo')).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); - - wrapper.instance().componentWillUnmount(); - expect(wrapper.instance().mounted).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({ + gateId: qualityGate.id, + organization, page: 1, - query: 'foo', + pageSize: 100, + query: undefined, selected: Filter.Selected }) ); - expect(wrapper.state().listHasBeenTouched).toBe(false); -}); + expect(wrapper.state().needToReload).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); + wrapper.instance().componentWillUnmount(); + expect(wrapper.instance().mounted).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); + expect(wrapper.state().needToReload).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); + expect(wrapper.state().needToReload).toBe(true); }); function shallowRender(props: Partial<Projects['props']> = {}) { - return shallow<Projects>(<Projects qualityGate={mockQualityGate()} {...props} />); + return shallow<Projects>( + <Projects organization={organization} qualityGate={qualityGate} {...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 index 804961561e4..58db09fcecd 100644 --- 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 @@ -13,9 +13,7 @@ exports[`should render correctly 1`] = ` labelAll="quality_gates.projects.all" labelSelected="quality_gates.projects.with" labelUnselected="quality_gates.projects.without" - needReload={false} - onLoadMore={[Function]} - onReload={[Function]} + needToReload={false} onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} @@ -26,6 +24,7 @@ exports[`should render correctly 1`] = ` "test3", ] } + withPaging={true} /> `; 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 ed132d1bd46..5119c0a0b0d 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 @@ -21,7 +21,10 @@ import * as React from 'react'; import { find, without } from 'lodash'; import { translate } from 'sonar-ui-common/helpers/l10n'; import Modal from 'sonar-ui-common/components/controls/Modal'; -import SelectList, { Filter } from '../../../components/SelectList/SelectList'; +import SelectList, { + Filter, + SelectListSearchParams +} from '../../../components/SelectList/SelectList'; import { Profile } from '../types'; import { associateProject, @@ -36,25 +39,14 @@ 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; + needToReload: boolean; + lastSearchParams?: SelectListSearchParams; projects: ProfileProject[]; projectsTotalCount?: number; selectedProjects: string[]; } -const PAGE_SIZE = 100; - export default class ChangeProjectsForm extends React.PureComponent<Props, State> { mounted = false; @@ -62,15 +54,7 @@ export default class ChangeProjectsForm extends React.PureComponent<Props, State super(props); this.state = { - lastSearchParams: { - key: props.profile.key, - organization: props.organization, - page: 1, - pageSize: PAGE_SIZE, - query: '', - selected: Filter.Selected - }, - listHasBeenTouched: false, + needToReload: false, projects: [], selectedProjects: [] }; @@ -78,22 +62,25 @@ export default class ChangeProjectsForm extends React.PureComponent<Props, State componentDidMount() { this.mounted = true; - this.fetchProjects(this.state.lastSearchParams); } componentWillUnmount() { this.mounted = false; } - fetchProjects = (searchParams: SearchParams, more?: boolean) => + fetchProjects = (searchParams: SelectListSearchParams) => getProfileProjects({ - ...searchParams, + key: this.props.profile.key, + organization: this.props.organization, p: searchParams.page, ps: searchParams.pageSize, - q: searchParams.query !== '' ? searchParams.query : undefined + q: searchParams.query !== '' ? searchParams.query : undefined, + selected: searchParams.filter }).then(data => { if (this.mounted) { this.setState(prevState => { + const more = searchParams.page != null && searchParams.page > 1; + const projects = more ? [...prevState.projects, ...data.results] : data.results; const newSeletedProjects = data.results .filter(project => project.selected) @@ -104,7 +91,7 @@ export default class ChangeProjectsForm extends React.PureComponent<Props, State return { lastSearchParams: searchParams, - listHasBeenTouched: false, + needToReload: false, projects, projectsTotalCount: data.paging.total, selectedProjects @@ -113,34 +100,11 @@ export default class ChangeProjectsForm extends React.PureComponent<Props, State } }); - 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: 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, + needToReload: true, selectedProjects: [...state.selectedProjects, key] })); } @@ -150,7 +114,7 @@ export default class ChangeProjectsForm extends React.PureComponent<Props, State dissociateProject(this.props.profile.key, key).then(() => { if (this.mounted) { this.setState((state: State) => ({ - listHasBeenTouched: true, + needToReload: true, selectedProjects: without(state.selectedProjects, key) })); } @@ -195,16 +159,17 @@ export default class ChangeProjectsForm extends React.PureComponent<Props, State 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 + needToReload={ + this.state.needToReload && + this.state.lastSearchParams && + this.state.lastSearchParams.filter !== Filter.All } - onLoadMore={this.handleLoadMore} - onReload={this.handleReload} - onSearch={this.handleSearch} + onSearch={this.fetchProjects} onSelect={this.handleSelect} onUnselect={this.handleUnselect} renderElement={this.renderElement} selectedElements={this.state.selectedProjects} + withPaging={true} /> </div> 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 index 19256e1d4d6..8bc50653e14 100644 --- 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 @@ -19,8 +19,8 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import ChangeProjectsForm, { SearchParams } from '../ChangeProjectsForm'; +import { waitAndUpdate, click } from 'sonar-ui-common/helpers/testUtils'; +import ChangeProjectsForm from '../ChangeProjectsForm'; import SelectList, { Filter } from '../../../../components/SelectList/SelectList'; import { getProfileProjects, @@ -28,6 +28,9 @@ import { dissociateProject } from '../../../../api/quality-profiles'; +const profile: any = { key: 'profFile_key' }; +const organization = 'TEST'; + jest.mock('../../../../api/quality-profiles', () => ({ getProfileProjects: jest.fn().mockResolvedValue({ paging: { pageIndex: 1, pageSize: 3, total: 55 }, @@ -41,95 +44,77 @@ jest.mock('../../../../api/quality-profiles', () => ({ dissociateProject: jest.fn().mockResolvedValue({}) })); -const profile: any = { key: 'profFile_key' }; - beforeEach(() => { jest.clearAllMocks(); }); it('should render correctly', async () => { const wrapper = shallowRender(); + wrapper + .find(SelectList) + .props() + .onSearch({ + query: '', + filter: Filter.Selected, + page: 1, + pageSize: 100 + }); await waitAndUpdate(wrapper); - expect(wrapper.instance().mounted).toBe(true); + expect(wrapper.instance().mounted).toBe(true); expect(wrapper).toMatchSnapshot(); expect(wrapper.instance().renderElement('test1')).toMatchSnapshot(); expect(wrapper.instance().renderElement('test_foo')).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); - - wrapper.instance().componentWillUnmount(); - expect(wrapper.instance().mounted).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({ + key: profile.key, + organization, p: 1, - q: 'foo', + ps: 100, + q: undefined, selected: Filter.Selected }) ); - expect(wrapper.state().listHasBeenTouched).toBe(false); -}); - -it('should handle load more properly', async () => { - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); + expect(wrapper.state().needToReload).toBe(false); - wrapper.instance().handleLoadMore(); - expect(getProfileProjects).toHaveBeenCalledWith( - expect.objectContaining({ - p: 2 - }) - ); - expect(wrapper.state().listHasBeenTouched).toBe(false); + wrapper.instance().componentWillUnmount(); + expect(wrapper.instance().mounted).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); + expect(wrapper.state().needToReload).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); + expect(wrapper.state().needToReload).toBe(true); +}); + +it('should close modal properly', () => { + const spy = jest.fn(); + const wrapper = shallowRender({ onClose: spy }); + click(wrapper.find('a')); + + expect(spy).toHaveBeenCalled(); }); function shallowRender(props: Partial<ChangeProjectsForm['props']> = {}) { return shallow<ChangeProjectsForm>( - <ChangeProjectsForm onClose={jest.fn()} organization="TEST" profile={profile} {...props} /> + <ChangeProjectsForm + onClose={jest.fn()} + organization={organization} + 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 index c67712805f8..602b4366eae 100644 --- 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 @@ -29,9 +29,7 @@ exports[`should render correctly 1`] = ` labelAll="quality_gates.projects.all" labelSelected="quality_gates.projects.with" labelUnselected="quality_gates.projects.without" - needReload={false} - onLoadMore={[Function]} - onReload={[Function]} + needToReload={false} onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} @@ -41,6 +39,7 @@ exports[`should render correctly 1`] = ` "test3", ] } + withPaging={true} /> </div> <div diff --git a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx index cdf7e5eab4a..697c533e387 100644 --- a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx @@ -21,7 +21,10 @@ import * as React from 'react'; import { find, without } from 'lodash'; import { translate } from 'sonar-ui-common/helpers/l10n'; import Modal from 'sonar-ui-common/components/controls/Modal'; -import SelectList, { Filter } from '../../../components/SelectList/SelectList'; +import SelectList, { + Filter, + SelectListSearchParams +} from '../../../components/SelectList/SelectList'; import { getUserGroups, UserGroup } from '../../../api/users'; import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups'; @@ -31,25 +34,14 @@ interface Props { user: T.User; } -export interface SearchParams { - login: string; - organization?: string; - page: number; - pageSize: number; - query?: string; - selected: string; -} - interface State { + needToReload: boolean; + lastSearchParams?: SelectListSearchParams; groups: UserGroup[]; groupsTotalCount?: number; - lastSearchParams: SearchParams; - listHasBeenTouched: boolean; selectedGroups: string[]; } -const PAGE_SIZE = 100; - export default class GroupsForm extends React.PureComponent<Props, State> { mounted = false; @@ -57,39 +49,33 @@ export default class GroupsForm extends React.PureComponent<Props, State> { super(props); this.state = { + needToReload: false, groups: [], - lastSearchParams: { - login: props.user.login, - page: 1, - pageSize: PAGE_SIZE, - query: '', - selected: Filter.Selected - }, - listHasBeenTouched: false, selectedGroups: [] }; } componentDidMount() { this.mounted = true; - this.fetchUsers(this.state.lastSearchParams); } componentWillUnmount() { this.mounted = false; } - fetchUsers = (searchParams: SearchParams, more?: boolean) => + fetchUsers = (searchParams: SelectListSearchParams) => getUserGroups({ - login: searchParams.login, - organization: searchParams.organization !== '' ? searchParams.organization : undefined, + login: this.props.user.login, + organization: undefined, p: searchParams.page, ps: searchParams.pageSize, q: searchParams.query !== '' ? searchParams.query : undefined, - selected: searchParams.selected + selected: searchParams.filter }).then(data => { if (this.mounted) { this.setState(prevState => { + const more = searchParams.page != null && searchParams.page > 1; + const groups = more ? [...prevState.groups, ...data.groups] : data.groups; const newSeletedGroups = data.groups.filter(gp => gp.selected).map(gp => gp.name); const selectedGroups = more @@ -98,7 +84,7 @@ export default class GroupsForm extends React.PureComponent<Props, State> { return { lastSearchParams: searchParams, - listHasBeenTouched: false, + needToReload: false, groups, groupsTotalCount: data.paging.total, selectedGroups @@ -107,29 +93,6 @@ export default class GroupsForm extends React.PureComponent<Props, State> { } }); - handleLoadMore = () => - this.fetchUsers( - { - ...this.state.lastSearchParams, - page: this.state.lastSearchParams.page + 1 - }, - true - ); - - handleReload = () => - this.fetchUsers({ - ...this.state.lastSearchParams, - page: 1 - }); - - handleSearch = (query: string, selected: Filter) => - this.fetchUsers({ - ...this.state.lastSearchParams, - page: 1, - query, - selected - }); - handleSelect = (name: string) => addUserToGroup({ name, @@ -137,7 +100,7 @@ export default class GroupsForm extends React.PureComponent<Props, State> { }).then(() => { if (this.mounted) { this.setState((state: State) => ({ - listHasBeenTouched: true, + needToReload: true, selectedGroups: [...state.selectedGroups, name] })); } @@ -150,7 +113,7 @@ export default class GroupsForm extends React.PureComponent<Props, State> { }).then(() => { if (this.mounted) { this.setState((state: State) => ({ - listHasBeenTouched: true, + needToReload: true, selectedGroups: without(state.selectedGroups, name) })); } @@ -196,16 +159,17 @@ export default class GroupsForm extends React.PureComponent<Props, State> { <SelectList elements={this.state.groups.map(group => group.name)} elementsTotalCount={this.state.groupsTotalCount} - needReload={ - this.state.listHasBeenTouched && this.state.lastSearchParams.selected !== Filter.All + needToReload={ + this.state.needToReload && + this.state.lastSearchParams && + this.state.lastSearchParams.filter !== Filter.All } - onLoadMore={this.handleLoadMore} - onReload={this.handleReload} - onSearch={this.handleSearch} + onSearch={this.fetchUsers} onSelect={this.handleSelect} onUnselect={this.handleUnselect} renderElement={this.renderElement} selectedElements={this.state.selectedGroups} + withPaging={true} /> </div> diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx index 6d9846b5efd..e6cbf910c64 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx @@ -19,13 +19,15 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import GroupsForm, { SearchParams } from '../GroupsForm'; +import { waitAndUpdate, click } from 'sonar-ui-common/helpers/testUtils'; +import GroupsForm from '../GroupsForm'; import SelectList, { Filter } from '../../../../components/SelectList/SelectList'; import { getUserGroups } from '../../../../api/users'; import { addUserToGroup, removeUserFromGroup } from '../../../../api/user_groups'; import { mockUser } from '../../../../helpers/testMocks'; +const user = mockUser(); + jest.mock('../../../../api/users', () => ({ getUserGroups: jest.fn().mockResolvedValue({ paging: { pageIndex: 1, pageSize: 10, total: 1 }, @@ -63,93 +65,78 @@ beforeEach(() => { it('should render correctly', async () => { const wrapper = shallowRender(); + wrapper + .find(SelectList) + .props() + .onSearch({ + query: '', + filter: Filter.Selected, + page: 1, + pageSize: 100 + }); await waitAndUpdate(wrapper); + expect(wrapper.instance().mounted).toBe(true); expect(wrapper).toMatchSnapshot(); - expect(getUserGroups).toHaveBeenCalledWith( - expect.objectContaining({ - p: 1 - }) - ); + expect(wrapper.instance().renderElement('test1')).toMatchSnapshot(); + expect(wrapper.instance().renderElement('test_foo')).toMatchSnapshot(); - 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(getUserGroups).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(getUserGroups).toHaveBeenCalledWith( expect.objectContaining({ + login: user.login, + organization: undefined, p: 1, - q: 'foo', + ps: 100, + q: undefined, selected: Filter.Selected }) ); - expect(wrapper.state().listHasBeenTouched).toBe(false); -}); - -it('should handle load more properly', async () => { - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); + expect(wrapper.state().needToReload).toBe(false); - wrapper.instance().handleLoadMore(); - expect(getUserGroups).toHaveBeenCalledWith( - expect.objectContaining({ - p: 2 - }) - ); - expect(wrapper.state().listHasBeenTouched).toBe(false); + wrapper.instance().componentWillUnmount(); + expect(wrapper.instance().mounted).toBe(false); }); it('should handle selection properly', async () => { const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - wrapper.instance().handleSelect('toto'); await waitAndUpdate(wrapper); + expect(addUserToGroup).toHaveBeenCalledWith( expect.objectContaining({ + login: user.login, name: 'toto' }) ); - expect(wrapper.state().listHasBeenTouched).toBe(true); + expect(wrapper.state().needToReload).toBe(true); }); it('should handle deselection properly', async () => { const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - wrapper.instance().handleUnselect('tata'); await waitAndUpdate(wrapper); + expect(removeUserFromGroup).toHaveBeenCalledWith( expect.objectContaining({ + login: user.login, name: 'tata' }) ); - expect(wrapper.state().listHasBeenTouched).toBe(true); + expect(wrapper.state().needToReload).toBe(true); +}); + +it('should close modal properly', () => { + const spyOnClose = jest.fn(); + const spyOnUpdateUsers = jest.fn(); + const wrapper = shallowRender({ onClose: spyOnClose, onUpdateUsers: spyOnUpdateUsers }); + click(wrapper.find('.js-modal-close')); + + expect(spyOnClose).toHaveBeenCalled(); + expect(spyOnUpdateUsers).toHaveBeenCalled(); }); function shallowRender(props: Partial<GroupsForm['props']> = {}) { return shallow<GroupsForm>( - <GroupsForm onClose={jest.fn()} onUpdateUsers={jest.fn()} user={mockUser()} {...props} /> + <GroupsForm onClose={jest.fn()} onUpdateUsers={jest.fn()} user={user} {...props} /> ); } diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap index 6a383259146..f8a5bab167e 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap @@ -24,9 +24,7 @@ exports[`should render correctly 1`] = ` ] } elementsTotalCount={1} - needReload={false} - onLoadMore={[Function]} - onReload={[Function]} + needToReload={false} onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} @@ -37,6 +35,7 @@ exports[`should render correctly 1`] = ` "test2", ] } + withPaging={true} /> </div> <footer @@ -52,3 +51,27 @@ exports[`should render correctly 1`] = ` </footer> </Modal> `; + +exports[`should render correctly 2`] = ` +<div + className="select-list-list-item" +> + <React.Fragment> + test1 + <br /> + <span + className="note" + > + test1 + </span> + </React.Fragment> +</div> +`; + +exports[`should render correctly 3`] = ` +<div + className="select-list-list-item" +> + test_foo +</div> +`; 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 14c2168c237..7cafc36df69 100644 --- a/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx +++ b/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx @@ -39,55 +39,92 @@ interface Props { labelSelected?: string; labelUnselected?: string; labelAll?: string; - needReload?: boolean; - onLoadMore?: () => Promise<void>; - onReload?: () => Promise<void>; - onSearch: (query: string, tab: Filter) => Promise<void>; + needToReload?: boolean; + onSearch: (searchParams: SelectListSearchParams) => Promise<void>; onSelect: (element: string) => Promise<void>; onUnselect: (element: string) => Promise<void>; + pageSize?: number; readOnly?: boolean; renderElement: (element: string) => React.ReactNode; selectedElements: string[]; + withPaging?: boolean; } -interface State { +export interface SelectListSearchParams { filter: Filter; - loading: boolean; + page?: number; + pageSize?: number; query: string; } +interface State { + lastSearchParams: SelectListSearchParams; + loading: boolean; +} + +const DEFAULT_PAGE_SIZE = 100; + export default class SelectList extends React.PureComponent<Props, State> { mounted = false; - state: State = { filter: Filter.Selected, loading: false, query: '' }; + + constructor(props: Props) { + super(props); + + this.state = { + lastSearchParams: { + filter: Filter.Selected, + page: 1, + pageSize: props.pageSize ? props.pageSize : DEFAULT_PAGE_SIZE, + query: '' + }, + loading: false + }; + } componentDidMount() { this.mounted = true; + this.search({}); } componentWillUnmount() { this.mounted = false; } - stopLoading = () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }; + getFilter = () => + this.state.lastSearchParams.query === '' ? this.state.lastSearchParams.filter : Filter.All; + + search = (searchParams: Partial<SelectListSearchParams>) => + this.setState( + prevState => ({ + loading: true, + lastSearchParams: { ...prevState.lastSearchParams, ...searchParams } + }), + () => + this.props + .onSearch({ + filter: this.getFilter(), + page: this.props.withPaging ? this.state.lastSearchParams.page : undefined, + pageSize: this.props.withPaging ? this.state.lastSearchParams.pageSize : undefined, + query: this.state.lastSearchParams.query + }) + .finally(() => { + if (this.mounted) { + this.setState({ loading: false }); + } + }) + ); + + changeFilter = (filter: Filter) => this.search({ filter, page: 1 }); - changeFilter = (filter: Filter) => { - this.setState({ filter, loading: true }); - this.props.onSearch(this.state.query, filter).then(this.stopLoading, this.stopLoading); - }; + handleQueryChange = (query: string) => this.search({ page: 1, query }); - handleQueryChange = (query: string) => { - this.setState({ loading: true, query }, () => { - this.props.onSearch(query, this.getFilter()).then(this.stopLoading, this.stopLoading); + onLoadMore = () => + this.search({ + page: + this.state.lastSearchParams.page != null ? this.state.lastSearchParams.page + 1 : undefined }); - }; - getFilter = () => { - return this.state.query === '' ? this.state.filter : Filter.All; - }; + onReload = () => this.search({ page: 1 }); render() { const { @@ -95,9 +132,9 @@ export default class SelectList extends React.PureComponent<Props, State> { labelUnselected = translate('unselected'), labelAll = translate('all') } = this.props; - const { filter } = this.state; + const { filter } = this.state.lastSearchParams; - const disabled = this.state.query !== ''; + const disabled = this.state.lastSearchParams.query !== ''; return ( <div className="select-list"> @@ -118,7 +155,7 @@ export default class SelectList extends React.PureComponent<Props, State> { loading={this.state.loading} onChange={this.handleQueryChange} placeholder={translate('search_verb')} - value={this.state.query} + value={this.state.lastSearchParams.query} /> </div> <SelectListListContainer @@ -132,12 +169,12 @@ export default class SelectList extends React.PureComponent<Props, State> { renderElement={this.props.renderElement} selectedElements={this.props.selectedElements} /> - {!!this.props.elementsTotalCount && this.props.onLoadMore && ( + {!!this.props.elementsTotalCount && ( <ListFooter count={this.props.elements.length} - loadMore={this.props.onLoadMore} - needReload={this.props.needReload} - reload={this.props.onReload} + loadMore={this.onLoadMore} + needReload={this.props.needToReload} + reload={this.onReload} total={this.props.elementsTotalCount} /> )} 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 98401d0f14e..2d1ab5a0c99 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,67 +22,126 @@ import { shallow } from 'enzyme'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; import SelectList, { Filter } from '../SelectList'; -it('should display selected elements only by default', () => { - const wrapper = shallowRender(); - expect(wrapper.state().filter).toBe(Filter.Selected); -}); +const elements = ['foo', 'bar', 'baz']; +const selectedElements = [elements[0]]; +const disabledElements = [elements[1]]; -it('should display a loader when searching', async () => { +it('should display properly with basics features', async () => { const wrapper = shallowRender(); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.state().loading).toBe(false); + await waitAndUpdate(wrapper); + expect(wrapper.instance().mounted).toBe(true); - wrapper.instance().handleQueryChange(''); - expect(wrapper.state().loading).toBe(true); expect(wrapper).toMatchSnapshot(); + wrapper.instance().componentWillUnmount(); + expect(wrapper.instance().mounted).toBe(false); +}); + +it('should display properly with advanced features', async () => { + const wrapper = shallowRender({ + allowBulkSelection: true, + elementsTotalCount: 125, + pageSize: 10, + readOnly: true, + withPaging: true + }); await waitAndUpdate(wrapper); - expect(wrapper.state().loading).toBe(false); + + expect(wrapper).toMatchSnapshot(); }); -it('should display a loader when updating filter', async () => { +it('should display a loader when searching', async () => { const wrapper = shallowRender(); - expect(wrapper).toMatchSnapshot(); + await waitAndUpdate(wrapper); expect(wrapper.state().loading).toBe(false); - wrapper.instance().changeFilter(Filter.Unselected); + wrapper.instance().search({}); expect(wrapper.state().loading).toBe(true); expect(wrapper).toMatchSnapshot(); await waitAndUpdate(wrapper); - expect(wrapper.state().filter).toBe(Filter.Unselected); expect(wrapper.state().loading).toBe(false); }); it('should cancel filter selection when search is active', async () => { - const wrapper = shallowRender(); + const spy = jest.fn().mockResolvedValue({}); + const wrapper = shallowRender({ onSearch: spy }); + wrapper.instance().changeFilter(Filter.Unselected); + await waitAndUpdate(wrapper); + + expect(spy).toHaveBeenCalledWith({ + query: '', + filter: Filter.Unselected, + page: undefined, + pageSize: undefined + }); + expect(wrapper).toMatchSnapshot(); + + const query = 'test'; + wrapper.instance().handleQueryChange(query); + expect(spy).toHaveBeenCalledWith({ + query, + filter: Filter.All, + page: undefined, + pageSize: undefined + }); - wrapper.setState({ filter: Filter.Selected }); await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); - wrapper.setState({ query: 'foobar' }); + wrapper.instance().handleQueryChange(''); + expect(spy).toHaveBeenCalledWith({ + query: '', + filter: Filter.Unselected, + page: undefined, + pageSize: undefined + }); + await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); }); -it('should display pagination element properly', () => { - const wrapper = shallowRender({ elementsTotalCount: 100, onLoadMore: jest.fn() }); +it('should display pagination element properly and call search method with correct parameters', () => { + const spy = jest.fn().mockResolvedValue({}); + const wrapper = shallowRender({ elementsTotalCount: 100, onSearch: spy, withPaging: true }); expect(wrapper).toMatchSnapshot(); + expect(spy).toHaveBeenCalledWith({ + query: '', + filter: Filter.Selected, + page: 1, + pageSize: 100 + }); // Basic default call + + wrapper.instance().onLoadMore(); + expect(spy).toHaveBeenCalledWith({ + query: '', + filter: Filter.Selected, + page: 2, + pageSize: 100 + }); // Load more call + + wrapper.instance().onReload(); + expect(spy).toHaveBeenCalledWith({ + query: '', + filter: Filter.Selected, + page: 1, + pageSize: 100 + }); // Reload call - wrapper.setProps({ needReload: true, onReload: jest.fn() }); + wrapper.setProps({ needToReload: true }); expect(wrapper).toMatchSnapshot(); }); function shallowRender(props: Partial<SelectList['props']> = {}) { return shallow<SelectList>( <SelectList - elements={['foo', 'bar', 'baz']} + disabledElements={disabledElements} + elements={elements} onSearch={jest.fn(() => Promise.resolve())} onSelect={jest.fn(() => Promise.resolve())} onUnselect={jest.fn(() => Promise.resolve())} renderElement={(foo: string) => foo} - selectedElements={['foo']} + selectedElements={selectedElements} {...props} /> ); 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 eb38a0a6304..f68fa52b1bf 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 @@ -31,7 +31,7 @@ exports[`should cancel filter selection when search is active 1`] = ` }, ] } - value="selected" + value="deselected" /> <SearchBox autoFocus={true} @@ -42,7 +42,11 @@ exports[`should cancel filter selection when search is active 1`] = ` /> </div> <SelectListListContainer - disabledElements={Array []} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -50,7 +54,7 @@ exports[`should cancel filter selection when search is active 1`] = ` "baz", ] } - filter="selected" + filter="deselected" onSelect={[MockFunction]} onUnselect={[MockFunction]} renderElement={[Function]} @@ -94,18 +98,22 @@ exports[`should cancel filter selection when search is active 2`] = ` }, ] } - value="selected" + value="deselected" /> <SearchBox autoFocus={true} loading={false} onChange={[Function]} placeholder="search_verb" - value="foobar" + value="test" /> </div> <SelectListListContainer - disabledElements={Array []} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -126,7 +134,7 @@ exports[`should cancel filter selection when search is active 2`] = ` </div> `; -exports[`should display a loader when searching 1`] = ` +exports[`should cancel filter selection when search is active 3`] = ` <div className="select-list" > @@ -157,7 +165,7 @@ exports[`should display a loader when searching 1`] = ` }, ] } - value="selected" + value="deselected" /> <SearchBox autoFocus={true} @@ -168,7 +176,11 @@ exports[`should display a loader when searching 1`] = ` /> </div> <SelectListListContainer - disabledElements={Array []} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -176,7 +188,7 @@ exports[`should display a loader when searching 1`] = ` "baz", ] } - filter="selected" + filter="deselected" onSelect={[MockFunction]} onUnselect={[MockFunction]} renderElement={[Function]} @@ -189,7 +201,7 @@ exports[`should display a loader when searching 1`] = ` </div> `; -exports[`should display a loader when searching 2`] = ` +exports[`should display a loader when searching 1`] = ` <div className="select-list" > @@ -231,7 +243,11 @@ exports[`should display a loader when searching 2`] = ` /> </div> <SelectListListContainer - disabledElements={Array []} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -252,7 +268,7 @@ exports[`should display a loader when searching 2`] = ` </div> `; -exports[`should display a loader when updating filter 1`] = ` +exports[`should display pagination element properly and call search method with correct parameters 1`] = ` <div className="select-list" > @@ -287,14 +303,18 @@ exports[`should display a loader when updating filter 1`] = ` /> <SearchBox autoFocus={true} - loading={false} + loading={true} onChange={[Function]} placeholder="search_verb" value="" /> </div> <SelectListListContainer - disabledElements={Array []} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -312,10 +332,16 @@ exports[`should display a loader when updating filter 1`] = ` ] } /> + <ListFooter + count={3} + loadMore={[Function]} + reload={[Function]} + total={100} + /> </div> `; -exports[`should display a loader when updating filter 2`] = ` +exports[`should display pagination element properly and call search method with correct parameters 2`] = ` <div className="select-list" > @@ -346,7 +372,7 @@ exports[`should display a loader when updating filter 2`] = ` }, ] } - value="deselected" + value="selected" /> <SearchBox autoFocus={true} @@ -357,7 +383,11 @@ exports[`should display a loader when updating filter 2`] = ` /> </div> <SelectListListContainer - disabledElements={Array []} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -365,7 +395,7 @@ exports[`should display a loader when updating filter 2`] = ` "baz", ] } - filter="deselected" + filter="selected" onSelect={[MockFunction]} onUnselect={[MockFunction]} renderElement={[Function]} @@ -375,10 +405,17 @@ exports[`should display a loader when updating filter 2`] = ` ] } /> + <ListFooter + count={3} + loadMore={[Function]} + needReload={true} + reload={[Function]} + total={100} + /> </div> `; -exports[`should display pagination element properly 1`] = ` +exports[`should display properly with advanced features 1`] = ` <div className="select-list" > @@ -420,7 +457,12 @@ exports[`should display pagination element properly 1`] = ` /> </div> <SelectListListContainer - disabledElements={Array []} + allowBulkSelection={true} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -431,6 +473,7 @@ exports[`should display pagination element properly 1`] = ` filter="selected" onSelect={[MockFunction]} onUnselect={[MockFunction]} + readOnly={true} renderElement={[Function]} selectedElements={ Array [ @@ -440,13 +483,14 @@ exports[`should display pagination element properly 1`] = ` /> <ListFooter count={3} - loadMore={[MockFunction]} - total={100} + loadMore={[Function]} + reload={[Function]} + total={125} /> </div> `; -exports[`should display pagination element properly 2`] = ` +exports[`should display properly with basics features 1`] = ` <div className="select-list" > @@ -488,7 +532,11 @@ exports[`should display pagination element properly 2`] = ` /> </div> <SelectListListContainer - disabledElements={Array []} + disabledElements={ + Array [ + "bar", + ] + } elements={ Array [ "foo", @@ -506,12 +554,5 @@ exports[`should display pagination element properly 2`] = ` ] } /> - <ListFooter - count={3} - loadMore={[MockFunction]} - needReload={true} - reload={[MockFunction]} - total={100} - /> </div> `; |