]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12147 Add pagination to QG & QP projects list
authorphilippe-perrin-sonarsource <philippe.perrin@sonarsource.com>
Tue, 18 Jun 2019 10:05:14 +0000 (12:05 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 28 Jun 2019 06:45:50 +0000 (08:45 +0200)
16 files changed:
server/sonar-web/src/main/js/api/quality-gates.ts
server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/Projects-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/Projects-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ChangeProjectsForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ChangeProjectsForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/SelectList.tsx
server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap
server/sonar-web/src/main/js/components/controls/ListFooter.tsx
server/sonar-web/src/main/js/components/controls/__tests__/ListFooter-test.tsx
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap [new file with mode: 0644]

index 29809c00691617b484e3ac1f6b97db88257496cb..5cab317b5ba555f2a388e27911610c098da70379 100644 (file)
@@ -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);
 }
 
index 84105505fba82d1c570a2b87db47cb3a75f906cf..1db75ac344771bc6eec4ae7fefeddfa30aa94748 100644 (file)
  * 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 (file)
index 0000000..0efe2ae
--- /dev/null
@@ -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 (file)
index 0000000..dadf536
--- /dev/null
@@ -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",
+    ]
+  }
+/>
+`;
index b1a718f1eadfd2f4f676dbab10bfb6a9e800e7ef..ed11dd0454ee77ae375795653955df88f9f2b6f2 100644 (file)
  * 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 (file)
index 0000000..97f036c
--- /dev/null
@@ -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 (file)
index 0000000..0eaee66
--- /dev/null
@@ -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>
+`;
index b1c7e9aa665a1d125a029bbf2f9090c9e3a7c93c..bf5cffcf15a9cb492cce9147c312a31995ad2bbc 100644 (file)
@@ -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>
     );
   }
index 829bee3f284db880cd9e096dbe68b193001eb28d..fa2ed489c4b30b6d156de63c10886e7f698038a4 100644 (file)
@@ -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}
index 5e8ebe5253155e608a0cffb61e9d8fe1b5aeccd8..f78438970894cdd98a8c852ba1ca5db8a418ecc8 100644 (file)
@@ -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}
+    />
+  );
+}
index c5bc86aff22355ddc0812be20ded2dd5efdd85ae..496b9b49a9af315a3c736949e8640bb7551539e9 100644 (file)
@@ -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();
 });
index d7fadb494c1d1629d26761abf74f3db41540e946..eb38a0a6304f3c6feef834799d2ce4243947718b 100644 (file)
@@ -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>
+`;
index 9f946d3e41b42ad01cd26a45293cbeb81a53786a..04b0553b72a75243eaf4b6a3ec491cb96c1a0c99 100644 (file)
@@ -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>
-`;
index 9d0efcf94f3ea635f580d296fdd54e44d0c780dc..922899cd67a487ce2dfef2c8d0a79ecaa1e86748 100644 (file)
@@ -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>
   );
index b92b4ca945444bccb1e755fee53949ff9de8a31f..901cfb756375813bd9cd5d26f02651f905e668e5 100644 (file)
  */
 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 (file)
index 0000000..c2aa7f0
--- /dev/null
@@ -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>
+`;