]> source.dussan.org Git - sonarqube.git/commitdiff
Rewrite SelectList component in React on Quality Page (#3152)
authorPascal Mugnier <pascal.mugnier@sonarsource.com>
Fri, 16 Mar 2018 15:21:29 +0000 (16:21 +0100)
committerSonarTech <sonartech@sonarsource.com>
Thu, 22 Mar 2018 11:37:48 +0000 (12:37 +0100)
29 files changed:
server/sonar-web/src/main/js/api/quality-gates.ts
server/sonar-web/src/main/js/api/quality-profiles.ts
server/sonar-web/src/main/js/api/user_groups.ts
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/app/styles/init/icons.css
server/sonar-web/src/main/js/app/styles/init/type.css
server/sonar-web/src/main/js/app/utils/exposeLibraries.ts
server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap
server/sonar-web/src/main/js/apps/quality-gates/components/Projects.js
server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
server/sonar-web/src/main/js/components/SelectList/SelectList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/SelectList/index.js [deleted file]
server/sonar-web/src/main/js/components/SelectList/styles.css
server/sonar-web/src/main/js/components/SelectList/templates/item.hbs [deleted file]
server/sonar-web/src/main/js/components/SelectList/templates/list.hbs [deleted file]
server/sonar-web/src/main/js/components/controls/Checkbox.tsx
server/sonar-web/src/main/js/components/controls/RadioToggle.tsx
server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index b40e6130b28a8ed12b704b1c90275cc96c748112..fc72234b27e03a8d99b796e2ca1ca4c6c57fdda0 100644 (file)
@@ -133,10 +133,21 @@ export function getGateForProject(data: {
   );
 }
 
+export function searchGates(data: {
+  gateId: number;
+  organization?: string;
+  page: number;
+  pageSize: number;
+  selected: string;
+}): Promise<void | Response> {
+  return getJSON('/api/qualitygates/search', data).catch(throwGlobalError);
+}
+
 export function associateGateWithProject(data: {
   gateId: number;
   organization?: string;
-  projectKey: string;
+  projectKey?: string;
+  projectId?: string;
 }): Promise<void | Response> {
   return post('/api/qualitygates/select', data).catch(throwGlobalError);
 }
@@ -144,7 +155,8 @@ export function associateGateWithProject(data: {
 export function dissociateGateWithProject(data: {
   gateId: number;
   organization?: string;
-  projectKey: string;
+  projectKey?: string;
+  projectId?: string;
 }): Promise<void | Response> {
   return post('/api/qualitygates/deselect', data).catch(throwGlobalError);
 }
index e589638ac763447f871aec9d41ada9f40fa02013..bf8fc2b3eeb8f4f9f7087dc0fc163701c6cfeeb8 100644 (file)
@@ -107,7 +107,16 @@ export function restoreQualityProfile(data: RequestData): Promise<any> {
     .then(parseJSON);
 }
 
-export function getProfileProjects(data: RequestData): Promise<any> {
+export interface ProfileProject {
+  id: number;
+  key: string;
+  name: string;
+  selected: boolean;
+}
+
+export function getProfileProjects(
+  data: RequestData
+): Promise<{ more: boolean; results: ProfileProject[] }> {
   return getJSON('/api/qualityprofiles/projects', data).catch(throwGlobalError);
 }
 
@@ -153,12 +162,16 @@ export function compareProfiles(leftKey: string, rightKey: string): Promise<any>
   return getJSON('/api/qualityprofiles/compare', { leftKey, rightKey });
 }
 
-export function associateProject(profileKey: string, projectKey: string): Promise<void> {
-  return post('/api/qualityprofiles/add_project', { profileKey, projectKey });
+export function associateProject(profileKey: string, projectKey: string) {
+  return post('/api/qualityprofiles/add_project', { profileKey, projectKey }).catch(
+    throwGlobalError
+  );
 }
 
-export function dissociateProject(profileKey: string, projectKey: string): Promise<void> {
-  return post('/api/qualityprofiles/remove_project', { profileKey, projectKey });
+export function dissociateProject(profileKey: string, projectKey: string) {
+  return post('/api/qualityprofiles/remove_project', { profileKey, projectKey }).catch(
+    throwGlobalError
+  );
 }
 
 export interface SearchUsersGroupsParameters {
index 2e1b85c5cdf2c8c1ef48858695431694b16eed55..eadd920a5f0554445cee35a858f54513c637587d 100644 (file)
@@ -28,7 +28,25 @@ export function searchUsersGroups(data: {
   ps?: number;
   q?: string;
 }): Promise<{ groups: Group[]; paging: Paging }> {
-  return getJSON('/api/user_groups/search', data);
+  return getJSON('/api/user_groups/search', data).catch(throwGlobalError);
+}
+
+export interface GroupUser {
+  login: string;
+  name: string;
+  selected: boolean;
+}
+
+export function getUsersInGroup(data: {
+  id?: number;
+  name?: string;
+  organization?: string;
+  p?: number;
+  ps?: number;
+  q?: string;
+  selected?: string;
+}): Promise<{ paging: Paging; users: GroupUser[] }> {
+  return getJSON('/api/user_groups/users', data).catch(throwGlobalError);
 }
 
 export function addUserToGroup(data: {
@@ -37,7 +55,7 @@ export function addUserToGroup(data: {
   login?: string;
   organization?: string;
 }) {
-  return post('/api/user_groups/add_user', data);
+  return post('/api/user_groups/add_user', data).catch(throwGlobalError);
 }
 
 export function removeUserFromGroup(data: {
@@ -46,7 +64,7 @@ export function removeUserFromGroup(data: {
   login?: string;
   organization?: string;
 }) {
-  return post('/api/user_groups/remove_user', data);
+  return post('/api/user_groups/remove_user', data).catch(throwGlobalError);
 }
 
 export function createGroup(data: {
index b8667f54e3884f995187baa22d885859cf1c9f83..055be31081f1c02c1d92906394ee94488171f18f 100644 (file)
@@ -33,11 +33,30 @@ export function changePassword(data: {
   return post('/api/users/change_password', data);
 }
 
-export function getUserGroups(login: string, organization?: string): Promise<any> {
+export interface UserGroup {
+  default: boolean;
+  description: string;
+  id: number;
+  name: string;
+  selected: boolean;
+}
+
+export function getUserGroups(
+  login: string,
+  organization?: string,
+  query?: string,
+  selected?: string
+): Promise<{ paging: Paging; groups: UserGroup[] }> {
   const data: RequestData = { login };
   if (organization) {
     data.organization = organization;
   }
+  if (query) {
+    data.q = query;
+  }
+  if (selected) {
+    data.selected = selected;
+  }
   return getJSON('/api/users/groups', data);
 }
 
index 9955f3fef6ec67eb84da229d03d1e11a94d08968..b6c9cdb078bd3636a4d76437c0bb3b5083811fa9 100644 (file)
@@ -310,6 +310,15 @@ a[class*=' icon-'] {
   background-image: url('data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M10%204.698C10%204.312%209.688%204%209.302%204H4.698C4.312%204%204%204.312%204%204.698v4.604c0%20.386.312.698.698.698h4.604c.386%200%20.698-.312.698-.698V4.698z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E');
 }
 
+.icon-checkbox-disabled:before {
+  border: 1px solid #bbb;
+  cursor: not-allowed;
+}
+
+.icon-checkbox-disabled.icon-checkbox-checked:before {
+  background-color: #bbb;
+}
+
 .icon-checkbox-invisible {
   visibility: hidden;
 }
index 28ab828201fcd0055bd31d0d76c44d12bff1283a..943ee5497a819ff063062e949a1cc53bb0b875c3 100644 (file)
@@ -242,7 +242,7 @@ small,
 }
 
 .text-muted {
-  color: var(--secondFontColor);
+  color: var(--secondFontColor) !important;
 }
 
 .text-muted-2 {
index 1cd899f9064625f0ef27a6c9cd3487b91787422a..001ed45dd06c1c4457ad7344dea1cf3c37f62650 100644 (file)
@@ -34,7 +34,7 @@ import Modal from '../../components/controls/Modal';
 import SearchBox from '../../components/controls/SearchBox';
 import Select from '../../components/controls/Select';
 import Tooltip from '../../components/controls/Tooltip';
-import SelectList from '../../components/SelectList';
+import SelectList from '../../components/SelectList/SelectList';
 import CoverageRating from '../../components/ui/CoverageRating';
 import DuplicationsRating from '../../components/ui/DuplicationsRating';
 import Level from '../../components/ui/Level';
index 9b62320e86b1e892a6d9080ef9a4a19d667d8eda..ee0fc20aacc4cbda3074ade98e29646b4e1336e9 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import * as escapeHtml from 'escape-html';
+import { find, without } from 'lodash';
 import { Group } from '../../../app/types';
 import Modal from '../../../components/controls/Modal';
 import BulletListIcon from '../../../components/icons-components/BulletListIcon';
-import SelectList from '../../../components/SelectList';
+import SelectList, { Filter } from '../../../components/SelectList/SelectList';
 import { ButtonIcon, ResetButtonLink } from '../../../components/ui/buttons';
 import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/urls';
+import {
+  getUsersInGroup,
+  addUserToGroup,
+  removeUserFromGroup,
+  GroupUser
+} from '../../../api/user_groups';
 
 interface Props {
   group: Group;
@@ -35,14 +40,17 @@ interface Props {
 
 interface State {
   modal: boolean;
+  users: GroupUser[];
+  selectedUsers: string[];
 }
 
 export default class EditMembers extends React.PureComponent<Props, State> {
   container?: HTMLElement | null;
   mounted = false;
-  state: State = { modal: false };
+  state: State = { modal: false, users: [], selectedUsers: [] };
 
   componentDidMount() {
+    this.handleSearch('', Filter.Selected);
     this.mounted = true;
   }
 
@@ -50,13 +58,49 @@ export default class EditMembers extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  handleMembersClick = () => {
-    this.setState({ modal: true }, () => {
-      // defer rendering of the SelectList to make sure we have `ref` assigned
-      setTimeout(this.renderSelectList, 0);
+  handleSearch = (query: string, selected: Filter) => {
+    return getUsersInGroup({
+      id: this.props.group.id,
+      organization: this.props.organization,
+      ps: 100,
+      q: query !== '' ? query : undefined,
+      selected
+    }).then(data => {
+      this.setState({
+        users: data.users,
+        selectedUsers: data.users.filter(user => user.selected).map(user => user.login)
+      });
+    });
+  };
+
+  handleSelect = (login: string) => {
+    return addUserToGroup({
+      name: this.props.group.name,
+      login,
+      organization: this.props.organization
+    }).then(() => {
+      this.setState((state: State) => ({
+        selectedUsers: [...state.selectedUsers, login]
+      }));
+    });
+  };
+
+  handleUnselect = (login: string) => {
+    return removeUserFromGroup({
+      name: this.props.group.name,
+      login,
+      organization: this.props.organization
+    }).then(() => {
+      this.setState((state: State) => ({
+        selectedUsers: without(state.selectedUsers, login)
+      }));
     });
   };
 
+  handleMembersClick = () => {
+    this.setState({ modal: true });
+  };
+
   handleModalClose = () => {
     if (this.mounted) {
       this.setState({ modal: false });
@@ -64,29 +108,9 @@ export default class EditMembers extends React.PureComponent<Props, State> {
     }
   };
 
-  renderSelectList = () => {
-    if (this.container) {
-      const extra = { name: this.props.group.name, organization: this.props.organization };
-
-      /* eslint-disable no-new */
-      new SelectList({
-        el: this.container,
-        width: '100%',
-        readOnly: false,
-        focusSearch: false,
-        dangerouslyUnescapedHtmlFormat: (item: { login: string; name: string }) =>
-          `${escapeHtml(item.name)}<br><span class="note">${escapeHtml(item.login)}</span>`,
-        queryParam: 'q',
-        searchUrl: getBaseUrl() + '/api/user_groups/users?ps=100&id=' + this.props.group.id,
-        selectUrl: getBaseUrl() + '/api/user_groups/add_user',
-        deselectUrl: getBaseUrl() + '/api/user_groups/remove_user',
-        extra,
-        selectParameter: 'login',
-        selectParameterValue: 'login',
-        parse: (r: any) => r.users
-      });
-      /* eslint-enable no-new */
-    }
+  renderElement = (login: string): React.ReactNode => {
+    const user = find(this.state.users, { login });
+    return user === undefined ? login : user.login;
   };
 
   render() {
@@ -104,7 +128,14 @@ export default class EditMembers extends React.PureComponent<Props, State> {
             </header>
 
             <div className="modal-body">
-              <div id="groups-users" ref={node => (this.container = node)} />
+              <SelectList
+                elements={this.state.users.map(user => user.login)}
+                onSearch={this.handleSearch}
+                onSelect={this.handleSelect}
+                onUnselect={this.handleUnselect}
+                renderElement={this.renderElement}
+                selectedElements={this.state.selectedUsers}
+              />
             </div>
 
             <footer className="modal-foot">
index 230836640f5143d32fcb3d4187343ae180357241..a0b1f679d14cc3bc60fe070a47c245db38826162 100644 (file)
@@ -33,8 +33,13 @@ exports[`should edit members 2`] = `
     <div
       className="modal-body"
     >
-      <div
-        id="groups-users"
+      <SelectList
+        elements={Array []}
+        onSearch={[Function]}
+        onSelect={[Function]}
+        onUnselect={[Function]}
+        renderElement={[Function]}
+        selectedElements={Array []}
       />
     </div>
     <footer
index 09d8baf401d3a24f9d0f1ed5549a08e3601839fc..b51bed9bd11cd0f4802de0078724ad2313f680ee 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import React from 'react';
-import escapeHtml from 'escape-html';
-import SelectList from '../../../components/SelectList';
+import { find, without } from 'lodash';
+import SelectList, { Filter } from '../../../components/SelectList/SelectList';
 import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/urls';
+import {
+  searchGates,
+  associateGateWithProject,
+  dissociateGateWithProject
+} from '../../../api/quality-gates';
+/*:: import { Project } from '../../projects/types'; */
+
+/*::
+type State = {
+  projects: Projects[],
+  selectedProjects: string[]
+};
+*/
 
 export default class Projects extends React.PureComponent {
+  state /*: State */ = { projects: [], selectedProjects: [] };
+
   componentDidMount() {
-    this.renderSelectList();
+    this.handleSearch('', Filter.Selected);
   }
 
-  renderSelectList = () => {
-    if (!this.container) return;
-
-    const { qualityGate, edit, organization } = this.props;
+  handleSearch = (query /*: string*/, selected /*: string */) => {
+    return searchGates({
+      gateId: this.props.qualityGate.id,
+      organization: this.props.organization,
+      pageSize: 100,
+      query: query !== '' ? query : undefined,
+      selected
+    }).then(data => {
+      this.setState({
+        projects: data.results,
+        selectedProjects: data.results
+          .filter(project => project.selected)
+          .map(project => project.id)
+      });
+    });
+  };
 
-    const extra = { gateId: qualityGate.id };
-    let orgQuery = '';
-    if (organization) {
-      extra.organization = organization;
-      orgQuery = '&organization=' + organization;
-    }
+  handleSelect = (id /*: string*/) => {
+    return associateGateWithProject({
+      gateId: this.props.qualityGate.id,
+      organization: this.props.organization,
+      projectId: id
+    }).then(() => {
+      this.setState((state /*: State*/) => ({
+        selectedProjects: [...state.selectedProjects, id]
+      }));
+    });
+  };
 
-    // eslint-disable-next-line no-new
-    new SelectList({
-      el: this.container,
-      width: '100%',
-      readOnly: !edit,
-      focusSearch: false,
-      dangerouslyUnescapedHtmlFormat: item => escapeHtml(item.name),
-      searchUrl: getBaseUrl() + `/api/qualitygates/search?gateId=${qualityGate.id}${orgQuery}`,
-      selectUrl: getBaseUrl() + '/api/qualitygates/select',
-      deselectUrl: getBaseUrl() + '/api/qualitygates/deselect',
-      extra,
-      selectParameter: 'projectId',
-      selectParameterValue: 'id',
-      labels: {
-        selected: translate('quality_gates.projects.with'),
-        deselected: translate('quality_gates.projects.without'),
-        all: translate('quality_gates.projects.all'),
-        noResults: translate('quality_gates.projects.noResults')
+  handleUnselect = (id /*: string*/) => {
+    return dissociateGateWithProject({
+      gateId: this.props.qualityGate.id,
+      organization: this.props.organization,
+      projectId: id
+    }).then(
+      () => {
+        this.setState((state /*: State*/) => ({
+          selectedProjects: without(state.selectedProjects, id)
+        }));
       },
-      tooltips: {
-        select: translate('quality_gates.projects.select_hint'),
-        deselect: translate('quality_gates.projects.deselect_hint')
-      }
-    });
+      () => {}
+    );
+  };
+
+  renderElement = (id /*: string*/) /*: React.ReactNode*/ => {
+    const project = find(this.state.projects, { id });
+    return project === undefined ? id : project.name;
   };
 
   render() {
-    return <div ref={node => (this.container = node)} />;
+    return (
+      <SelectList
+        elements={this.state.projects.map(project => project.id)}
+        labelAll={translate('quality_gates.projects.all')}
+        labelSelected={translate('quality_gates.projects.with')}
+        labelUnselected={translate('quality_gates.projects.without')}
+        onSearch={this.handleSearch}
+        onSelect={this.handleSelect}
+        onUnselect={this.handleUnselect}
+        renderElement={this.renderElement}
+        selectedElements={this.state.selectedProjects}
+      />
+    );
   }
 }
index 8eb09c52072a3fb7ac8975a67c7b72c91ddcbb06..408c5afa85def4207a70367e0b0e4f4186cdcaf0 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import * as escapeHtml from 'escape-html';
+import { find, without } from 'lodash';
 import Modal from '../../../components/controls/Modal';
-import SelectList from '../../../components/SelectList';
+import SelectList, { Filter } from '../../../components/SelectList/SelectList';
 import { translate } from '../../../helpers/l10n';
 import { Profile } from '../types';
+import {
+  getProfileProjects,
+  associateProject,
+  dissociateProject,
+  ProfileProject
+} from '../../../api/quality-profiles';
 
 interface Props {
   onClose: () => void;
@@ -30,61 +36,84 @@ interface Props {
   profile: Profile;
 }
 
+interface State {
+  projects: ProfileProject[];
+  selectedProjects: string[];
+}
+
 export default class ChangeProjectsForm extends React.PureComponent<Props> {
   container?: HTMLElement | null;
+  state: State = { projects: [], selectedProjects: [] };
+
+  componentDidMount() {
+    this.handleSearch('', Filter.Selected);
+  }
+
+  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
+            .filter(project => project.selected)
+            .map(project => project.key)
+        });
+      },
+      () => {}
+    );
+  };
+
+  handleSelect = (key: string) => {
+    return associateProject(this.props.profile.key, key).then(() => {
+      this.setState((state: State) => ({
+        selectedProjects: [...state.selectedProjects, key]
+      }));
+    });
+  };
+
+  handleUnselect = (key: string) => {
+    return dissociateProject(this.props.profile.key, key).then(() => {
+      this.setState((state: State) => ({ selectedProjects: without(state.selectedProjects, key) }));
+    });
+  };
 
   handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => {
     event.preventDefault();
     this.props.onClose();
   };
 
-  renderSelectList = () => {
-    if (this.container) {
-      const { key } = this.props.profile;
-
-      const searchUrl =
-        (window as any).baseUrl + '/api/qualityprofiles/projects?key=' + encodeURIComponent(key);
-
-      new SelectList({
-        searchUrl,
-        el: this.container,
-        width: '100%',
-        readOnly: false,
-        focusSearch: false,
-        dangerouslyUnescapedHtmlFormat: (item: { name: string }) => escapeHtml(item.name),
-        selectUrl: (window as any).baseUrl + '/api/qualityprofiles/add_project',
-        deselectUrl: (window as any).baseUrl + '/api/qualityprofiles/remove_project',
-        extra: { profileKey: key },
-        selectParameter: 'projectUuid',
-        selectParameterValue: 'uuid',
-        labels: {
-          selected: translate('quality_gates.projects.with'),
-          deselected: translate('quality_gates.projects.without'),
-          all: translate('quality_gates.projects.all'),
-          noResults: translate('quality_gates.projects.noResults')
-        },
-        tooltips: {
-          select: translate('quality_profiles.projects.select_hint'),
-          deselect: translate('quality_profiles.projects.deselect_hint')
-        }
-      });
-    }
+  renderElement = (key: string): React.ReactNode => {
+    const project = find(this.state.projects, { key });
+    return project === undefined ? key : project.name;
   };
 
   render() {
     const header = translate('projects');
 
     return (
-      <Modal
-        contentLabel={header}
-        onAfterOpen={this.renderSelectList}
-        onRequestClose={this.props.onClose}>
+      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
         <div className="modal-head">
           <h2>{header}</h2>
         </div>
 
-        <div className="modal-body">
-          <div id="profile-projects" ref={node => (this.container = node)} />
+        <div className="modal-body" id="profile-projects">
+          <SelectList
+            elements={this.state.projects.map(project => project.key)}
+            labelAll={translate('quality_gates.projects.all')}
+            labelSelected={translate('quality_gates.projects.with')}
+            labelUnselected={translate('quality_gates.projects.without')}
+            onSearch={this.handleSearch}
+            onSelect={this.handleSelect}
+            onUnselect={this.handleUnselect}
+            renderElement={this.renderElement}
+            selectedElements={this.state.selectedProjects}
+          />
         </div>
 
         <div className="modal-foot">
index a15eac7f596adf2e92910eb7a0f3a74f89bd2e67..6bb46a383355f50292d8f44d829c2c337ac3b66b 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import * as escapeHtml from 'escape-html';
+import { find, without } from 'lodash';
 import { User } from '../../../app/types';
 import Modal from '../../../components/controls/Modal';
-import SelectList from '../../../components/SelectList';
+import SelectList, { Filter } from '../../../components/SelectList/SelectList';
 import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/urls';
+import { getUserGroups, UserGroup } from '../../../api/users';
+import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups';
 
 interface Props {
   onClose: () => void;
@@ -31,8 +32,48 @@ interface Props {
   user: User;
 }
 
+interface State {
+  error: string;
+  groups: UserGroup[];
+  selectedGroups: string[];
+}
+
 export default class GroupsForm extends React.PureComponent<Props> {
   container?: HTMLDivElement | null;
+  state: State = { error: '', groups: [], selectedGroups: [] };
+
+  componentDidMount() {
+    this.handleSearch('', Filter.Selected);
+  }
+
+  handleSearch = (query: string, selected: Filter) => {
+    return getUserGroups(this.props.user.login, undefined, query, selected).then(data => {
+      this.setState({
+        groups: data.groups,
+        selectedGroups: data.groups.filter(group => group.selected).map(group => group.name)
+      });
+    });
+  };
+
+  handleSelect = (name: string) => {
+    return addUserToGroup({
+      name,
+      login: this.props.user.login
+    }).then(() => {
+      this.setState((state: State) => ({ selectedGroups: [...state.selectedGroups, name] }));
+    });
+  };
+
+  handleUnselect = (name: string) => {
+    return removeUserFromGroup({
+      name,
+      login: this.props.user.login
+    }).then(() => {
+      this.setState((state: State) => ({
+        selectedGroups: without(state.selectedGroups, name)
+      }));
+    });
+  };
 
   handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => {
     event.preventDefault();
@@ -44,46 +85,34 @@ export default class GroupsForm extends React.PureComponent<Props> {
     this.props.onClose();
   };
 
-  renderSelectList = () => {
-    const searchUrl = `${getBaseUrl()}/api/users/groups?ps=100&login=${encodeURIComponent(
-      this.props.user.login
-    )}`;
-
-    new (SelectList as any)({
-      el: this.container,
-      width: '100%',
-      readOnly: false,
-      focusSearch: false,
-      dangerouslyUnescapedHtmlFormat: (item: { name: string; description: string }) =>
-        `${escapeHtml(item.name)}<br><span class="note">${escapeHtml(item.description)}</span>`,
-      queryParam: 'q',
-      searchUrl,
-      selectUrl: getBaseUrl() + '/api/user_groups/add_user',
-      deselectUrl: getBaseUrl() + '/api/user_groups/remove_user',
-      extra: { login: this.props.user.login },
-      selectParameter: 'id',
-      selectParameterValue: 'id',
-      parse(r: any) {
-        this.more = false;
-        return r.groups;
-      }
-    });
+  renderElement = (name: string): React.ReactNode => {
+    const group = find(this.state.groups, { name });
+    return group === undefined ? name : group.name;
   };
 
   render() {
     const header = translate('users.update_groups');
 
     return (
-      <Modal
-        contentLabel={header}
-        onAfterOpen={this.renderSelectList}
-        onRequestClose={this.handleClose}>
+      <Modal contentLabel={header} onRequestClose={this.handleClose}>
         <div className="modal-head">
           <h2>{header}</h2>
         </div>
 
         <div className="modal-body">
-          <div id="user-groups" ref={node => (this.container = node)} />
+          {this.state.error !== '' && (
+            <div className="alert alert-danger">
+              <p>{this.state.error}</p>
+            </div>
+          )}
+          <SelectList
+            elements={this.state.groups.map(group => group.name)}
+            onSearch={this.handleSearch}
+            onSelect={this.handleSelect}
+            onUnselect={this.handleUnselect}
+            renderElement={this.renderElement}
+            selectedElements={this.state.selectedGroups}
+          />
         </div>
 
         <footer className="modal-foot">
diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx
new file mode 100644 (file)
index 0000000..c57fc9a
--- /dev/null
@@ -0,0 +1,128 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as React from 'react';
+import SelectListListContainer from './SelectListListContainer';
+import { translate } from '../../helpers/l10n';
+import SearchBox from '../controls/SearchBox';
+import RadioToggle from '../controls/RadioToggle';
+import './styles.css';
+
+export enum Filter {
+  All = 'all',
+  Selected = 'selected',
+  Unselected = 'deselected'
+}
+
+interface Props {
+  elements: string[];
+  disabledElements?: string[];
+  labelSelected?: string;
+  labelUnselected?: string;
+  labelAll?: string;
+  onSearch: (query: string, tab: Filter) => Promise<void>;
+  onSelect: (element: string) => Promise<void>;
+  onUnselect: (element: string) => Promise<void>;
+  renderElement: (element: string) => React.ReactNode;
+  selectedElements: string[];
+}
+
+interface State {
+  filter: Filter;
+  loading: boolean;
+  query: string;
+}
+
+export default class SelectList extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = { filter: Filter.Selected, loading: false, query: '' };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  stopLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  changeFilter = (filter: Filter) => {
+    this.setState({ filter, loading: true });
+    this.props.onSearch(this.state.query, filter).then(this.stopLoading, this.stopLoading);
+  };
+
+  handleQueryChange = (query: string) => {
+    this.setState({ loading: true, query });
+    this.props.onSearch(query, this.getFilter()).then(this.stopLoading, this.stopLoading);
+  };
+
+  getFilter = () => {
+    return this.state.query === '' ? this.state.filter : Filter.All;
+  };
+
+  render() {
+    const {
+      labelSelected = translate('selected'),
+      labelUnselected = translate('unselected'),
+      labelAll = translate('all')
+    } = this.props;
+    const { filter } = this.state;
+
+    const disabled = this.state.query !== '';
+
+    return (
+      <div className="select-list">
+        <div className="display-flex-center">
+          <RadioToggle
+            className="spacer-right"
+            name="filter"
+            onCheck={this.changeFilter}
+            options={[
+              { disabled, label: labelSelected, value: Filter.Selected },
+              { disabled, label: labelUnselected, value: Filter.Unselected },
+              { disabled, label: labelAll, value: Filter.All }
+            ]}
+            value={filter}
+          />
+          <SearchBox
+            autoFocus={true}
+            loading={this.state.loading}
+            onChange={this.handleQueryChange}
+            placeholder={translate('search_verb')}
+            value={this.state.query}
+          />
+        </div>
+        <SelectListListContainer
+          disabledElements={this.props.disabledElements || []}
+          elements={this.props.elements}
+          filter={this.getFilter()}
+          onSelect={this.props.onSelect}
+          onUnselect={this.props.onUnselect}
+          renderElement={this.props.renderElement}
+          selectedElements={this.props.selectedElements}
+        />
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx
new file mode 100644 (file)
index 0000000..ac54e39
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as React from 'react';
+import { Filter } from './SelectList';
+import SelectListListElement from './SelectListListElement';
+
+interface Props {
+  elements: string[];
+  disabledElements: string[];
+  filter: Filter;
+  onSelect: (element: string) => Promise<void>;
+  onUnselect: (element: string) => Promise<void>;
+  renderElement: (element: string) => React.ReactNode;
+  selectedElements: string[];
+}
+
+export default class SelectListListContainer extends React.PureComponent<Props> {
+  isDisabled = (element: string): boolean => {
+    return this.props.disabledElements.includes(element);
+  };
+
+  isSelected = (element: string): boolean => {
+    return this.props.selectedElements.includes(element);
+  };
+
+  render() {
+    const { 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="select-list-list-container spacer-top">
+        <ul className="menu">
+          {filteredElements.map(element => (
+            <SelectListListElement
+              disabled={this.isDisabled(element)}
+              element={element}
+              key={element}
+              onSelect={this.props.onSelect}
+              onUnselect={this.props.onUnselect}
+              renderElement={this.props.renderElement}
+              selected={this.isSelected(element)}
+            />
+          ))}
+        </ul>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx
new file mode 100644 (file)
index 0000000..a0a7afd
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as React from 'react';
+import * as classNames from 'classnames';
+import Checkbox from '../controls/Checkbox';
+
+interface Props {
+  active?: boolean;
+  disabled?: boolean;
+  element: string;
+  onSelect: (element: string) => Promise<void>;
+  onUnselect: (element: string) => Promise<void>;
+  renderElement: (element: string) => React.ReactNode;
+  selected: boolean;
+}
+
+interface State {
+  loading: boolean;
+}
+
+export default class SelectListListElement extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = { loading: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  stopLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  handleCheck = (checked: boolean) => {
+    this.setState({ loading: true });
+    const request = checked ? this.props.onSelect : this.props.onUnselect;
+    request(this.props.element).then(this.stopLoading, this.stopLoading);
+  };
+
+  render() {
+    return (
+      <li>
+        <Checkbox
+          checked={this.props.selected}
+          className={classNames({ active: this.props.active })}
+          disabled={this.props.disabled}
+          onCheck={this.handleCheck}>
+          <span className="little-spacer-left">{this.props.renderElement(this.props.element)}</span>
+        </Checkbox>
+      </li>
+    );
+  }
+}
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
new file mode 100644 (file)
index 0000000..0e27e47
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as React from 'react';
+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);
+  expect(wrapper.state().filter).toBe(Filter.Selected);
+});
+
+it('should display a loader when searching', async () => {
+  const wrapper = shallow(selectList);
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.state().loading).toBe(false);
+
+  (wrapper.instance() as SelectList).handleQueryChange('');
+  expect(wrapper.state().loading).toBe(true);
+  expect(wrapper).toMatchSnapshot();
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().loading).toBe(false);
+});
+
+it('should display a loader when updating filter', async () => {
+  const wrapper = shallow(selectList);
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.state().loading).toBe(false);
+
+  (wrapper.instance() as SelectList).changeFilter(Filter.Unselected);
+  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 = shallow(selectList);
+
+  wrapper.setState({ filter: Filter.Selected });
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.setState({ query: 'foobar' });
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx
new file mode 100644 (file)
index 0000000..ef8440f
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as React from 'react';
+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);
+  expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx
new file mode 100644 (file)
index 0000000..d059fd5
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as React from 'react';
+import { shallow } from 'enzyme';
+import SelectListListElement from '../SelectListListElement';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+
+const listElement = (
+  <SelectListListElement
+    element={'foo'}
+    key={'foo'}
+    onSelect={jest.fn(() => Promise.resolve())}
+    onUnselect={jest.fn(() => Promise.resolve())}
+    renderElement={(foo: string) => foo}
+    selected={false}
+  />
+);
+
+it('should display a loader when checking', async () => {
+  const wrapper = shallow(listElement);
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.state().loading).toBe(false);
+
+  (wrapper.instance() as SelectListListElement).handleCheck(true);
+  expect(wrapper.state().loading).toBe(true);
+  expect(wrapper).toMatchSnapshot();
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().loading).toBe(false);
+});
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
new file mode 100644 (file)
index 0000000..1ed4160
--- /dev/null
@@ -0,0 +1,379 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should cancel filter selection when search is active 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",
+      ]
+    }
+  />
+</div>
+`;
+
+exports[`should cancel filter selection when search is active 2`] = `
+<div
+  className="select-list"
+>
+  <div
+    className="display-flex-center"
+  >
+    <RadioToggle
+      className="spacer-right"
+      disabled={false}
+      name="filter"
+      onCheck={[Function]}
+      options={
+        Array [
+          Object {
+            "disabled": true,
+            "label": "selected",
+            "value": "selected",
+          },
+          Object {
+            "disabled": true,
+            "label": "unselected",
+            "value": "deselected",
+          },
+          Object {
+            "disabled": true,
+            "label": "all",
+            "value": "all",
+          },
+        ]
+      }
+      value="selected"
+    />
+    <SearchBox
+      autoFocus={true}
+      loading={false}
+      onChange={[Function]}
+      placeholder="search_verb"
+      value="foobar"
+    />
+  </div>
+  <SelectListListContainer
+    disabledElements={Array []}
+    elements={
+      Array [
+        "foo",
+        "bar",
+        "baz",
+      ]
+    }
+    filter="all"
+    onSelect={[MockFunction]}
+    onUnselect={[MockFunction]}
+    renderElement={[Function]}
+    selectedElements={
+      Array [
+        "foo",
+      ]
+    }
+  />
+</div>
+`;
+
+exports[`should display a loader when searching 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",
+      ]
+    }
+  />
+</div>
+`;
+
+exports[`should display a loader when searching 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",
+      ]
+    }
+  />
+</div>
+`;
+
+exports[`should display a loader when updating filter 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",
+      ]
+    }
+  />
+</div>
+`;
+
+exports[`should display a loader when updating filter 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",
+      ]
+    }
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap
new file mode 100644 (file)
index 0000000..9f946d3
--- /dev/null
@@ -0,0 +1,88 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display elements based on filters 1`] = `
+<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}
+    />
+    <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 2`] = `
+<div
+  className="select-list-list-container spacer-top"
+>
+  <ul
+    className="menu"
+  >
+    <SelectListListElement
+      disabled={false}
+      element="bar"
+      key="bar"
+      onSelect={[MockFunction]}
+      onUnselect={[MockFunction]}
+      renderElement={[Function]}
+      selected={false}
+    />
+    <SelectListListElement
+      disabled={false}
+      element="baz"
+      key="baz"
+      onSelect={[MockFunction]}
+      onUnselect={[MockFunction]}
+      renderElement={[Function]}
+      selected={false}
+    />
+  </ul>
+</div>
+`;
+
+exports[`should display elements based on filters 3`] = `
+<div
+  className="select-list-list-container spacer-top"
+>
+  <ul
+    className="menu"
+  >
+    <SelectListListElement
+      disabled={false}
+      element="foo"
+      key="foo"
+      onSelect={[MockFunction]}
+      onUnselect={[MockFunction]}
+      renderElement={[Function]}
+      selected={true}
+    />
+  </ul>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap
new file mode 100644 (file)
index 0000000..28b25b2
--- /dev/null
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display a loader when checking 1`] = `
+<li>
+  <Checkbox
+    checked={false}
+    className=""
+    onCheck={[Function]}
+    thirdState={false}
+  >
+    <span
+      className="little-spacer-left"
+    >
+      foo
+    </span>
+  </Checkbox>
+</li>
+`;
+
+exports[`should display a loader when checking 2`] = `
+<li>
+  <Checkbox
+    checked={false}
+    className=""
+    onCheck={[Function]}
+    thirdState={false}
+  >
+    <span
+      className="little-spacer-left"
+    >
+      foo
+    </span>
+  </Checkbox>
+</li>
+`;
diff --git a/server/sonar-web/src/main/js/components/SelectList/index.js b/server/sonar-web/src/main/js/components/SelectList/index.js
deleted file mode 100644 (file)
index f8b47f5..0000000
+++ /dev/null
@@ -1,460 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 $ from 'jquery';
-import Backbone from 'backbone';
-import { debounce, throttle } from 'lodash';
-import escapeHtml from 'escape-html';
-import ItemTemplate from './templates/item.hbs';
-import ListTemplate from './templates/list.hbs';
-import { translate } from '../../helpers/l10n';
-import './styles.css';
-import '../controls/SearchBox.css';
-
-let showError = null;
-
-/*
- * SelectList Collection
- */
-
-const SelectListCollection = Backbone.Collection.extend({
-  initialize(options) {
-    this.options = options;
-  },
-
-  parse(r) {
-    return this.options.parse.call(this, r);
-  },
-
-  fetch(options) {
-    const data = $.extend(
-      {
-        page: 1,
-        pageSize: 100
-      },
-      options.data || {}
-    );
-    const settings = $.extend({}, options, { data });
-
-    this.settings = {
-      url: settings.url,
-      data
-    };
-
-    Backbone.Collection.prototype.fetch.call(this, settings);
-  },
-
-  fetchNextPage(options) {
-    if (this.more) {
-      const nextPage = this.settings.data.page + 1;
-      const settings = $.extend(this.settings, options);
-
-      settings.data.page = nextPage;
-      settings.remove = false;
-      this.fetch(settings);
-    } else {
-      options.error();
-    }
-  }
-});
-
-/*
- * SelectList Item View
- */
-
-const SelectListItemView = Backbone.View.extend({
-  tagName: 'li',
-  template: ItemTemplate,
-
-  events: {
-    'change .select-list-list-checkbox': 'toggle'
-  },
-
-  initialize(options) {
-    this.listenTo(this.model, 'change', this.render);
-    this.settings = options.settings;
-  },
-
-  render() {
-    this.$el.html(this.template(this.settings.dangerouslyUnescapedHtmlFormat(this.model.toJSON())));
-    this.$('input').prop('name', this.model.get('name'));
-    this.$el.toggleClass('selected', this.model.get('selected'));
-    this.$('.select-list-list-checkbox')
-      .prop(
-        'title',
-        this.model.get('selected') ? this.settings.tooltips.deselect : this.settings.tooltips.select
-      )
-      .prop('checked', this.model.get('selected'));
-
-    if (this.settings.readOnly) {
-      this.$('.select-list-list-checkbox').prop('disabled', true);
-    }
-  },
-
-  remove(postpone) {
-    if (postpone) {
-      this.$el.addClass(this.model.get('selected') ? 'added' : 'removed');
-      setTimeout(() => {
-        Backbone.View.prototype.remove.call(this, arguments);
-      }, 500);
-    } else {
-      Backbone.View.prototype.remove.call(this, arguments);
-    }
-  },
-
-  toggle() {
-    const selected = this.model.get('selected');
-    const that = this;
-    const url = selected ? this.settings.deselectUrl : this.settings.selectUrl;
-    const data = $.extend({}, this.settings.extra || {});
-
-    data[this.settings.selectParameter] = this.model.get(this.settings.selectParameterValue);
-
-    that.$el.addClass('progress');
-    $.ajax({
-      url,
-      data,
-      type: 'POST',
-      statusCode: {
-        // do not show global error
-        400: null,
-        403: null,
-        500: null
-      }
-    })
-      .done(() => {
-        that.model.set('selected', !selected);
-      })
-      .fail(jqXHR => {
-        that.render();
-        showError(jqXHR);
-      })
-      .always(() => {
-        that.$el.removeClass('progress');
-      });
-  }
-});
-
-/*
- * SelectList View
- */
-
-const SelectListView = Backbone.View.extend({
-  template: ListTemplate,
-
-  events: {
-    'click .select-list-control-button[name=selected]': 'showSelected',
-    'click .select-list-control-button[name=deselected]': 'showDeselected',
-    'click .select-list-control-button[name=all]': 'showAll',
-    'click .js-reset': 'onResetClick'
-  },
-
-  initialize(options) {
-    this.listenTo(this.collection, 'add', this.renderListItem);
-    this.listenTo(this.collection, 'reset', this.renderList);
-    this.listenTo(this.collection, 'remove', this.removeModel);
-    this.listenTo(this.collection, 'change:selected', this.confirmFilter);
-    this.settings = options.settings;
-
-    const that = this;
-    this.showFetchSpinner = function() {
-      that.$listContainer.addClass('loading');
-    };
-    this.hideFetchSpinner = function() {
-      that.$listContainer.removeClass('loading');
-    };
-
-    const onScroll = function() {
-      that.showFetchSpinner();
-
-      that.collection.fetchNextPage({
-        success() {
-          that.hideFetchSpinner();
-        },
-        error() {
-          that.hideFetchSpinner();
-        }
-      });
-    };
-    this.onScroll = throttle(onScroll, 1000);
-  },
-
-  render() {
-    const that = this;
-    const keyup = function() {
-      that.search();
-    };
-
-    this.$el.html(this.template(this.settings.labels)).width(this.settings.width);
-
-    this.$listContainer = this.$('.select-list-list-container');
-    if (!this.settings.readOnly) {
-      this.$listContainer
-        .height(this.settings.height)
-        .css('overflow', 'auto')
-        .on('scroll', () => {
-          that.scroll();
-        });
-    } else {
-      this.$listContainer.addClass('select-list-list-container-readonly');
-    }
-
-    this.$list = this.$('.select-list-list');
-
-    const searchInput = this.$('.select-list-search-control input')
-      .on('keyup', debounce(keyup, 250))
-      .on('search', debounce(keyup, 250));
-
-    if (this.settings.focusSearch) {
-      setTimeout(() => {
-        searchInput.focus();
-      }, 250);
-    }
-
-    this.listItemViews = [];
-
-    showError = function(jqXHR) {
-      let message = translate('default_error_message');
-      if (jqXHR != null && jqXHR.responseJSON != null && jqXHR.responseJSON.errors != null) {
-        message = jqXHR.responseJSON.errors.map(e => e.msg).join('. ');
-      }
-
-      that.$el.prevAll('.alert').remove();
-      $('<div>')
-        .addClass('alert alert-danger')
-        .text(message)
-        .insertBefore(that.$el);
-    };
-
-    if (this.settings.readOnly) {
-      this.$('.select-list-control').remove();
-    }
-  },
-
-  renderList() {
-    this.listItemViews.forEach(view => {
-      view.remove();
-    });
-    this.listItemViews = [];
-    if (this.collection.length > 0) {
-      this.collection.each(this.renderListItem, this);
-    } else if (this.settings.readOnly) {
-      this.renderEmpty();
-    }
-    this.$listContainer.scrollTop(0);
-  },
-
-  renderListItem(item) {
-    const itemView = new SelectListItemView({
-      model: item,
-      settings: this.settings
-    });
-    this.listItemViews.push(itemView);
-    this.$list.append(itemView.el);
-    itemView.render();
-  },
-
-  renderEmpty() {
-    this.$list.append(`<li class="empty-message">${this.settings.labels.noResults}</li>`);
-  },
-
-  confirmFilter(model) {
-    if (this.currentFilter !== 'all') {
-      this.collection.remove(model);
-    }
-  },
-
-  removeModel(model, collection, options) {
-    this.listItemViews[options.index].remove(true);
-    this.listItemViews.splice(options.index, 1);
-  },
-
-  filterBySelection(filter) {
-    const that = this;
-    filter = this.currentFilter = filter || this.currentFilter;
-
-    if (filter != null) {
-      this.$('.select-list-check-control').toggleClass('disabled', false);
-      this.$('.select-list-search-control').toggleClass('disabled', true);
-      this.$('.select-list-search-control input').val('');
-
-      this.$('.select-list-control-button')
-        .removeClass('active')
-        .filter(`[name=${filter}]`)
-        .addClass('active');
-
-      this.showFetchSpinner();
-
-      this.collection.fetch({
-        url: this.settings.searchUrl,
-        reset: true,
-        data: { selected: filter },
-        success() {
-          that.hideFetchSpinner();
-        },
-        error: showError
-      });
-    }
-  },
-
-  showSelected() {
-    this.filterBySelection('selected');
-  },
-
-  showDeselected() {
-    this.filterBySelection('deselected');
-  },
-
-  showAll() {
-    this.filterBySelection('all');
-  },
-
-  search() {
-    const query = this.$('.select-list-search-control input').val();
-    const hasQuery = query.length > 0;
-    const that = this;
-    const data = {};
-
-    this.$('.select-list-check-control').toggleClass('disabled', hasQuery);
-    this.$('.select-list-search-control').toggleClass('disabled', !hasQuery);
-    this.$('.js-reset').toggleClass('hidden', !hasQuery);
-
-    if (hasQuery) {
-      this.showFetchSpinner();
-      this.currentFilter = 'all';
-
-      data[this.settings.queryParam] = query;
-      data.selected = 'all';
-      this.collection.fetch({
-        data,
-        url: this.settings.searchUrl,
-        reset: true,
-        success() {
-          that.hideFetchSpinner();
-        },
-        error: showError
-      });
-    } else {
-      this.filterBySelection();
-    }
-  },
-
-  onResetClick(e) {
-    e.preventDefault();
-    e.currentTarget.blur();
-    this.$('.select-list-search-control input')
-      .val('')
-      .focus()
-      .trigger('search');
-  },
-
-  searchByQuery(query) {
-    this.$('.select-list-search-control input').val(query);
-    this.search();
-  },
-
-  clearSearch() {
-    this.filterBySelection();
-  },
-
-  scroll() {
-    const scrollBottom =
-      this.$listContainer.scrollTop() >=
-      this.$list[0].scrollHeight - this.$listContainer.outerHeight();
-
-    if (scrollBottom && this.collection.more) {
-      this.onScroll();
-    }
-  }
-});
-
-/*
- * SelectList Entry Point
- */
-
-const SelectList = function(options) {
-  this.settings = $.extend(this.defaults, options);
-
-  this.collection = new SelectListCollection({
-    parse: this.settings.parse
-  });
-
-  this.view = new SelectListView({
-    el: this.settings.el,
-    collection: this.collection,
-    settings: this.settings
-  });
-
-  this.view.render();
-  this.filter('selected');
-  return this;
-};
-
-/*
- * SelectList API Methods
- */
-
-SelectList.prototype.filter = function(filter) {
-  this.view.filterBySelection(filter);
-  return this;
-};
-
-SelectList.prototype.search = function(query) {
-  this.view.searchByQuery(query);
-  return this;
-};
-
-/*
- * SelectList Defaults
- */
-
-SelectList.prototype.defaults = {
-  width: '50%',
-  height: 400,
-
-  readOnly: false,
-  focusSearch: true,
-
-  dangerouslyUnescapedHtmlFormat(item) {
-    return escapeHtml(item.value);
-  },
-
-  parse(r) {
-    this.more = r.more;
-    return r.results;
-  },
-
-  queryParam: 'query',
-
-  labels: {
-    selected: 'Selected',
-    deselected: 'Deselected',
-    all: 'All',
-    noResults: ''
-  },
-
-  tooltips: {
-    select: 'Click this to select item',
-    deselect: 'Click this to deselect item'
-  },
-
-  errorMessage: 'Something gone wrong, try to reload the page and try again.'
-};
-
-export default SelectList;
index 89aa7d6e551418de503bba819de23c27f7851c45..8f44b2e61ae4ad1ff9849401e9cf989436bf8e56 100644 (file)
@@ -17,6 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+.select-list {
+}
+
 .select-list-container {
   min-width: 500px;
   box-sizing: border-box;
@@ -30,6 +33,8 @@
 .select-list-list-container {
   border: 1px solid #bfbfbf;
   box-sizing: border-box;
+  height: 400px;
+  overflow: auto;
 }
 
 .select-list-list-container.loading .select-list-list {
diff --git a/server/sonar-web/src/main/js/components/SelectList/templates/item.hbs b/server/sonar-web/src/main/js/components/SelectList/templates/item.hbs
deleted file mode 100644 (file)
index 95655f6..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-<input class="select-list-list-checkbox" type="checkbox">
-<div class="select-list-list-item">{{{this}}}</div>
diff --git a/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs b/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs
deleted file mode 100644 (file)
index fe93794..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<div class="select-list-container">
-  <div class="select-list-control">
-    <div class="select-list-check-control">
-      <a class="select-list-control-button" name="selected">{{this.selected}}</a><a class="select-list-control-button" name="deselected">{{this.deselected}}</a><a class="select-list-control-button" name="all">{{this.all}}</a>
-    </div>
-    <div class="select-list-search-control">
-      <div class="search-box">
-        <input class="search-box-input" type="text" name="q" placeholder="{{t 'search_verb'}}" maxlength="100" autocomplete="off">
-        <svg class="search-box-magnifier" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
-          <g transform="matrix(0.0288462,0,0,0.0288462,2,1.07692)">
-            <path d="M288,208C288,177.167 277.042,150.792 255.125,128.875C233.208,106.958 206.833,96 176,96C145.167,96 118.792,106.958 96.875,128.875C74.958,150.792 64,177.167 64,208C64,238.833 74.958,265.208 96.875,287.125C118.792,309.042 145.167,320 176,320C206.833,320 233.208,309.042 255.125,287.125C277.042,265.208 288,238.833 288,208ZM416,416C416,424.667 412.833,432.167 406.5,438.5C400.167,444.833 392.667,448 384,448C375,448 367.5,444.833 361.5,438.5L275.75,353C245.917,373.667 212.667,384 176,384C152.167,384 129.375,379.375 107.625,370.125C85.875,360.875 67.125,348.375 51.375,332.625C35.625,316.875 23.125,298.125 13.875,276.375C4.625,254.625 0,231.833 0,208C0,184.167 4.625,161.375 13.875,139.625C23.125,117.875 35.625,99.125 51.375,83.375C67.125,67.625 85.875,55.125 107.625,45.875C129.375,36.625 152.167,32 176,32C199.833,32 222.625,36.625 244.375,45.875C266.125,55.125 284.875,67.625 300.625,83.375C316.375,99.125 328.875,117.875 338.125,139.625C347.375,161.375 352,184.167 352,208C352,244.667 341.667,277.917 321,307.75L406.75,393.5C412.917,399.667 416,407.167 416,416Z" style="fill:currentColor;fill-rule:nonzero;"/>
-          </g>
-        </svg>
-        <button class="js-reset hidden button-tiny search-box-clear button-icon" style="color: rgb(153, 153, 153);" type="reset">
-          <svg width="12" height="12" viewBox="0 0 16 16" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve">
-            <path d="M14 4.242L11.758 2l-3.76 3.76L4.242 2 2 4.242l3.756 3.756L2 11.758 4.242 14l3.756-3.76 3.76 3.76L14 11.758l-3.76-3.76L14 4.242z" style="fill: currentcolor;"/>
-          </svg>
-        </button>
-      </div>
-    </div>
-  </div>
-  <div class="select-list-list-container">
-    <ul class="select-list-list"></ul>
-  </div>
-</div>
index cb5513b2b3929ebab6347d512d5680c60b233c5d..2c174010ac9d41fb2c5b64296a1e726fa816c063 100644 (file)
@@ -22,6 +22,7 @@ import * as classNames from 'classnames';
 
 interface Props {
   checked: boolean;
+  disabled?: boolean;
   children?: React.ReactNode;
   className?: string;
   id?: string;
@@ -34,24 +35,29 @@ export default class Checkbox extends React.PureComponent<Props> {
     thirdState: false
   };
 
-  handleClick = (e: React.SyntheticEvent<HTMLElement>) => {
-    e.preventDefault();
-    e.currentTarget.blur();
-    this.props.onCheck(!this.props.checked, this.props.id);
+  handleClick = (event: React.SyntheticEvent<HTMLElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    if (!this.props.disabled) {
+      this.props.onCheck(!this.props.checked, this.props.id);
+    }
   };
 
   render() {
     const className = classNames('icon-checkbox', {
       'icon-checkbox-checked': this.props.checked,
-      'icon-checkbox-single': this.props.thirdState
+      'icon-checkbox-single': this.props.thirdState,
+      'icon-checkbox-disabled': this.props.disabled
     });
 
     if (this.props.children) {
       return (
         <a
-          id={this.props.id}
-          className={classNames('link-checkbox', this.props.className)}
+          className={classNames('link-checkbox', this.props.className, {
+            'text-muted': this.props.disabled
+          })}
           href="#"
+          id={this.props.id}
           onClick={this.handleClick}>
           <i className={className} />
           {this.props.children}
@@ -61,9 +67,9 @@ export default class Checkbox extends React.PureComponent<Props> {
 
     return (
       <a
-        id={this.props.id}
         className={classNames(className, this.props.className)}
         href="#"
+        id={this.props.id}
         onClick={this.handleClick}
       />
     );
index 69ceedbeeb85a9b075ba28ba23efcd785c04a598..21ff253d8981f0317b946b59dfaca3589e640245 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import * as classNames from 'classnames';
 import Tooltip from './Tooltip';
 
 interface Option {
@@ -28,6 +29,7 @@ interface Option {
 }
 
 interface Props {
+  className?: string;
   name: string;
   onCheck: (value: string) => void;
   options: Option[];
@@ -71,6 +73,10 @@ export default class RadioToggle extends React.PureComponent<Props> {
   };
 
   render() {
-    return <ul className="radio-toggle">{this.props.options.map(this.renderOption)}</ul>;
+    return (
+      <ul className={classNames('radio-toggle', this.props.className)}>
+        {this.props.options.map(this.renderOption)}
+      </ul>
+    );
   }
 }
index f34d80e83fc020794e32493a079d83840404caad..3804325fe1340b8cfc171bd9a7bc15c0ef70d785 100644 (file)
@@ -32,14 +32,19 @@ it('should render checked', () => {
   expect(checkbox.is('.icon-checkbox-checked')).toBe(true);
 });
 
+it('should render disabled', () => {
+  const checkbox = shallow(<Checkbox checked={true} disabled={true} onCheck={() => true} />);
+  expect(checkbox.is('.icon-checkbox-disabled')).toBe(true);
+});
+
 it('should render unchecked third state', () => {
-  const checkbox = shallow(<Checkbox checked={false} thirdState={true} onCheck={() => true} />);
+  const checkbox = shallow(<Checkbox checked={false} onCheck={() => true} thirdState={true} />);
   expect(checkbox.is('.icon-checkbox-single')).toBe(true);
   expect(checkbox.is('.icon-checkbox-checked')).toBe(false);
 });
 
-it('should render checked  third state', () => {
-  const checkbox = shallow(<Checkbox checked={true} thirdState={true} onCheck={() => true} />);
+it('should render checked third state', () => {
+  const checkbox = shallow(<Checkbox checked={true} onCheck={() => true} thirdState={true} />);
   expect(checkbox.is('.icon-checkbox-single')).toBe(true);
   expect(checkbox.is('.icon-checkbox-checked')).toBe(true);
 });
@@ -61,16 +66,23 @@ it('should call onCheck', () => {
   expect(onCheck).toBeCalledWith(true, undefined);
 });
 
+it('should not call onCheck when disabled', () => {
+  const onCheck = jest.fn();
+  const checkbox = shallow(<Checkbox checked={false} disabled={true} onCheck={onCheck} />);
+  click(checkbox);
+  expect(onCheck).toHaveBeenCalledTimes(0);
+});
+
 it('should call onCheck with id as second parameter', () => {
   const onCheck = jest.fn();
-  const checkbox = shallow(<Checkbox id="foo" checked={false} onCheck={onCheck} />);
+  const checkbox = shallow(<Checkbox checked={false} id="foo" onCheck={onCheck} />);
   click(checkbox);
   expect(onCheck).toBeCalledWith(true, 'foo');
 });
 
 it('should apply custom class', () => {
   const checkbox = shallow(
-    <Checkbox className="customclass" checked={true} onCheck={() => true} />
+    <Checkbox checked={true} className="customclass" onCheck={() => true} />
   );
   expect(checkbox.is('.customclass')).toBe(true);
 });
index caf4db6930431d003ba6adabeb052962c258d52a..3610dd3c107bb41279a748737af04dd8ae90703f 100644 (file)
@@ -145,6 +145,7 @@ save=Save
 search_verb=Search
 see_all=See All
 select_verb=Select
+selected=Selected
 set=Set
 severity=Severity
 shared=Shared
@@ -173,6 +174,7 @@ updated=Updated
 updated_on=Updated on
 update_verb=Update
 updating=Updating
+unselected=Unselected
 user=User
 value=Value
 variation=Variation
@@ -2844,4 +2846,4 @@ webhooks.url=URL
 webhooks.url.bad_format=Bad format of URL.
 webhooks.url.bad_protocol=URL must start with "http://" or "https://".
 webhooks.url.description=Server endpoint that will receive the webhook payload, for example: "http://my_server/foo". If HTTP Basic authentication is used, HTTPS is recommended to avoid man in the middle attacks. Example: "https://myLogin:myPassword@my_server/foo"
-webhooks.url.required=URL is required.
\ No newline at end of file
+webhooks.url.required=URL is required.